Skip to content

Fix OAuth login CSRF in Google account linking flow - #114

Open
jay79-boop wants to merge 1 commit into
chrysb:mainfrom
jay79-boop:fix/google-oauth-csrf-state
Open

Fix OAuth login CSRF in Google account linking flow#114
jay79-boop wants to merge 1 commit into
chrysb:mainfrom
jay79-boop:fix/google-oauth-csrf-state

Conversation

@jay79-boop

Copy link
Copy Markdown

Summary

/auth/google/callback trusted the state query param as-is: it was a base64url-encoded JSON blob (accountId, client, email, services) built entirely from client-supplied input on /auth/google/start, with nothing tying a given callback back to the browser session that actually initiated it.

Since /auth/google/callback is necessarily exempt from the app's normal session-cookie auth (it has to be reachable when Google redirects the browser back), this meant anyone could craft their own state — including one naming an existing account's accountId — and get an admin to open:

/auth/google/callback?code=<attacker's own authorization code>&state=<forged>

via a plain link (no JS needed, just a GET navigation). The server would exchange the attacker's authorization code for the attacker's own Google tokens and store them under the target account's id — silently swapping in Google credentials the admin doesn't control, without ever needing the admin's password or session token.

Fix

This codebase already has the correct pattern for exactly this problem in /auth/codex/callback (lib/server/routes/codex.js): mint a random, unguessable state token server-side when the flow starts, hold the actual account-linking data in an in-memory map keyed by that token (never round-tripped through the client), and require the callback's state to match a live, single-use entry before doing anything.

Applied the same pattern to /auth/google/start / /auth/google/callback:

  • state sent to Google is now a random 16-byte hex token, not JSON.
  • The real accountId/client/email/services are stored server-side in a map keyed by that token, with a TTL (kGoogleOauthStateTtlMs, matching the existing Codex TTL).
  • The callback looks up and deletes the entry before proceeding; unknown, forged, or already-consumed state values are rejected before any token exchange happens.

Test plan

  • Added tests/server/routes-google.test.js:
    • the minted state is opaque (not attacker-decodable JSON)
    • a forged/unknown state is rejected and fetch (the token exchange) is never called
    • a valid state works once, and replaying it is rejected

/auth/google/callback trusted the `state` query param as-is: it was a
base64url-encoded JSON blob (accountId, client, email, services) built
entirely from client-supplied input on /auth/google/start, with nothing
tying a callback back to the browser session that actually initiated it.

Since /auth/google/callback is (necessarily) exempt from the app's normal
session-cookie auth -- it has to be reachable when Google redirects the
browser back -- this meant anyone could craft their own `state` (with an
existing account's accountId) and get an admin to open
`/auth/google/callback?code=<attacker's own authorization code>&state=<forged>`
via a plain link. The server would exchange the attacker's code for the
attacker's Google tokens and store them under the target account's id,
silently swapping in Google credentials the admin doesn't control -- all
without ever needing the admin's password or session.

Fixed the same way /auth/codex/callback already does it elsewhere in this
codebase: mint a random, unguessable state token server-side in
/auth/google/start, hold the account-linking data in an in-memory map keyed
by that token (never round-tripped through the client), and require the
callback's `state` to match a live, single-use entry before doing anything.
Unknown/forged/replayed state values are rejected before any token exchange
happens.

Added tests/server/routes-google.test.js covering: the minted state is
opaque (not attacker-decodable JSON), a forged state is rejected without
ever calling the token endpoint, and a valid state is single-use.
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