feat(security): OAuth CSRF nonce + redirect allowlist [needs user_id review] (#11)#24
Conversation
#11) The NationBuilder OAuth callback previously trusted an unsigned, base64-only `state` ({user_id, nb_slug, redirect_uri}) and an unvalidated redirect_uri, enabling login-CSRF (attacker crafts state with their own user_id to capture a victim's nation tokens) and authorization-code interception. - Add shared `oauth_state` module: single-use, server-side nonce (DynamoDB w/ TTL) bound to the issued identity; validate-then-delete on callback (atomic conditional delete); created_at expiry; exact-match redirect_uri allowlist. Tampered/replayed/expired/forged state rejected. - Wire `validate_oauth_state` into the callback handler. - Add `nb_oauth_init` Lambda (issuance side) that mints the nonce and redirects to NationBuilder's authorize endpoint. - Infra: OAuthStateTable (TTL on expires_at), IAM, GET /auth/nationbuilder init route, env wiring for both lambdas. - Tests: shared module (round-trip, single-use, expiry, tamper, forged, allowlist), init handler, callback CSRF cases, updated integration tests. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Reject nb_slug values that aren't lowercase-alphanumeric+hyphen so a crafted slug cannot distort the authorize host. Defense-in-depth from adversarial review; not a CSRF bypass (redirect_uri is fixed + allowlisted). Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
…tion) The init handler now always mints user_id server-side and ignores any query-string user_id. Trusting a caller-supplied user_id is an OAuth login-CSRF / session-fixation vector: an attacker could mint a state nonce bound to a user_id they control, lure a victim through authorize, and pin the victim's connection to that identity. The connect flow doubles as login, so a fresh connect has no prior trusted identity; the authoritative identity is the signed session token minted at the callback. Resolves the open security question that held #24 in needs-ian-judgment. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
🧭 Needs Ian's judgment — security code is airtight; deploy packaging is a cross-cutting blockerTL;DR: The actual security work in this PR (issue #11's acceptance criteria) is complete and independently verified as sound. The reason this isn't a clean "merge it" is a systemic Lambda-packaging bug that would crash these (and already-merged) functions at deploy time. That fix touches code outside #11's scope and is an architecture decision I think is yours to make. ✅ What's verified good (issue #11 acceptance criteria — all met)
Evidence: ➕ What I added this run (in scope, verified)Closed the open ⛔ Why this needs your call: a systemic flattened-Lambda import bugBoth OAuth handlers (and the already-merged Reproduced against the flattened layout: This is pre-existing and not created by #11 — 🔧 Recommended decision
I did not fold the packaging fix into this PR on purpose: bundling a cross-cutting infra fix for already-merged functions into a CSRF PR would muddy review and make it hard to revert independently. Happy to take that on as a dedicated PR if you'd like — just say the word. — autonomous pr-driver |
|
Update (2026-06-22): the recommended hardening is now implemented and pushed — |
Addresses #11 — CSRF protection + redirect_uri allowlist for the NationBuilder OAuth flow. Flagged for review, not auto-merged: one open security question + deploy-packaging gaps (below).
What's implemented (committed)
src/lambdas/shared/oauth_state.py— single-use CSRF nonce: server-generated (secrets.token_urlsafe(32)), persisted in a newOAuthStateTable(DynamoDB, TTL,DeletionPolicy: Retain), bound to(user_id, nb_slug, redirect_uri).src/lambdas/nb_oauth_init/handler.py(+ API routeGET /auth/nationbuilder) — issuance side: mints the nonce, then 302s to NB authorize.redirect_uriis fixed server-side fromOAUTH_CALLBACK_URL(can't be client-influenced).nb_oauth_callback— validates + atomically consumes the nonce and enforces aredirect_uriallowlist before exchanging the code.Verified independently
pytest -q→ 573 passed (+31);mypyclean (25 files);cfn-lintcleandelete_item+ConditionExpression=attribute_exists(nonce)+ReturnValues=ALL_OLD— atomic single-use, no replay/TOCTOU.redirect_urifixed server-side. Tests cover forged/missing/replayed state./auth/nationbuilder(the new init route) — no extension change needed (theexample.comhost is the Consolidate to one domain, replace placeholder URLs, load real secrets #14 placeholder).nb_oauth_initaccepts a caller-supplieduser_id(query_params.get("user_id") or generate). Classic OAuth session-fixation: an attacker could mint a nonce bound to their ownuser_id, lure a victim through authorize, and link the victim's nation under the attacker'suser_id. My analysis is this is likely inert given #10 (tokens are per-nation; the session token that grants access is minted at the callback and delivered to the victim's browser; agent authz derives nation from the session-token claims, not the UsersTable link) — but I could not conclusively clear it, and the adversarial reviewer stalled before reaching a verdict. Recommended: hardennb_oauth_initto never trust an unverifieduser_id(generate server-side for fresh connects; for re-auth deriveuser_idfrom a verified existing session token), then merge.Deploy-packaging gaps (tracked under deploy readiness, not blockers for code review)
deploy.ymldoesn't package the newnb_oauth_initLambda.nb_oauth_callbackimportsshared/but its zip step doesn't bundleshared/(the agent functions do). Both oauth Lambdas need the shared-bundling pattern before first deploy. Noted on Consolidate to one domain, replace placeholder URLs, load real secrets #14.Net: this is strictly more secure than
main(which had no CSRF), but I'm holding it for the user_id call.