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>
159 lines
4.5 KiB
Go
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)
|
|
}
|