Skip to content

fix(cloud): require CSRF state in the WorkOS login callback - #1886

Merged
RhysSullivan merged 6 commits into
UsefulSoftwareCo:mainfrom
ra-co88:fix/login-csrf-state-mandatory
Sep 15, 2026
Merged

RhysSullivan merged 6 commits into
UsefulSoftwareCo:mainfrom
ra-co88:fix/login-csrf-state-mandatory

Conversation

@ra-co88

@ra-co88 ra-co88 commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

What

The WorkOS login callback now requires a valid, unconsumed CSRF state parameter 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 state parameter 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

  • The callback handler rejects requests with no state (400) before any WorkOS call.
  • State values are single-use: a replayed state returns 400.
  • A fresh state matching the browser's cookie proceeds to the session exchange (302).

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):

  • no state → 400, no WorkOS call issued
  • replayed/consumed state → 400
  • fresh state matching cookie → 302 + session

All green against current main.

@ra-co88
ra-co88 force-pushed the fix/login-csrf-state-mandatory branch from 6102fbd to 4a0c27e Compare August 30, 2026 17:12
@ra-co88

ra-co88 commented Aug 30, 2026

Copy link
Copy Markdown
Contributor Author

Heads-up on the red E2E (cloud 13of16) check here: it's failing on main itself (e.g. the Version Packages runs), so it's pre-existing rather than from this PR. It's the cap-eviction scenario tripping over workerd resetting session Durable Objects mid-initialize when the test opens its burst of sessions — diagnosis and a proposed fix in #1895.

@ra-co88 ra-co88 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Superseded by the corrected review below (this one was posted with shell-mangled formatting). See the 07:28 UTC review for the actual verdict.

@ra-co88 ra-co88 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

ra-co88 pushed a commit to ra-co88/executor that referenced this pull request Sep 13, 2026
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).
@RhysSullivan
RhysSullivan merged commit 5ecb881 into UsefulSoftwareCo:main Sep 15, 2026
40 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.

3 participants