Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions docs/guide/checkout.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +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`, `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
Expand Down
26 changes: 20 additions & 6 deletions stripe/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"net/http"
"net/netip"
"net/url"
"regexp"
"strings"
Expand All @@ -31,8 +32,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) 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
// buyer to their own machine is always a misconfiguration.
Expand Down Expand Up @@ -86,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)
}
Expand Down Expand Up @@ -135,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)
Expand All @@ -159,9 +161,21 @@ func (c *Client) checkURL(raw string) error {
}
}

// isLoopback reports whether host is localhost 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" || 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
Expand Down
6 changes: 3 additions & 3 deletions stripe/review_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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://app.localhost.:8080/b", "http://127.0.0.1/b"} {
if err := at(open, ok); err != nil {
t.Errorf("%s: %v", ok, err)
}
Expand Down Expand Up @@ -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", "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)
}
Expand All @@ -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", "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) {
Expand Down
Loading