Skip to content

fix(oauth): validate redirect_uri in POST /api/authorize and /callback error path - #266

Open
andesyteoss wants to merge 1 commit into
neondatabase:mainfrom
andesyteoss:fix/cwe601-route-open-1114
Open

fix(oauth): validate redirect_uri in POST /api/authorize and /callback error path#266
andesyteoss wants to merge 1 commit into
neondatabase:mainfrom
andesyteoss:fix/cwe601-route-open-1114

Conversation

@andesyteoss

@andesyteoss andesyteoss commented May 15, 2026

Copy link
Copy Markdown

Vulnerability: Open Redirect via crafted OAuth state (CWE-601)

Severity: High
Affected files: landing/app/api/authorize/route.ts, landing/app/callback/route.ts

Summary

The OAuth authorization flow is vulnerable to an open redirect. An attacker can craft a base64-encoded state parameter containing an arbitrary redirectUri and POST it directly to /api/authorize. This state is forwarded to the upstream identity provider, which round-trips it back to /callback. The callback handler then redirects the authorization code to whatever redirectUri was embedded in the state — including attacker-controlled domains.

This allows an attacker to steal OAuth authorization codes by tricking a user into completing the OAuth flow through a link that ultimately redirects the code to a server the attacker controls.

Data flow

  1. Attacker constructs a state payload: btoa(JSON.stringify({ clientId: "legit-client", redirectUri: "https://attacker.example/steal", ... }))
  2. Attacker POSTs to /api/authorize with this state. The POST handler decodes the state but does not validate redirectUri against the client's registered redirect URIs — it re-encodes it and forwards it to the upstream IdP.
  3. The upstream IdP completes authentication and redirects back to /callback?code=...&state=...
  4. /callback decodes the state again and issues a 307 redirect to redirectUri with the authorization code appended.
  5. The attacker's server at https://attacker.example/steal receives the code.

The GET handler for /api/authorize does validate the redirect URI when it initially builds the state, but the POST handler accepts externally supplied state without re-validation, bypassing that check entirely.

A second path exists in the error-relay branch of /callback: when the upstream IdP returns an error, the callback decodes the state and redirects the error to redirectUri — also without validation.

Proof of concept

# 1. Craft a malicious state with an attacker-controlled redirect_uri
STATE=$(echo -n '{"responseType":"code","clientId":"<valid-client-id>","redirectUri":"https://attacker.example/steal","scope":["read","write"],"state":"csrf-token"}' | base64 -w0)

# 2. POST directly to /api/authorize, bypassing the GET handler's validation
curl -X POST "https://<target>/api/authorize" \
  -H "Content-Type: application/json" \
  -d "{\"state\": \"$STATE\", \"selectedScopes\": [\"read\",\"write\"]}"

# 3. The response redirects to the upstream IdP with the attacker's state intact.
#    After the user authenticates, /callback redirects the auth code to
#    https://attacker.example/steal?code=<authorization_code>&state=csrf-token

Fix description

POST /api/authorize: Before re-encoding the state for the upstream IdP, the handler now loads the client by clientId from the decoded state and validates redirectUri against the client's registered redirect_uris using the existing matchesRedirectUri helper. Requests with unknown clients or non-allowlisted redirect URIs are rejected with a 400 error.

GET /callback (error relay path): A new isAllowedRedirectUri helper validates the decoded redirectUri against the client's registered URIs before relaying upstream errors. On validation failure or database errors, the handler fails closed — it returns a 400 instead of redirecting.

Both paths use the same matchesRedirectUri function from lib/oauth/redirect-uri.ts that already powers the GET /api/authorize handler, so the validation logic is consistent across all entry points.

Testing

Added integration tests in mcp-src/__tests__/auth-callback-open-redirect.integration.test.ts covering:

  • Attacker redirect on success path — verifies a 400 is returned and no authorization code is minted when redirectUri is not on the client's allowlist.
  • Attacker redirect on error relay path — verifies a 400 is returned and no redirect to the attacker host occurs when the upstream IdP returns an error.
  • Legitimate redirect on success path — confirms a registered redirectUri still produces a 307 with the authorization code.
  • Legitimate redirect on error relay path — confirms upstream errors are still relayed to registered redirect URIs.

Adversarial review

Before submitting, we considered whether existing protections prevent exploitation. The GET /api/authorize handler does validate redirect URIs when building the initial state — but this is irrelevant because the POST handler accepts arbitrary externally-crafted state directly, completely bypassing the GET handler. There are no CSRF protections on the POST endpoint that would prevent an attacker from calling it directly. The upstream IdP treats the state as opaque and passes it through unchanged, so it cannot act as a guardrail either.

…e (CWE-601)

The /callback route decoded the OAuth state and used the embedded
redirectUri as the destination of its 307 (both on success and when
relaying upstream errors) without re-validating that the URI was on
the client's registered redirect_uris allowlist.

POST /api/authorize re-encoded a caller-supplied state into a fresh
upstream auth URL without re-validating clientId/redirectUri, so an
attacker could supply a state with an attacker-controlled redirectUri
and the authorization code would be redirected there once the upstream
IdP round-tripped it back to /callback.

Both paths now use matchesRedirectUri() (the same RFC 8252 loopback
matcher used by GET /api/authorize) before any redirect is emitted.

Adds an integration test that asserts the /callback route refuses to
redirect to an off-allowlist URI on both success and error paths.
@vercel

vercel Bot commented May 15, 2026

Copy link
Copy Markdown

@sebastiondev is attempting to deploy a commit to the neondatabase Team on Vercel.

A member of the Team first needs to authorize it.

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