fix(api): bind integration OAuth state to the browser completing the flow (GHSA-2cp5-f6gg-w5fp)

Merged
KI
1mo ago
+791-279

Summary

Closes the cross-org installation hijack in GHSA-2cp5-f6gg-w5fp. The signed OAuth state was not bound to the browser completing the flow, so an integration callback recorded the installation against whoever minted the state, not whoever approved it.

Root cause

createSignedState signed only {organizationId, userId, timestamp}; resolveCallback read identity from the state and checked membership on it, consulting no cookie. An attacker could mint a state for their own org, send the provider authorize URL to a victim, and have the victim's installation/tokens land in the attacker's org. Seven callbacks were unbound (github + integrations google, linear, notion, slack, microsoft-teams, microsoft-teams/identity); connectors and sentry already bound to a cookie.

Fix (well-factored)

New shared helper apps/api/src/lib/integrations/oauthFlow.ts mints the state and sets an HttpOnly SameSite=Lax cookie holding it; resolveCallback rejects unless the query state matches the cookie and clears it on exit. All nine callbacks (incl. the two already-safe ones) now share one implementation, so new callbacks are bound by default.

Proof

  • Live CDP two-account drive: before → installation row written to the attacker's org; after → invalid_state, no row; normal same-browser flow still succeeds.
  • Tests: oauthFlow.test.ts + resolveCallback.test.ts — integration state-handling 3 fail pre-fix → 18 pass post-fix; apps/api 218 pass; typecheck 42/42; lint clean.
  • Full write-up: https://app.superset.sh/page/ghsa-2cp5-f6gg-w5fp-oauth-state-binding-fix-v2c57r

Merge note

Squash-merge only — earlier commits on this branch contained local repro artifacts (now untracked); squash applies only the final tree and drops them.


Summary by cubic

Fixes the cross-org OAuth hijack in GHSA-2cp5-f6gg-w5fp by binding each flow's signed state to the browser that starts it. Previously the state carried only org/user and callbacks trusted it without checking who answered, so an attacker could mint a state for their own org, send the provider link to a victim, and have the victim's installation recorded against the attacker's org. Now beginOAuthFlow mints the state and sets it in an HttpOnly SameSite=Lax cookie scoped to the flow's path, and resolveCallback rejects any callback whose state does not match that cookie, clearing the cookie on every exit.

All nine callbacks now share this implementation, and requireOrgMember no longer returns a state token so new flows are bound by default. Tests cover the attack and the two-leg Teams flow.

Summary by CodeRabbit

  • Security
    • OAuth sign-in and connection flows now verify that the authorization response matches the browser that started the flow, helping prevent invalid or reused callbacks.
    • Temporary authorization data is cleared when flows finish or fail.
  • Documentation
    • Added guidance on running the development environment in cloud sandboxes with limited Docker, database, and network configuration support.