Skip to content

chore(http): refuse redirects on spec-defined fetches, parse max-age strictly - #775

Merged
lakhansamani merged 1 commit into
mainfrom
chore/http-redirect-policy
Aug 15, 2026
Merged

chore(http): refuse redirects on spec-defined fetches, parse max-age strictly#775
lakhansamani merged 1 commit into
mainfrom
chore/http-redirect-policy

Conversation

@lakhansamani

Copy link
Copy Markdown
Contributor

Two hardening items from the same review as #774. Neither is a live
vulnerability — stated plainly so nobody reads more into it than is there.

F3 — redirect policy at 4 of 8 SSRF-client call sites

validators.safeHTTPClient pins DialContext to the validated IP for every
dial on the transport, so a redirect cannot reach a new host: it would
re-issue the request against the original address carrying a foreign Host
header. The gap is consistency, not exposure — but a policy present at half the
call sites is one relaxed dialer away from being a real one.

Applied to the two fetches that retrieve a spec-defined resource which must be
at the URL named
:

  • internal/token/backchannel_logout.go — Go re-issues a 302'd POST as a
    bodyless GET, so an RP whose backchannel_logout_uri redirects was already
    silently not processing the logout. This turns a success-that-did-nothing into
    a visible non-2xx.
  • internal/clientmetadata/clientmetadata.go — a hop away from the client_id
    URL serves a document for a different identifier. This was already rejected
    incidentally by the client_id-must-match-its-URL check; the change makes the
    rejection deterministic and matches the sibling JWKS fetch.

Not applied, with the reason recorded in a comment at both sites:
events.webhookHTTPClient and admin_webhooks TestEndpoint. Those URLs are the
operator's own, a same-host path redirect is followed today with its final
status recorded in WebhookLog.HttpStatus, and refusing them would change that
recorded status for existing deployments to close nothing. The test-endpoint
probe must also match delivery exactly, or it reports failures the live webhook
would not hit.

One implementation note

The policy is set on a copy of the client, not in place. fetchViaClient is
also the SetHTTPClientForTest seam, so setting the field directly would mutate
a client the caller owns — and would mean the test seam no longer exercises the
production policy. TestFetchViaClientDoesNotMutateTheCallersClient pins that.

F5 — cacheTTL accepted trailing garbage

fmt.Sscanf(v, "%d", &secs) stops at the first non-digit and still reports
success, so max-age=600junk parsed as 600. strconv.Atoi rejects it and the
value falls back to the floor. The clamp already made every outcome safe, so
there is no live defect here — the parse just should not be half-reading a
header written by the party being validated.

Verification

TestFetchRefusesRedirects and the new TestCacheTTLIsClamped rows confirmed
failing with the source reverted and the tests kept.

go build ./...   OK
go vet ./...     OK
make test        exit 0 — 43 packages, 0 FAIL
make lint        exit 0

Stacked independently of #774 — both branch from main and touch disjoint files.

…strictly

Four outbound fetches build an SSRF-hardened client; only two set a
redirect policy. The pinned dialer already makes a redirect unable to
reach a new host, so this is consistency, not a hole — but a policy
present at half the call sites is one relaxed dialer away from being one.

Applied to the two that retrieve a spec-defined resource which must be
AT the URL named:

- backchannel_logout: Go re-issues a 302'd POST as a bodyless GET, so a
  redirecting RP was already not receiving the logout. Now visible.
- clientmetadata: a hop away from the client_id URL serves a document
  for a different identifier. Already rejected incidentally by the
  client_id-must-match-its-URL check; now rejected deterministically,
  matching the sibling JWKS fetch.

Set on a copy of the client rather than in place: fetchViaClient is also
the injected-test-client seam, and mutating a caller's value is a side
effect whichever path is hotter. The copy keeps production and the test
seam on one policy.

Left following redirects, with the reason recorded at both sites:
webhook delivery and the admin test-endpoint probe. Those URLs are the
operator's own, a same-host path redirect is followed today and its
final status recorded in WebhookLog.HttpStatus, and changing that would
alter recorded behaviour for existing deployments to close nothing.

Separately, cacheTTL used fmt.Sscanf, which stops at the first non-digit
and still reports success — "max-age=600junk" parsed as 600. strconv.Atoi
rejects it. The clamp already made every outcome safe, so no live defect.
@lakhansamani
lakhansamani merged commit 25a75fb into main Aug 15, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant