Skip to content

fix(oauth): block redirects for credential posts#5980

Merged
bokelley merged 1 commit into
codex/oauth-safe-fetchfrom
codex/oauth-post-no-redirects
Jul 23, 2026
Merged

fix(oauth): block redirects for credential posts#5980
bokelley merged 1 commit into
codex/oauth-safe-fetchfrom
codex/oauth-post-no-redirects

Conversation

@bokelley

Copy link
Copy Markdown
Contributor

Summary

  • prevent OAuth token and dynamic-registration POST requests from following redirects
  • preserve SSRF-safe redirect handling for discovery GET requests
  • cover 302, 307, and 308 responses and verify credentials are never sent to the redirect target

Dependency

This is intentionally stacked on #5961 because the scoped OAuth fetch adapter is not on main yet. Do not merge this PR before #5961. After #5961 merges, retarget this PR to main; auto-merge is intentionally disabled.

Testing

  • npx vitest run server/tests/unit/oauth-safe-fetch.test.ts server/tests/unit/url-security-redirects.test.ts --pool=threads (20 passed)
  • npm run typecheck
  • git diff --check

Closes #5963.

@aao-secretariat aao-secretariat Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Clean, contained security fix. Correct principle: fail-closed beats fail-open — credential-bearing POSTs never follow a redirect, because an OAuth token or dynamic-registration endpoint has no legitimate reason to 3xx, and forwarding an Authorization header or PKCE-bearing body to a Location target is a credential leak.

Things I checked

  • oauth-safe-fetch.ts:48...(method === 'POST' && { maxRedirects: 0 }) is valid JS: for GET the expression is false and spreads nothing (falls through to the default), for POST it injects maxRedirects: 0. Right shape.
  • url-security.ts:465options?.maxRedirects ?? 5 uses nullish coalescing, so 0 survives. This is the load-bearing detail; a || 5 here would have silently reverted the fix to 5 hops. It's correct.
  • url-security.ts:519,525 — initial fetch is hardcoded redirect: 'manual' and the only follow mechanism is the for (i < maxRedirects && ...) loop. With maxRedirects: 0, 0 < 0 is false, the loop never runs, undici never dials Location, and the raw 3xx is returned. No credential ever reaches the redirect target.
  • GET discovery unchanged: maxRedirects defaults to 5, per-hop validateRedirectTarget SSRF validation intact. Verified by the discovery test at url-security-redirects.test.ts:62-95.
  • Caller handling: oauth-client-credentials-exchange.ts:116 takes a 3xx as response.ok === false and returns { ok: false } without re-issuing — no re-POST that would re-leak.
  • No changeset required: server/** app code is outside the protocol-package changeset scope (.agents/playbook.md). Correct absence.
  • Test plan in the PR body (vitest 20 passed, typecheck, git diff --check) is checked, and the primary path — credentials not forwarded across 302/307/308 — is exactly what the new parametrized test asserts. Ships validated against the path it claims to fix.

code-reviewer: ready to push, correctness confirmed. security-reviewer: fix is correct and effective, no ship-blockers — credential leak on POST 3xx confirmed closed, GET discovery preserved, no residual auto-follow path.

Follow-ups (non-blocking — file as issues)

  • Pin redirect: 'manual' as an explicit invariant. The whole guarantee rests on the hardcoded redirect: 'manual' in safeFetch. The new tests mock undici, so a future refactor to redirect: 'follow' would neuter maxRedirects: 0 while the tests stayed green. Worth an assertion (or a comment at url-security.ts:519) that names this as load-bearing. (security-reviewer, Low.)
  • SDK-delegated flows unverified. agent-oauth.ts passes oauthSafeFetch into @adcp/sdk (not in this checkout). Structurally safe — any SDK re-follow would re-enter safeFetch's SSRF validation — but a test driving a token exchange through the SDK against a 3xx mock would close the gap. (security-reviewer, Low.)

Minor nits (non-blocking)

  1. 301/303 not in the parametrized set. url-security-redirects.test.ts:37 covers 302/307/308. maxRedirects: 0 short-circuits before the status is inspected, so these are equivalent, but adding 301/303 would make the "no follow regardless of status class" intent explicit.

One behavior change worth noting for adopters: a provider that legitimately 307s a token request to a regional endpoint now fails as a 3xx rather than following. That is fail-closed and correct — flagging only so it isn't chased as a regression later.

LGTM. Follow-ups noted below.

@bokelley
bokelley merged commit 37ddeda into codex/oauth-safe-fetch Jul 23, 2026
4 checks passed
@bokelley
bokelley deleted the codex/oauth-post-no-redirects branch July 23, 2026 20:17
bokelley added a commit that referenced this pull request Jul 23, 2026
* fix(oauth): route agent flow requests through safe fetch

* test(oauth): inject callback state without cookie middleware

* fix(oauth): secure client credential token exchange

* fix(oauth): block redirects for credential posts (#5980)
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