fix(cloud): require CSRF state in the WorkOS login callback - #1886
Conversation
6102fbd to
4a0c27e
Compare
|
Heads-up on the red |
ra-co88
left a comment
There was a problem hiding this comment.
Verdict: approve — login-CSRF hardening, correctly implemented and tested. (Comment review: GitHub blocks formal self-approval on your own PR.)
Supersession check (upstream/main @ 2dc399e): the callback still only enforces state when present — the if (query.state !== undefined) guard allows a no-state entry path, which is precisely the login-CSRF hole this PR closes. Not superseded; the fix is genuinely missing upstream.
The change is the right shape:
- Unconditional state check before any WorkOS network call — forged callbacks now die at 400 with zero WorkOS cost, and the code comment documents the attack (attacker completes their own round-trip, victim gets signed into the attacker's account).
- Single-use contract preserved: the state cookie is consumed on use; the replay test at workos-callback-state.node.test.ts:150 drives a second callback with the same state and no cookie and asserts 400.
- Negative cases are real, not vacuous: node-level (replay ⇒ 400), e2e-level (login-csrf.test.ts:69 replayed GET ⇒ 400 "Invalid login state"), plus browser-binding and provider-redirect capture coverage. The negative-control discipline holds.
- Timing-safe comparison on the state match.
One honest caveat for the maintainer: the branch's diff footprint vs upstream is large (34 files) because it carries an old merge-base; the actual payload is 4a0c27e (the handler hardening) plus its test commits. The extra files (session-build-semaphore.test.ts, org-api-keys-console.test.ts) arrive via the stale base, not this PR's intent.
CI: all shards green (cloud 1-16, local, selfhost). Merges clean against current upstream/main.
Minor note: the missing-state case compares against the empty string rather than branching separately; fine, though a maintainer may prefer an explicit two-branch form for readability. Not blocking.
Brings the fork up to upstream 2dc399e (Version Packages UsefulSoftwareCo#1906): UsefulSoftwareCo#1949 workspace-write release patch, UsefulSoftwareCo#1834 selfhost Google SSO, UsefulSoftwareCo#1947 Google OAuth listing gate, UsefulSoftwareCo#1934/UsefulSoftwareCo#1931/UsefulSoftwareCo#1933 rate-limit and pricing, UsefulSoftwareCo#1932 pricing nav, UsefulSoftwareCo#1919 admin-restricted workspace writes, plus release tooling and package bumps. Conflict resolution (packages/core/sdk/src/executor.ts, policy paths): upstream UsefulSoftwareCo#1919 landed its own transaction wrap of policiesCreate/ policiesUpdate — kept upstream's wrap verbatim and kept the fork's discriminating it.live concurrent-creates regression test in policies.test.ts. The fork's 8 security/hardening fixes (PRs UsefulSoftwareCo#1886-UsefulSoftwareCo#1893) remain the fork's delta; each has a posted verdict. Housekeeping in the same merge: .oxlintrc.jsonc ignorePatterns gains ".agents/" (local workflow files, gitignored, previously linted as stray errors during gates). executor.ts re-run through oxfmt after hand-resolution. Gates: format:check, lint, typecheck green; test — package suites green (sdk, openapi, keychain, deno-subprocess verified; full parallel turbo run shows rotating SIGINT contention failures on this loaded machine, each "failed" package passes in isolation).
What
The WorkOS login callback now requires a valid, unconsumed CSRF
stateparameter on every request. Missing state, unknown state, or a replayed (already consumed) state each return 400 before any WorkOS API call is made.Why
Login CSRF: an attacker can craft a callback URL that logs the victim into the attacker's account. The
stateparameter ties the callback to a login flow the user actually initiated — without enforcing it unconditionally, the callback accepts forged authorization codes. The check now happens before any network call to WorkOS, so forged requests are rejected at zero cost.What changed
state(400) before any WorkOS call.Breaking changes
Flows that initiate login server-side and construct the callback URL without a state parameter will now be rejected. If you drive login from a server, generate a random nonce, persist it server-side (or sign it), and pass it as
state— the callback validates it against the cookie it sets.Test plan
Focused suite on the callback handler (
apps/cloud/src/auth/workos-callback-state.node.test.ts):All green against current main.