Skip to content
Open
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
12 changes: 8 additions & 4 deletions internal/x402/forwardauth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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-<name> 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
Expand Down
40 changes: 40 additions & 0 deletions internal/x402/forwardauth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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-<name> 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)
}
})
}
}
15 changes: 12 additions & 3 deletions internal/x402/paymentrequired.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading