veza/veza-backend-api/internal/validators/url_validator.go
senke 29cb93767f feat(security): open-redirect protection on Stripe Connect + KYC return URLs
v1.0.10 sécu item 7. The SSRF audit flagged callbacks on Hyperswitch +
distribution submissions ; investigating those revealed a different
risk class on the user-supplied return_url fields :

  * sell_handler.ConnectOnboard accepts return_url + refresh_url and
    forwards them to Stripe Connect.
  * kyc_handler.StartVerification accepts return_url and forwards it
    to Stripe Identity.

Stripe doesn't fetch these URLs server-side (so SSRF is not the
risk), but it redirects the user's browser there after the flow
completes. Without an allow-list, an attacker can craft an onboarding
or verification link with `return_url=https://attacker.com/phishing`
and a victim who clicks the resulting Stripe URL lands on the
attacker's page after Stripe finishes — open-redirect attack
disguised as a legitimate Stripe flow.

Hyperswitch + distribution were already protected :
  * Webhook URLs go through validators.ValidateWebhookURL
    (services/webhook_service.go:54) which blocks private IPs +
    requires HTTPS — pre-existing SSRF guard from SEC-07.
  * Hyperswitch's own callback URL is configured server-side, not
    user-supplied (cf. hyperswitch/client.go) — no SSRF surface.
  * Distribution submissions don't carry user-supplied callbacks —
    the destination platforms are hard-coded.

What's added :

  validators/url_validator.go
    * ValidateRedirectURL(rawURL, allowedHosts) — accepts http or
      https (since Stripe-redirect targets may be local dev hosts),
      requires hostname to match one of allowedHosts exactly OR be
      a subdomain of one. Empty allowedHosts ⇒ permissive (used in
      dev / unconfigured envs ; only checks for non-internal IPs).
    * Reuses the existing IsInternalOrPrivateURL guard so SSRF
      protection still applies for the permissive branch.

  handlers/sell_handler.go + handlers/kyc_handler.go
    * Both handlers now take an allowedRedirectHosts []string param
      at construction. Validation runs after the URL defaults are
      applied so the caller's submitted URL is checked, not the
      backend-derived fallback.
    * Validation failure → 400 with a clear message ("invalid
      return_url: <reason>") so the SPA can render the right error.

  api/routes_marketplace.go
    * Both handlers receive the existing
      cfg.OAuthAllowedRedirectDomains list at construction. Same
      list as the OAuth callback validation, same operator config,
      single source of truth.

Tests pass : go test ./internal/{handlers,validators} -short.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 11:42:41 +02:00

159 lines
4.5 KiB
Go

package validators
import (
"fmt"
"net"
"net/url"
"strings"
)
// blockedHostnames contains hostnames that must never be used as webhook targets (SSRF protection).
var blockedHostnames = []string{
"localhost",
"127.0.0.1",
"metadata",
"metadata.google.internal",
"metadata.google",
"instance-data",
"169.254.169.254",
}
// IsInternalOrPrivateURL returns true if the URL targets an internal or private address (SSRF risk).
// Blocks: localhost, 127.0.0.1, 10.x, 172.16-31.x, 192.168.x, 169.254.x (cloud metadata).
func IsInternalOrPrivateURL(rawURL string) bool {
parsed, err := url.Parse(rawURL)
if err != nil {
return true // Invalid URL treated as unsafe
}
scheme := strings.ToLower(parsed.Scheme)
if scheme != "http" && scheme != "https" {
return true
}
hostname := strings.ToLower(parsed.Hostname())
if hostname == "" {
return true
}
for _, blocked := range blockedHostnames {
if hostname == blocked {
return true
}
}
ips, err := net.LookupIP(hostname)
if err != nil {
return true // Resolution failure treated as unsafe
}
for _, ip := range ips {
if isPrivateIP(ip) {
return true
}
}
return false
}
func isPrivateIP(ip net.IP) bool {
privateRanges := []string{
"10.0.0.0/8", // RFC1918
"172.16.0.0/12", // RFC1918
"192.168.0.0/16", // RFC1918
"127.0.0.0/8", // Loopback
"169.254.0.0/16", // Link-local / cloud metadata
"::1/128", // IPv6 loopback
"fc00::/7", // IPv6 unique local
"fe80::/10", // IPv6 link-local
}
for _, cidrStr := range privateRanges {
_, cidr, err := net.ParseCIDR(cidrStr)
if err != nil {
continue
}
if cidr.Contains(ip) {
return true
}
}
return false
}
// ValidateWebhookURL validates that a webhook URL is safe for registration.
// SEC-07: Only HTTPS allowed. Blocks private/internal IPs (SSRF protection).
func ValidateWebhookURL(rawURL string) error {
parsed, err := url.Parse(rawURL)
if err != nil {
return fmt.Errorf("invalid URL: %w", err)
}
scheme := strings.ToLower(parsed.Scheme)
if scheme != "https" {
return fmt.Errorf("only https URLs are allowed for webhooks (got %q)", parsed.Scheme)
}
hostname := strings.ToLower(parsed.Hostname())
if hostname == "" {
return fmt.Errorf("URL must have a hostname")
}
if IsInternalOrPrivateURL(rawURL) {
return fmt.Errorf("URL targets internal or private address - not allowed for webhooks")
}
return nil
}
// ValidateRedirectURL validates that a user-supplied redirect URL is
// safe for browser redirection. v1.0.10 sécu item 7 — open-redirect
// protection on Stripe Connect onboarding return_url, KYC return_url,
// and any future user-supplied callback that ends up in a Location
// header or Stripe redirect.
//
// Different from ValidateWebhookURL : the platform does NOT fetch this
// URL server-side (so no SSRF concern), but a user who submits
// `return_url=https://attacker.com/phishing` and gets a victim to
// click the resulting Stripe link will see the victim land on the
// attacker's page after Stripe completes. We require the host to
// match one of `allowedHosts` exactly OR be a subdomain of one of
// them.
//
// Empty allowedHosts ⇒ permissive : only checks that the URL parses
// and uses https. Used as a soft mode for dev / staging where the
// canonical hosts may not be set.
func ValidateRedirectURL(rawURL string, allowedHosts []string) error {
parsed, err := url.Parse(rawURL)
if err != nil {
return fmt.Errorf("invalid URL: %w", err)
}
scheme := strings.ToLower(parsed.Scheme)
if scheme != "https" && scheme != "http" {
return fmt.Errorf("redirect URL must use http or https (got %q)", parsed.Scheme)
}
hostname := strings.ToLower(parsed.Hostname())
if hostname == "" {
return fmt.Errorf("URL must have a hostname")
}
// In production we want HTTPS only ; staging / dev may relax to
// http for localhost / .local domains. The handler chooses the
// allowedHosts list per-environment.
if len(allowedHosts) == 0 {
// Permissive mode (dev / unconfigured) — accept any
// well-formed URL with a non-internal hostname.
if IsInternalOrPrivateURL(rawURL) {
return fmt.Errorf("redirect URL targets internal or private address")
}
return nil
}
for _, allowed := range allowedHosts {
allowed = strings.ToLower(strings.TrimSpace(allowed))
if allowed == "" {
continue
}
if hostname == allowed {
return nil
}
// Subdomain match : foo.veza.fr matches "veza.fr".
if strings.HasSuffix(hostname, "."+allowed) {
return nil
}
}
return fmt.Errorf("redirect URL host %q is not in the allow-list", hostname)
}