From 611c561c887d87a3687000a5b2f496a3db25bcd0 Mon Sep 17 00:00:00 2001 From: John Wang Date: Mon, 9 Nov 2026 12:15:19 -0800 Subject: [PATCH 1/2] fix(stripe): treat names under localhost as loopback hosts RFC 6761 reserves names under localhost for the loopback interface, so a local development setup that serves its apps on names such as auth.localhost can return a test-mode checkout to them. A live key still refuses every loopback host. Co-Authored-By: Claude Opus 5.5 --- docs/guide/checkout.md | 5 +++-- stripe/client.go | 10 ++++++---- stripe/review_test.go | 6 +++--- 3 files changed, 12 insertions(+), 9 deletions(-) diff --git a/docs/guide/checkout.md b/docs/guide/checkout.md index b005704..f855be2 100644 --- a/docs/guide/checkout.md +++ b/docs/guide/checkout.md @@ -24,8 +24,9 @@ calls in a row. Success, cancel, return and refresh URLs must be `https`, and may not carry a user name or password. **Set `AllowedURLHosts` to your own hostnames**: a URL that names any other host is refused with `ErrInvalidURL`. With a test-mode key, -`localhost`, `127.0.0.1` and `::1` are also accepted, over `http` too, even when -the list does not name them, for local development. With a live key they are +`localhost` and names under it such as `app.localhost`, `127.0.0.1` and `::1` +are also accepted, over `http` too, even when the list does not name them, for +local development. With a live key they are never accepted: the client refuses a list that names one when it is made, and a URL on one is refused, because a live checkout that returns the buyer to their own machine is always a misconfiguration. Otherwise a URL built from anything a user sent could diff --git a/stripe/client.go b/stripe/client.go index db8541e..d183af4 100644 --- a/stripe/client.go +++ b/stripe/client.go @@ -31,8 +31,9 @@ type Config struct { // catalog may leave it empty. A URL must be https and may not carry a user // name or password. // - // With a test-mode key, localhost, 127.0.0.1 and ::1 are also accepted, over - // http too, even when the list does not name them, for local development. + // With a test-mode key, localhost and names under it (app.localhost), + // 127.0.0.1 and ::1 are also accepted, over http too, even when the list does + // not name them, for local development. // With a live key they are never accepted: New refuses a list that names // one, and a URL on one is refused, since a live checkout that returns the // buyer to their own machine is always a misconfiguration. @@ -159,9 +160,10 @@ func (c *Client) checkURL(raw string) error { } } -// isLoopback reports whether host is localhost or a loopback address. +// isLoopback reports whether host is localhost, a name under it, which RFC 6761 +// reserves for the loopback interface, or a loopback address. func isLoopback(host string) bool { - return host == "localhost" || host == "127.0.0.1" || host == "::1" + return host == "localhost" || strings.HasSuffix(host, ".localhost") || host == "127.0.0.1" || host == "::1" } // withSessionID appends the placeholder Stripe replaces with the checkout diff --git a/stripe/review_test.go b/stripe/review_test.go index 82c05fe..ffc742e 100644 --- a/stripe/review_test.go +++ b/stripe/review_test.go @@ -111,7 +111,7 @@ func TestURLsAreHttpsUnlessLocalAndCanBeLimitedToHosts(t *testing.T) { _, err := c.CheckoutPlan(ctx, in) return err } - for _, ok := range []string{"https://app.example.com/b", "http://localhost:3000/b", "http://127.0.0.1/b"} { + for _, ok := range []string{"https://app.example.com/b", "http://localhost:3000/b", "http://auth.localhost:8080/b", "http://127.0.0.1/b"} { if err := at(open, ok); err != nil { t.Errorf("%s: %v", ok, err) } @@ -370,7 +370,7 @@ func TestALiveKeyNeverRedirectsToLoopback(t *testing.T) { spec := load(t, "subscription-tool.json") const liveKey = "sk_live_example" - for _, h := range []string{"localhost", "127.0.0.1"} { + for _, h := range []string{"localhost", "app.localhost", "127.0.0.1"} { if _, err := stripe.New(spec, stripe.Config{SecretKey: liveKey, APIURL: srv.URL, AllowedURLHosts: []string{"app.example.com", h}}); err == nil { t.Errorf("a live key with %q allowed was accepted", h) } @@ -383,7 +383,7 @@ func TestALiveKeyNeverRedirectsToLoopback(t *testing.T) { if live.Mode() != stripe.ModeLive { t.Fatalf("mode = %q, want live", live.Mode()) } - for _, bad := range []string{"http://localhost:3000/b", "https://localhost/b", "http://127.0.0.1/b", "http://[::1]:8080/b"} { + for _, bad := range []string{"http://localhost:3000/b", "https://localhost/b", "http://app.localhost:8080/b", "http://127.0.0.1/b", "http://[::1]:8080/b"} { in := planIn("plus") in.SuccessURL = bad if _, err := live.CheckoutPlan(ctx, in); !errors.Is(err, payforge.ErrInvalidURL) { From ee06aee682a1c161e61e88d1a9e0007d3182b75f Mon Sep 17 00:00:00 2001 From: John Wang Date: Wed, 11 Nov 2026 11:18:01 -0800 Subject: [PATCH 2/2] fix(stripe): ignore a trailing dot and cover every loopback address A host with a trailing root dot, such as app.localhost., is the same host as app.localhost, but it was not recognized as a loopback name. A test-mode URL on it was refused, and a live configuration could list it and redirect to it. Hosts are now compared without the dot, both in AllowedURLHosts and in URLs. Loopback addresses are now all of 127.0.0.0/8 and ::1 rather than only 127.0.0.1 and ::1, so a live key also refuses 127.0.0.2 and the like. Co-Authored-By: Claude Opus 5.5 --- docs/guide/checkout.md | 7 ++++--- stripe/client.go | 26 +++++++++++++++++++------- stripe/review_test.go | 6 +++--- 3 files changed, 26 insertions(+), 13 deletions(-) diff --git a/docs/guide/checkout.md b/docs/guide/checkout.md index f855be2..ebdb251 100644 --- a/docs/guide/checkout.md +++ b/docs/guide/checkout.md @@ -24,9 +24,10 @@ calls in a row. Success, cancel, return and refresh URLs must be `https`, and may not carry a user name or password. **Set `AllowedURLHosts` to your own hostnames**: a URL that names any other host is refused with `ErrInvalidURL`. With a test-mode key, -`localhost` and names under it such as `app.localhost`, `127.0.0.1` and `::1` -are also accepted, over `http` too, even when the list does not name them, for -local development. With a live key they are +`localhost` and names under it such as `app.localhost`, and loopback addresses +(`127.0.0.0/8` and `::1`) are also accepted, over `http` too, even when the list does not name them, for +local development. A trailing dot doesn't change a host: `app.localhost.` is +`app.localhost`. With a live key they are never accepted: the client refuses a list that names one when it is made, and a URL on one is refused, because a live checkout that returns the buyer to their own machine is always a misconfiguration. Otherwise a URL built from anything a user sent could diff --git a/stripe/client.go b/stripe/client.go index d183af4..9a8c6de 100644 --- a/stripe/client.go +++ b/stripe/client.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "net/http" + "net/netip" "net/url" "regexp" "strings" @@ -31,8 +32,8 @@ type Config struct { // catalog may leave it empty. A URL must be https and may not carry a user // name or password. // - // With a test-mode key, localhost and names under it (app.localhost), - // 127.0.0.1 and ::1 are also accepted, over http too, even when the list does + // With a test-mode key, localhost and names under it (app.localhost) and + // loopback addresses (127.0.0.0/8 and ::1) are also accepted, over http too, even when the list does // not name them, for local development. // With a live key they are never accepted: New refuses a list that names // one, and a URL on one is refused, since a live checkout that returns the @@ -87,7 +88,7 @@ func New(spec *pricingspec.Spec, cfg Config) (*Client, error) { } hosts := map[string]bool{} for _, h := range cfg.AllowedURLHosts { - h = strings.ToLower(strings.TrimSpace(h)) + h = canonicalHost(strings.TrimSpace(h)) if h == "" || strings.ContainsAny(h, "/:@*") { return nil, fmt.Errorf("stripe: AllowedURLHosts entry %q is not a hostname", h) } @@ -136,7 +137,7 @@ func (c *Client) checkURL(raw string) error { if err != nil || u.Host == "" || u.User != nil { return bad() } - host := strings.ToLower(u.Hostname()) + host := canonicalHost(u.Hostname()) local := isLoopback(host) if local && c.mode != ModeTest { return fmt.Errorf("%w: host %q is loopback, which a live key never redirects to", payforge.ErrInvalidURL, host) @@ -160,10 +161,21 @@ func (c *Client) checkURL(raw string) error { } } -// isLoopback reports whether host is localhost, a name under it, which RFC 6761 -// reserves for the loopback interface, or a loopback address. +// canonicalHost lowercases host and drops a trailing root dot, so app.localhost. +// and app.localhost are the same host. +func canonicalHost(host string) string { + return strings.TrimSuffix(strings.ToLower(host), ".") +} + +// isLoopback reports whether host, as canonicalHost returns it, is localhost, a +// name under it, which RFC 6761 reserves for the loopback interface, or a +// loopback address (127.0.0.0/8 or ::1). func isLoopback(host string) bool { - return host == "localhost" || strings.HasSuffix(host, ".localhost") || host == "127.0.0.1" || host == "::1" + if host == "localhost" || strings.HasSuffix(host, ".localhost") { + return true + } + a, err := netip.ParseAddr(host) + return err == nil && a.IsLoopback() } // withSessionID appends the placeholder Stripe replaces with the checkout diff --git a/stripe/review_test.go b/stripe/review_test.go index ffc742e..0ed07f1 100644 --- a/stripe/review_test.go +++ b/stripe/review_test.go @@ -111,7 +111,7 @@ func TestURLsAreHttpsUnlessLocalAndCanBeLimitedToHosts(t *testing.T) { _, err := c.CheckoutPlan(ctx, in) return err } - for _, ok := range []string{"https://app.example.com/b", "http://localhost:3000/b", "http://auth.localhost:8080/b", "http://127.0.0.1/b"} { + for _, ok := range []string{"https://app.example.com/b", "http://localhost:3000/b", "http://auth.localhost:8080/b", "http://app.localhost.:8080/b", "http://127.0.0.1/b"} { if err := at(open, ok); err != nil { t.Errorf("%s: %v", ok, err) } @@ -370,7 +370,7 @@ func TestALiveKeyNeverRedirectsToLoopback(t *testing.T) { spec := load(t, "subscription-tool.json") const liveKey = "sk_live_example" - for _, h := range []string{"localhost", "app.localhost", "127.0.0.1"} { + for _, h := range []string{"localhost", "app.localhost", "app.localhost.", "LOCALHOST.", "127.0.0.1", "127.0.0.2"} { if _, err := stripe.New(spec, stripe.Config{SecretKey: liveKey, APIURL: srv.URL, AllowedURLHosts: []string{"app.example.com", h}}); err == nil { t.Errorf("a live key with %q allowed was accepted", h) } @@ -383,7 +383,7 @@ func TestALiveKeyNeverRedirectsToLoopback(t *testing.T) { if live.Mode() != stripe.ModeLive { t.Fatalf("mode = %q, want live", live.Mode()) } - for _, bad := range []string{"http://localhost:3000/b", "https://localhost/b", "http://app.localhost:8080/b", "http://127.0.0.1/b", "http://[::1]:8080/b"} { + for _, bad := range []string{"http://localhost:3000/b", "https://localhost/b", "http://app.localhost:8080/b", "https://app.localhost./b", "http://127.0.0.1/b", "https://127.0.0.2/b", "http://[::1]:8080/b"} { in := planIn("plus") in.SuccessURL = bad if _, err := live.CheckoutPlan(ctx, in); !errors.Is(err, payforge.ErrInvalidURL) {