diff --git a/internal/x402/forwardauth.go b/internal/x402/forwardauth.go index e477a4b4..6e514006 100644 --- a/internal/x402/forwardauth.go +++ b/internal/x402/forwardauth.go @@ -598,14 +598,18 @@ func facilitatorSettle(ctx context.Context, client *http.Client, facilitatorURL } func buildResourceURL(r *http.Request) string { - scheme := "http" - if r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" { - scheme = "https" - } host := r.Host if forwardedHost := r.Header.Get("X-Forwarded-Host"); forwardedHost != "" { host = forwardedHost } + // resolveScheme, not X-Forwarded-Proto alone: behind the Cloudflare + // tunnel the ForwardAuth hop sees plaintext (XFP=http), which rendered + // http:// resource URLs in 402 challenges on public hostnames whenever a + // route lacked the X-Forwarded-Proto:https RequestHeaderModifier — the + // shared-origin so- routes don't carry it (#679). Public hosts + // default to https; only local plain-HTTP hosts (obol.stack, loopback, + // *.localhost/*.local) stay http. + scheme := resolveScheme(r, host) uri := r.RequestURI if forwardedURI := r.Header.Get("X-Forwarded-Uri"); forwardedURI != "" { uri = forwardedURI diff --git a/internal/x402/forwardauth_test.go b/internal/x402/forwardauth_test.go index be68d887..ac144bc2 100644 --- a/internal/x402/forwardauth_test.go +++ b/internal/x402/forwardauth_test.go @@ -949,3 +949,43 @@ func TestForwardAuth_402CarriesCatalogLinkHeader(t *testing.T) { } }) } + +func TestBuildResourceURL_Scheme(t *testing.T) { + cases := []struct { + name string + host string + xfHost string + xfp string + xfURI string + want string + }{ + // Public hosts default to https even when the TLS-terminating tunnel + // forwards plaintext (XFP=http) and the route carries no + // X-Forwarded-Proto filter — the shared-origin so- case (#679). + {"public host, no forwarded proto", "svc.example.org", "", "", "", "https://svc.example.org/services/x"}, + {"public host, xfp http from tunnel", "svc.example.org", "", "http", "", "https://svc.example.org/services/x"}, + {"forwarded public host", "10.42.0.5:8000", "svc.example.org", "", "", "https://svc.example.org/services/x"}, + {"local obol.stack stays http", "obol.stack:8080", "", "", "", "http://obol.stack:8080/services/x"}, + {"localhost stays http", "localhost:3000", "", "", "", "http://localhost:3000/services/x"}, + {"local host, explicit https honored", "obol.stack:8080", "", "https", "", "https://obol.stack:8080/services/x"}, + {"forwarded uri used", "svc.example.org", "", "", "/services/x/v1/chat", "https://svc.example.org/services/x/v1/chat"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + r := httptest.NewRequest("POST", "/services/x", nil) + r.Host = tc.host + if tc.xfHost != "" { + r.Header.Set("X-Forwarded-Host", tc.xfHost) + } + if tc.xfp != "" { + r.Header.Set("X-Forwarded-Proto", tc.xfp) + } + if tc.xfURI != "" { + r.Header.Set("X-Forwarded-Uri", tc.xfURI) + } + if got := buildResourceURL(r); got != tc.want { + t.Errorf("buildResourceURL() = %q, want %q", got, tc.want) + } + }) + } +} diff --git a/internal/x402/paymentrequired.go b/internal/x402/paymentrequired.go index 2461a8c7..087c0c35 100644 --- a/internal/x402/paymentrequired.go +++ b/internal/x402/paymentrequired.go @@ -190,11 +190,20 @@ func resolveSiteURL(r *http.Request) string { if forwarded := r.Header.Get("X-Forwarded-Host"); forwarded != "" { host = forwarded } - scheme := "https" + return resolveScheme(r, host) + "://" + host +} + +// resolveScheme is the single source of truth for the public scheme of a +// request that may have crossed a TLS-terminating tunnel. Default https; +// downgrade to http only for hosts the stack serves locally over plain HTTP. +// An explicit https signal (direct TLS or X-Forwarded-Proto: https) still +// forces https for any host. Shared by resolveSiteURL (402 page links) and +// buildResourceURL (challenge resource.url) so both stay consistent. +func resolveScheme(r *http.Request, host string) string { if r.TLS == nil && r.Header.Get("X-Forwarded-Proto") != "https" && isLocalHost(host) { - scheme = "http" + return "http" } - return scheme + "://" + host + return "https" } // isLocalHost reports whether host (optionally with :port) is one the stack