Skip to content

sec(api): enforce CSRF on the RI-exchange session-authed approve path - #1796

Merged
cristim merged 3 commits into
mainfrom
sec/1757-riexchange-csrf
Aug 11, 2026
Merged

cristim merged 3 commits into
mainfrom
sec/1757-riexchange-csrf

Conversation

@cristim

@cristim cristim commented Aug 10, 2026 •

Copy link
Copy Markdown
Member

Closes #1757 (p0/severity/critical).

Recovered work. These two commits were written a day ago, committed to a worktree, and never pushed because the authoring agent hit a quota limit mid-task. Rebased onto current main (13 commits behind), zero conflicts, and re-verified after the rebase rather than carrying the earlier gate results forward.

The vulnerability

The session-authed RI-exchange approve path had no CSRF enforcement at all. Confirmed by execution with a purchases control in the same probe run:

CONTROL (purchases approve, valid session, no CSRF headers):
    err = "CSRF validation failed"          <- correctly refused

RI-EXCHANGE approve (same shape):
    err = <nil>
    TransitionRIExchangeStatus called: true  <- state transitioned

A logged-in user visiting an attacker-controlled page that fires a cross-site POST to /api/ri-exchange/approve/<id> had their exchange approved and executed. This is exactly the threat model #404 fixed for purchases; it was never applied here.

Why it survived

Two compounding reasons, both worth recording.

A comment asserted the protection existed. middleware.go:288-289 stated that /api/ri-exchange/approve/ and /api/ri-exchange/reject/ are AuthPublic and "therefore exempted by isPublicEndpoint(), not here" — the same sentence structure used two lines above for purchases approve/cancel, where "not here" means enforced inside the handler instead. For purchases that is true (approvePurchaseViaSession calls h.validateCSRF directly). For RI-exchange it was not: h.validateCSRF appeared nowhere in handler_ri_exchange.go.

A test asserted it was required, and passed. TestRequiresCSRFValidation_TokenBasedPathsWithSession asserts requiresCSRFValidation(...) returns true for this exact path — but calls the predicate directly, bypassing the isPublicEndpoint gate that stops the real dispatch from ever consulting it. The test name and its passing assertion both read as "CSRF is required and enforced here"; neither touches the enforced path.

The repos own test suite had zero real CSRF assertions for this handler: the only CSRF-related line was a stub ValidateCSRFToken returning nil unconditionally, never exercised to prove rejection.

The fix

h.validateCSRF(ctx, req) at the top of approveRIExchangeViaSession, mirroring approvePurchaseViaSession, with the explanatory comment about AuthPublic routes skipping the outer middleware so the next reader sees why it is in-handler.

rejectRIExchange needs nothing: it has no session-authed branch, being unconditionally token-gated with a subtle.ConstantTimeCompare against the stored approval token.

The regression test asserts the money-moving call

The second commit exists because asserting on the error alone would not prove the state transition was prevented. The test asserts TransitionRIExchangeStatus is not reached, which is the property that matters. Both directions: no CSRF token is refused before the transition, and a valid token still succeeds — a fix that refused every session-authed approve would pass a rejection-only test while breaking the feature.

Post-rebase verification

validateCSRF present in handler_ri_exchange.go (was 0 occurrences, now 1), go build ./... exit 0, and go test ./internal/api/ -run CSRF|RIExchange passes. Full gates run by CI.

Related, filed separately from this investigation

  • #1753 — approvePurchase/cancelPurchase discard an authorization denial and fall through. Verified latent, not live: safe today on two independent facts (inner and outer call the same authorizeSessionApprove, and this AuthPublic route family never caches a context principal), either of which could change alone.
  • #1749 — approvePurchaseViaSession returns a 409 carrying status= before authorization, letting a caller probe existence and status of an execution whose UUID it knows.

Summary by CodeRabbit

  • Bug Fixes

    • Added CSRF protection to session-authenticated RI exchange approvals.
    • Requests without valid CSRF validation are rejected before approval processing begins, even when an approval token is present.
    • Valid CSRF-authenticated approval requests continue normally.
  • Tests

    • Added regression coverage for missing or invalid CSRF tokens and successful requests with valid tokens.

approveRIExchangeViaSession had no CSRF check at all. The endpoint is
AuthPublic, so the generic middleware CSRF gate never runs for it
(validateSecurityContext returns before requiresCSRFValidation is
reached), same as purchases approve/cancel -- but unlike purchases,
nothing enforced CSRF inside the handler either. A comment at
middleware.go:288-289 claimed parity with the purchases path; it
didn't hold.

Confirmed live by execution with a purchases control in the same
probe run (not committed): same shape, valid session, zero CSRF
headers -- purchases correctly refused ("CSRF validation failed"),
RI-exchange transitioned the record from pending to processing with
no CSRF token at all. Filed as #1757.

Adds h.validateCSRF(ctx, req) at the top of approveRIExchangeViaSession,
mirroring approvePurchaseViaSession / cancelPurchaseViaSession exactly
(including the explanatory comment about AuthPublic routes).

Adds TestApproveRIExchangeViaSession_RequiresCSRF and its PassesCSRF
companion, both driving the real handler.approveRIExchange entry
point (not requiresCSRFValidation in isolation). Fixes four existing
tests (TestApproveRIExchange_SessionAdmin, both subtests of
TestApproveRIExchange_SessionApproveOwn, TestApproveRIExchange_
SessionActorStamped) that never supplied a CSRF token and would now
fail against the fix.

Corrects the middleware.go:288-289 comment, which asserted parity
that didn't exist and is why this survived -- the next reader now
sees where CSRF is actually enforced for this path.

Adds a scope-limit comment to TestRequiresCSRFValidation_
TokenBasedPathsWithSession (kept, not deleted: it's the only test
pinning requiresCSRFValidation's own per-path table, which would
matter again if any of these routes stopped being AuthPublic) -- it
calls requiresCSRFValidation directly, bypassing the isPublicEndpoint
gate that keeps the real dispatch from ever consulting it for these
paths, so a passing assertion there was never proof of end-to-end
enforcement, and was misread as exactly that here.

Systemic check: enumerated all 26 AuthPublic routes in router.go and
read every handler with a session-authed branch. approvePurchase /
cancelPurchase (protected via validateCSRF), revokeViaEmailToken
(protected on its only session-authed mutating path), rejectRIExchange
(no session-authed path at all -- unconditionally token-gated),
unsubscribeHandler (no session concept -- pure signed-token, RFC 8058),
submitRegistrationHandler (no session concept -- public submission,
separate AuthAdmin approval step). approveRIExchange was the only gap.

Fixes #1757.
TestApproveRIExchangeViaSession_RequiresCSRF only mocked
ValidateCSRFToken; under mutation (CSRF guard removed) it failed via
a panic on the first unmocked call (GetRIExchangeRecord) rather than
through its own AssertNotCalled assertions, which never ran. A panic
on an unrelated unmocked call proves the record wasn't fetched, not
that the state transition (the actual money-moving call) was
prevented -- the same class of fiction-vs-mechanism gap flagged on
PR #1758.

Adds the full happy-path fixture (.Maybe(), never meant to be
consumed under the fix) so a reintroduced bug can run all the way to
TransitionRIExchangeStatus instead of crashing early, and reorders
the state-changing AssertNotCalled checks ahead of the error-shape
assertions so they are the primary, always-run check.

Re-verified by mutation (inverse-edit revert of the CSRF guard, not
git checkout): fails cleanly with "TransitionRIExchangeStatus was
called 1 time(s)... expected none" and the recorded call showing the
pending->processing transition -- the assertion the test needs to
protect is now the one that actually fires.
@cristim cristim added triaged Item has been triaged priority/p0 Drop everything; same-day fix severity/critical Major harm when it happens urgency/now Drop other things impact/all-users Affects every user effort/s Hours type/security Security finding labels Aug 10, 2026
@coderabbitai

coderabbitai Bot commented Aug 10, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 98ba7c64-d144-4242-b2b9-9db7b93ce891

📥 Commits

Reviewing files that changed from the base of the PR and between b7d7c1c and 6026bb3.

📒 Files selected for processing (3)
  • internal/api/handler_ri_exchange.go
  • internal/api/handler_router.go
  • internal/api/middleware_test.go
🚧 Files skipped from review as they are similar to previous changes (1)
  • internal/api/handler_ri_exchange.go

📝 Walkthrough

Walkthrough

The session-authenticated RI exchange approval handler now validates CSRF before processing. CSRF failures cannot fall through to token approval. Tests cover rejected and successful approval paths.

Changes

RI exchange approval protection

Layer / File(s) Summary
Handler-level CSRF enforcement
internal/api/handler_ri_exchange.go, internal/api/handler_router.go, internal/api/middleware.go
approveRIExchangeViaSession validates CSRF before authorization and state changes. CSRF failures return HTTP 403 and cannot use token fallback. Comments document approval and rejection authentication behavior.
Approval regression coverage
internal/api/middleware_test.go, internal/api/handler_ri_exchange_test.go
Tests verify that missing or failed CSRF validation prevents exchange access and status transitions. Valid CSRF tokens allow approval. Existing session approval tests now provide CSRF tokens.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant SessionClient
  participant approveRIExchange
  participant approveRIExchangeViaSession
  participant TransitionRIExchangeStatus

  SessionClient->>approveRIExchange: Submit session-authenticated approval
  approveRIExchange->>approveRIExchangeViaSession: Route session approval
  approveRIExchangeViaSession->>approveRIExchangeViaSession: Validate CSRF token
  alt CSRF validation fails
    approveRIExchangeViaSession-->>approveRIExchange: Return errCSRFRejected
    approveRIExchange-->>SessionClient: HTTP 403
  else CSRF validation succeeds
    approveRIExchangeViaSession->>TransitionRIExchangeStatus: Transition exchange status
    TransitionRIExchangeStatus-->>SessionClient: Approval result
  end
Loading

Possibly related issues

  • LeanerCloud/CUDly#1753: Concerns authorization-failure fall-through in approval handlers, including the RI exchange path.

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely identifies CSRF enforcement on the RI-exchange session-authenticated approval path.
Linked Issues check ✅ Passed The changes implement the issue requirements, including handler-level CSRF validation, blocked token fallthrough, valid-token support, and regression coverage for [#1757].
Out of Scope Changes check ✅ Passed The production, test, and documentation changes remain focused on CSRF enforcement for RI-exchange approval and its regression coverage.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch sec/1757-riexchange-csrf

Comment @coderabbitai help to get the list of available commands.

@cristim

cristim commented Aug 11, 2026

Copy link
Copy Markdown
Member Author

Independent adversarial review — head b7d7c1c3b

Reviewed in a fresh worktree off refs/pull/1796/head, independent of the authoring and rebasing sessions. Everything below was re-derived by execution; nothing in the PR description was taken on trust.


1. The vulnerability and the fix, both reproduced through the real routed path

Driven through Router.Route (not the handler in isolation), with the purchases control in the same probe run. The pre-fix tree was produced by removing the validateCSRF call at internal/api/handler_ri_exchange.go:2112 and restored by re-applying the inverse edit — the worktree was verified byte-identical to b7d7c1c3b afterwards.

Pre-fix (Router.Route, valid session bearer, no CSRF headers):

PROBE  ri-exchange approve:   err = <nil>
                              TransitionRIExchangeStatus called = true
                              ExecuteExchange called            = true   <- money moved
CONTROL purchases approve:    err = "CSRF validation failed"
                              ApproveAndExecute called          = false

At head, same two requests:

PROBE  ri-exchange approve:   err = "CSRF validation failed"
                              TransitionRIExchangeStatus called = false
                              ExecuteExchange called            = false
CONTROL purchases approve:    err = "CSRF validation failed"
                              ApproveAndExecute called          = false

The control differentiating within the same run is what rules out "RI-exchange happens to fail for an unrelated reason". Note the probe reaches one step further than the original: not just the status transition but ExecuteExchange itself, i.e. the exchange actually executed pre-fix.

The PR's own regression test is non-vacuous — on the pre-fix tree it fails cleanly with exactly the claimed signature, not a panic on an earlier unmocked call:

mock: TransitionRIExchangeStatus was called 1 time(s) ..., expected none
Error: An error is expected but got nil.
--- FAIL: TestApproveRIExchangeViaSession_RequiresCSRF

2. Both directions

Confirmed at router level, with Region populated so execution runs to completion rather than short-circuiting:

POSITIVE ri-exchange approve (session + valid x-csrf-token):
    err    = <nil>
    result = map[exchange_id:exch-1 status:completed]
    TransitionRIExchangeStatus called = true
    ExecuteExchange called            = true
    StampRIExchangeApprovedBy called  = true

The fix does not refuse legitimate session-authed approves.

Minor note on the PR's own TestApproveRIExchangeViaSession_PassesCSRF: its fixture record has no Region, so executeApprovedExchange bails at the region guard and returns via failExchange, which returns (result, nil) (handler_ri_exchange.go:2283). require.NoError therefore passes on a failed exchange. The test's actual assertion (AssertCalled on TransitionRIExchangeStatus) is still sound and is the property that matters for CSRF — but the test does not demonstrate end-to-end completion. The probe above does.

3. Enforcement completeness — the negative is proven, not merely unfound

Enumerated the full route table in internal/api/router.go programmatically: 121 route entries (count cross-checked three ways), of which 26 are AuthPublic, reducing to 18 distinct handlers, each walked to its state-mutating leaf. Cross-checked against isPublicEndpoint() in both directions.

No route satisfies (has a session-authed branch) ∧ (reaches a state-changing call) ∧ (no in-handler validateCSRF). Repo-wide there are exactly 5 non-test h.validateCSRF( call sites: handler_purchases.go:672 (approve), :1135 (cancel), :1335 (revoke), handler_ri_exchange.go:2112 (this PR), and the middleware call at handler.go:785. RI-exchange approve was the only gap.

rejectRIExchange — the PR's claim is confirmed. The function cannot see a session because it never receives the request: rejectRIExchange(ctx, id, token string) (handler_ri_exchange.go:2445); the route shim passes only params["id"] and req.QueryStringParameters["token"] (router.go:885). No header access, no tryGetSession, no extractBearerToken. Its single state change (TransitionRIExchangeStatus, :2470) sits unconditionally downstream of the token == "" guard, the record.ApprovalToken == "" guard (which closes the empty-matches-empty case), and the subtle.ConstantTimeCompare. Nothing to fix there.


Finding F1 (should be fixed here) — the new guard is silently skipped when ?token= is present, and the parity claim with purchases does not hold

approveRIExchangeViaSession signals CSRF failure as NewClientError(403, ...) (:2113). isPermissionDenied is a bare code == 403 test (handler_purchases.go:2012-2015). So at handler_ri_exchange.go:2060:

if token == "" || !isPermissionDenied(sessErr) {
    return nil, sessErr
}

a CSRF failure with token != "" does not return — control falls through to approveRIExchangeViaToken at :2071.

Measured, same probe shape as above but with a valid ?token= on the request:

F1 PROBE   ri-exchange approve (session, NO csrf headers, VALID ?token=):
    err                               = <nil>
    TransitionRIExchangeStatus called = true
    ExecuteExchange called            = true   <- money moved
    transition actor was NIL          = true   <- audit attribution lost
    StampRIExchangeApprovedBy called  = false  <- approved_by never stamped

F1 CONTROL purchases approve (same shape):
    err                      = "CSRF validation failed"
    ApproveAndExecute called = false

This is not a reopened CSRF hole: the caller still has to present the unguessable ApprovalToken, which is constant-time-compared and is by design sufficient authorization on its own. But three things follow:

  1. The PR body and the new code comment both say this call "mirrors approvePurchaseViaSession". Measured, it does not — approvePurchase returns h.approvePurchaseViaSession(...) directly (handler_purchases.go:566-568), so its CSRF 403 always surfaces. RI-exchange captures the error and swallows it. Shipping a sec(api): RI-exchange session-authed approve has no CSRF enforcement despite a comment claiming parity with purchases #1757 fix whose comment overstates what the code does is the exact failure mode sec(api): RI-exchange session-authed approve has no CSRF enforcement despite a comment claiming parity with purchases #1757 was about.
  2. A legitimate session-authed approve carrying a token degrades to an unattributed system approval: transitioned_by = NULL and no approved_by stamp, on a money path.
  3. The defect is the conflation: 403 now means both "RBAC denied" (fall through, by design) and "CSRF failed" (must never fall through). Any future 403 added to that path inherits the fall-through silently.

Proportionate fix: make the CSRF failure distinguishable — a sentinel error checked before the isPermissionDenied fall-through — rather than adding a mode or restructuring the dispatch. No test currently covers the token != "" case, so this went unnoticed; the fix should come with one.

Priority 4 — the test that should have caught this

Keeping TestRequiresCSRFValidation_TokenBasedPathsWithSession is the right call: the csrfExemptWhenTokenOnly table is real logic and this is its only coverage, so deleting it trades a mislabeled test for no test. The added scope-limit comment is accurate and well-argued.

It is not sufficient, though. The test misled because its name and its passing assertion read as an end-to-end enforcement guarantee. Names travel into CI output, go test -v runs, and greps for "CSRF coverage"; comments only reach someone who already opened the file — which is not how the original misreading happened. Recommend renaming it to say what it actually pins, e.g. TestRequiresCSRFValidation_PerPathTable_NotEndToEnd. One line, and it puts the disclaimer where the mistake was made.

Priority 5 — the rebase

Faithful. Recovered the pre-rebase commits from the reflog (3a3ddea40, 472f19e36, committed 2026-08-08) and diffed each against its replayed counterpart:

  • 3a3ddea40 → f92c282ba: differs only in blob index hashes and two hunk line offsets (@@ -2089 → @@ -2103, @@ -1756 → @@ -1758). Every added and removed content line byte-identical.
  • 472f19e36 → b7d7c1c3b: byte-identical including offsets.

Merge-base with origin/main is fcdc681f9 (current tip); exactly 2 commits; diff vs fcdc681f9 touches only the 4 expected files. Nothing dropped, nothing duplicated.

Gates re-run locally at head

Gate Result
go test ./internal/api/ -count=1 exit 0, ok ... 18.4s
golangci-lint run ./... at CI-pinned v2.10.1 exit 0 and a genuine 0 issues. line
gocyclo -over 10 -ignore "_test\.go" clean (approveRIExchangeViaSession at 9, one below the pre-commit ceiling)
CI on the PR 20 passed, 0 failed; MERGEABLE / CLEAN

Out of scope for this PR — filing separately, not blockers

  • /api/info is prefix-matched in isPublicEndpoint (middleware.go:22) while /api/info/deployment is registered AuthUser (router.go:363). validateSecurityContext therefore skips authentication entirely for an endpoint that returns the AWS account ID and Secrets Manager ARN; it is saved only by Router.Route's defense-in-depth AuthUser check. The file's own comment says exact-match exists to stop precisely this. Pre-existing, currently mitigated, and the only such violation across all 121 routes.
  • csrfExemptWhenTokenOnly (middleware.go:311-325) is unreachable dead code. isPublicEndpoint returns at handler.go:762 for all four of its prefixes, so requiresCSRFValidation at :784 is never consulted for them. It reads as live protection — the same prose-vs-code divergence that produced sec(api): RI-exchange session-authed approve has no CSRF enforcement despite a comment claiming parity with purchases #1757. This PR adds 8 lines of (accurate) comment to that dead block.
  • RI-exchange approval emails emit a GET link (internal/email/templates.go:308,366) to a POST-only route (router.go:318), with no RI-exchange deeplink page in the frontend. Clicking Approve in the email should 404. Relevant here because it means the session+token combination F1 depends on is not producible by the current frontend, making F1 latent rather than active.

Verdict

The p0 is genuinely fixed and the fix is correct for the reachable flow. Reproduced by execution in both directions, with a control; the regression test is non-vacuous; the completeness sweep proves no sibling gap; the rebase replayed faithfully; all gates green.

F1 should be addressed before merge. It is a three-line change inside the fix's own control flow, it makes a claim the PR states in two places actually true, and it is the kind of latent conflation that becomes a live bypass the moment anything else on that path returns 403.

@cristim

cristim commented Aug 11, 2026

Copy link
Copy Markdown
Member Author

Delta review — b7d7c1c3b → ba3d34f36

The sentinel closes F1. Re-derived by execution against the new head, including the three cases you flagged plus the fallback case most at risk of being over-narrowed.

F1 is closed, and the fallback it narrowed is intact

All driven through Router.Route:

# Scenario err transitioned executed approved_by
D1 session, no CSRF, valid ?token= — the F1 case CSRF validation failed false false false
D2 no session at all, valid ?token= — pure email-link <nil> → status:completed true true –
D3 session w/ approve-own but not the creator, valid CSRF, valid ?token= <nil> → status:completed true true –
D4 session + valid CSRF, no ?token= — dashboard <nil> → status:completed true true true
D5 session with no approve right, valid ?token= <nil> → status:completed true true –

D1 is the finding, closed. D2 answers your second question directly — the legitimate email-link holder with no session is unaffected; the narrowing did not touch that path, because with no session the dispatch never enters approveRIExchangeViaSession at all.

D3 is the one I'd have most expected to break and it doesn't. It is the case the fallthrough actually exists for: a session that holds an approve right in general but is denied at record level (approve-own, not the creator, handler_ri_exchange.go:2251-2253). That denial is a genuine 403 that must still fall through to the token flow, and it does. Narrowing on the sentinel rather than widening isPermissionDenied is what preserved it — your reasoning about blast radius holds up under execution, not just on inspection.

Your first question: the sentinel vs isPermissionDenied on every path

Measured contract:

errors.Is(sentinel, sentinel)      = true
errors.Is(wrapped,  sentinel)      = true
errors.Is(freshEqualMsg, sentinel) = false   <- the mutation is genuinely caught
isPermissionDenied(sentinel)       = true    <- note this
isPermissionDenied(wrapped)        = false
IsClientError(sentinel) / (wrapped) = true / true   (IsClientError uses errors.As, so it unwraps)

Worth stating precisely, because it differs from how the commit message frames it: the sentinel is not invisible to isPermissionDenied — isPermissionDenied(errCSRFRejected) is true. What protects it is the short-circuit ordering of || at handler_ri_exchange.go:2068. The code comment says exactly this ("Checked first so the sentinel is never reached by the 403 test below"), so the intent is recorded; I just want the property named accurately, since "unreachable" and "reached first" have different failure modes. Reordering that boolean expression silently reopens F1 — but your new test drives that exact path, so a reorder fails it. The guard is itself guarded.

Flow is contained: errCSRFRejected is produced at exactly one site (:2121) and consumed at exactly one (:2068). approveRIExchangeViaSession has exactly two callers, both in approveRIExchange — :2054 (guarded) and :2082 (returned directly, no 403 test). The other three isPermissionDenied call sites are in handler_purchases.go and the sentinel cannot reach them.

Your third question: wrapping

Safe in both directions, and doubly so. errors.Is(wrapped, sentinel) is true, so the guard holds if validateCSRF's error is ever wrapped. And isPermissionDenied is a strict (non-unwrapping) assertion by design (handler_purchases.go:2013), so a wrapped sentinel returns false there — meaning even if the errors.Is term were deleted, a wrapped sentinel would still take the !isPermissionDenied branch and return. The dangerous direction is the opposite one — returning a fresh NewClientError(403, ...) instead of the sentinel — and that is precisely what your mutation exercises.

Purchases has no sibling instance of this defect

Checked, since the fix is scoped to RI-exchange. cancelPurchase (:1066) and tryRevokeViaSession (:1329) both switch isPermissionDenied on the RBAC pre-check (authorizeSessionCancel), and the CSRF check runs after that switch has already selected the == nil branch. Revoke returns (nil, true, err) on CSRF failure — handled=true, so the caller does not fall through. RI-exchange was the only place where a ViaSession result, CSRF error included, was fed back into isPermissionDenied. No follow-up needed there.


D-F1 — blocker: ci_gate_check.sh was committed

ba3d34f36 adds ci_gate_check.sh at the repo root, tracked, mode 100755, not in .gitignore, not present on fcdc681f9. It is a local CI-reproduction scratch script, unrelated to the CSRF fix, and it hardcodes a machine- and worktree-specific path:

log="/tmp/claude/wt-1757/ci-unit-${tag}.log"

— a path that does not exist on any other machine, and points at a different worktree than the one this fix was made in. Almost certainly an accidental git add. It should come out of the commit before merge; if the script is worth keeping, it belongs in .claude/scripts/ with the log path derived rather than hardcoded.

D-F2 — nit: duplicate fixture UUID

middleware_test.go:519 (the new TestApproveRIExchange_CSRFFailureDoesNotFallThroughToToken) and :577 (TestApproveRIExchangeViaSession_PassesCSRF) both use 550e8400-e29b-41d4-a716-446655440021. Harmless today — separate mock instances — but it is a copy-paste artifact and the neighbouring tests otherwise increment. One character.

Gates at ba3d34f36

Gate Result
go test ./internal/api/ -count=1 exit 0, ok ... 9.3s
golangci-lint run ./... at CI-pinned v2.10.1 exit 0 and a genuine 0 issues.
gocyclo -over 10 -ignore "_test\.go" . (repo-wide) empty

One thing to watch, not a finding: approveRIExchange went 8 → 9 with the added || term, and approveRIExchangeViaSession is also at 9. Two functions in this file now sit one point below the pre-commit ceiling of 10, so the next case or condition added to either will redden pre-commit --all-files.


Priority 4 — my view, since you asked me to decide

Add an end-to-end test, rename the existing one, and file the dead-code removal separately. Not "fix or delete or replace" — the first two together, and explicitly not the third as a replacement.

Add (the item that actually matters). The guard that would have caught #1757 does not exist in any form, and it is cheap: a table-driven test that drives Router.Route for each AuthPublic money route with a valid session and no CSRF header, asserting refusal. My probes are exactly this shape and took ~40 lines for the whole matrix. That converts a property currently proven only by this review into a permanent regression guard — and it guards the class, so the next AuthPublic route someone adds is covered without anyone remembering #1757. Everything else here is bookkeeping by comparison.

Rename. TestRequiresCSRFValidation_TokenBasedPathsWithSession → something like TestRequiresCSRFValidation_PerPathTable_NotEndToEnd. The test misled through its name, and names are what appear in CI output, go test -v, and a grep for CSRF coverage. The scope-limit comment you added is accurate and well-argued, but it only reaches someone who already opened the file — which is not how the original misreading happened.

Do not delete the csrfExemptWhenTokenOnly block in this PR. It is genuinely dead — isPublicEndpoint returns at handler.go:762 for all four of its prefixes, so requiresCSRFValidation at :784 is never consulted for them — and dead code that reads as live protection is the root cause of #1757, so it should go. But it is a separate concern from the CSRF fix, it touches a predicate shared with live non-AuthPublic routes, and removing it before the end-to-end test lands would briefly trade real coverage for none. File it next to LeanerCloud/cloud-commitments-platform#192.

This is a shift in emphasis from what I said last round. Asked narrowly what to do with that one test, "rename it" was the answer; asked whether an end-to-end test should exist, that is plainly the higher-value item, and it is additive rather than a substitute.


Verdict

F1 is closed and the fix is correct. The narrowing is minimal, the fallback it had to preserve is intact under execution, the sentinel's flow is contained to one producer and one consumer, wrapping is safe in both directions, purchases has no sibling instance, and all gates are green.

Blocking on D-F1 only — drop ci_gate_check.sh from the commit. That is a git rm --cached and a reword, not a code change. D-F2 is a one-character nit worth folding into the same amend. Once ci_gate_check.sh is gone, this is ready to merge.

…path

Review finding F1. approveRIExchange falls back to the token flow when the
session path returns 403, so email-link holders can still approve. That test
is isPermissionDenied, which compares the status code alone, so a CSRF
rejection was indistinguishable from a legitimate approve-own denial and the
request proceeded on the token.

Measured before fixing, session with no CSRF headers and a valid ?token=:

    err                               = <nil>
    TransitionRIExchangeStatus called = true
    ExecuteExchange called            = true    money moved
    transition actor was NIL          = true    audit attribution lost
    StampRIExchangeApprovedBy called  = false

Not a reopened CSRF hole: the caller still needs the unguessable ApprovalToken,
which is constant-time compared and is sufficient authorization by design. But
a legitimate session-authed approve carrying a token degraded to an
unattributed system approval on a money path, and the code comment claimed
parity with approvePurchaseViaSession, which returns its CSRF 403 directly
rather than swallowing it. Shipping a #1757 fix whose comment overstates what
the code does is the failure mode #1757 was about.

CSRF rejection is now the errCSRFRejected sentinel. Note precisely what
protects it: isPermissionDenied(errCSRFRejected) is still true, because the
sentinel is a 403. What keeps it out of the fallback is the short-circuit
ordering of || at the dispatch, with the errors.Is term first. "Reached first"
rather than "unreachable", and the two have different failure modes: reordering
that expression reopens F1. The new test drives that exact path, so a reorder
fails it.

Chose the sentinel over widening isPermissionDenied because that predicate has
three other call sites in handler_purchases.go, and narrowing its meaning
globally is a larger blast radius than this finding warrants. Verified the
record-level approve-own denial still falls through, which is the case the
fallback exists for.

The new test drives the dispatch with a valid token, which the existing
empty-token test cannot reach, and asserts the money-moving call is never made.
Mutation-verified: replacing the sentinel with a plain NewClientError(403, ...)
makes it fail on both the TransitionRIExchangeStatus assertion and "an error is
expected but got nil", by assertion rather than by panic. Restored afterwards
and confirmed byte-identical.

Refs #1757
@cristim
cristim force-pushed the sec/1757-riexchange-csrf branch from ba3d34f to 6026bb3 Compare August 11, 2026 00:32
@cristim

cristim commented Aug 11, 2026

Copy link
Copy Markdown
Member Author

Sign-off — ba3d34f36 → 6026bb3c7

D-F1 closed. The delta is exactly the 25-line deletion and nothing else:

$ git diff ba3d34f36 6026bb3c7 --stat
 ci_gate_check.sh | 25 -------------------------
 1 file changed, 25 deletions(-)

Because that diff is empty for every .go file, the Go sources at 6026bb3c7 are provably byte-identical to ba3d34f36 — so every measurement from the delta review carries over exactly, by inference rather than by assumption. git grep ci_gate_check finds no reference anywhere in the tree, so nothing depended on it. Final file list vs fcdc681f9 is the expected five:

internal/api/handler_ri_exchange.go
internal/api/handler_ri_exchange_test.go
internal/api/handler_router.go
internal/api/middleware.go
internal/api/middleware_test.go

Re-run at 6026bb3c7: go test ./internal/api/ -count=1 exit 0 (ok ... 16.3s); gocyclo -over 10 -ignore "_test\.go" . repo-wide empty.

D-F2 is still open (middleware_test.go:519 and :577 both use …446655440021). Still a one-character nit, still not worth a CI cycle on its own — fold it into the Priority 4 follow-up below.

State right now: CI at 6026bb3c7 is pending (Build Docker Image, E2E, Integration, Lint, Security Scanning all still running; Terraform and ARM-parity have passed), and CodeRabbit says "Review in progress". MERGEABLE / BLOCKED. So green is not yet established — I'm reporting what I observed, not clearing the gate.


Priority 4 — my call

Replace, as you lean — but in a follow-up, and the replacement is two changes, not one.

1. Add the guard that does not exist. A table-driven test driving Router.Route over every AuthPublic money route with a valid session and no CSRF header, asserting refusal. This is the only artifact here that would have caught #1757, and it guards the class: the next AuthPublic route someone registers is covered without them having heard of this issue. My probes are exactly this shape, ~40 lines for the whole matrix.

2. Delete TestRequiresCSRFValidation_TokenBasedPathsWithSession together with the dead csrfExemptWhenTokenOnly block, in one commit — not separately. This is where I'd adjust your plan. That test is the only thing pinning the block's behaviour. Deleting the test alone leaves unreachable production code with zero coverage, which is strictly worse than today: right now the table is at least pinned if someone ever makes those routes non-public. They are a matched pair — the dead code and the test that makes it look alive — and a single commit removing both, with the unreachability shown in the message (isPublicEndpoint returns at handler.go:762 for all four prefixes, so requiresCSRFValidation at :784 is never consulted for them), is reviewable in a way that either half alone is not.

Not in this PR. #1757 is a live money-path vulnerability on main until this merges; test hygiene does not reduce that exposure, and every extra commit here is another full CI cycle plus a CodeRabbit round on a p0. Blocking a verified p0 fix on a test-only improvement inverts the priority. Merge this, then land the pair immediately after.

This is the same conclusion I reached last round, sharpened by your framing: you're right that a test naming the exact property being violated is worse than none, and I'm not defending keeping it. The only thing I'd change is the sequencing — it goes out with the dead block, not ahead of it.


Nothing further from me

The fix is correct and complete for its scope, verified by execution at every step rather than by inspection:

  • The vulnerability and the fix both reproduced through Router.Route with a purchases control differentiating in the same run, reaching ExecuteExchange pre-fix — money moved, not merely state changed.
  • Both directions confirmed; the dashboard flow completes end to end with approved_by stamped.
  • F1 closed, and the three fallback paths it had to preserve (no session, approve-own record-level denial, no approve right) all still complete — D3 in particular, which a fix that closed F1 by breaking the fallback would have passed every F1 test while breaking.
  • The sentinel's flow is contained to one producer and one consumer; wrapping is safe in both directions; purchases has no sibling instance of the shape.
  • Completeness sweep over all 121 routes proves RI-exchange approve was the only gap, and rejectRIExchange genuinely needs nothing.
  • The rebase replayed faithfully, content byte-identical.

Merge when CI and CodeRabbit actually come back clean. Two follow-ups to file: the Priority 4 pair above, and LeanerCloud/cloud-commitments-platform#192 which you already have.

@cristim

cristim commented Aug 11, 2026

Copy link
Copy Markdown
Member Author

Merging. Closes #1757 (p0/severity/critical).

Recovered work. These commits were written a day ago, committed to a worktree, and never pushed because the authoring agent hit a quota limit between committing and pushing. Rebased onto current main (13 commits behind, zero conflicts) and re-verified after the rebase rather than carrying earlier gate results forward.

The vulnerability

The session-authed RI-exchange approve path had no CSRF enforcement at all. Reproduced independently in review through Router.Route, with the purchases control in the same probe run:

CONTROL (purchases approve, valid session, no CSRF headers):
    err = "CSRF validation failed"          <- correctly refused

RI-EXCHANGE approve (same shape), pre-fix:
    err = <nil>
    TransitionRIExchangeStatus called = true
    ExecuteExchange called            = true   <- the exchange executed

The control differentiating within the same run is what rules out "RI-exchange happens to fail for an unrelated reason". A logged-in user visiting an attacker-controlled page that fired a cross-site POST to /api/ri-exchange/approve/<id> had their exchange approved and executed. Exactly the threat model #404 fixed for purchases; never applied here.

Why it survived, and both reasons are the same defect

A comment asserted the protection existed. middleware.go:288-289 used the same sentence structure as the purchases entry two lines above, where "not here" means "enforced inside the handler". For purchases that is true. For RI-exchange, validateCSRF appeared nowhere in the file.

A test asserted it was required, and passed. TestRequiresCSRFValidation_TokenBasedPathsWithSession checks the predicate directly, bypassing the isPublicEndpoint gate that stops the real dispatch from ever consulting it. Its name and its passing assertion both read as proof of enforcement; neither touches the enforced path.

A comment claiming the control and a green test naming the exact property. Both reassuring, both wrong.

Review found a second defect inside the fix

approveRIExchange falls back to the token flow on a 403 from the session path, and isPermissionDenied compares only the status code. So the new CSRF rejection was indistinguishable from a legitimate approve-own denial, and a session with a valid ?token= proceeded anyway: the exchange executed as an unattributed system approval (transitioned_by NULL, no approved_by stamp) on a money path.

Not a reopened CSRF hole — the caller still needs the unguessable ApprovalToken, constant-time compared, sufficient authorization by design. But the code comment claimed parity with approvePurchaseViaSession, which returns its CSRF 403 directly rather than swallowing it. Shipping a #1757 fix whose comment overstates what the code does is the failure mode #1757 was about.

Fixed with an errCSRFRejected sentinel. The mechanism, stated precisely: isPermissionDenied(errCSRFRejected) is still true, because the sentinel is a 403. What keeps it out of the fallback is the short-circuit ordering of ||, with the errors.Is term first. "Reached first", not "unreachable" — and reordering that expression reopens the defect, which the new test now catches.

Chosen over widening isPermissionDenied, which has three other call sites; verified by execution that the record-level approve-own denial still falls through, since that is the case the fallback exists for.

Verified

  • Both directions: no CSRF token refused before TransitionRIExchangeStatus; a valid token still succeeds end to end.
  • The new test drives the dispatch with a valid token, structurally unreachable by the existing empty-token test. Mutation-verified: a plain NewClientError(403, ...) in place of the sentinel fails it on both the money-moving assertion and "an error is expected but got nil", by assertion rather than panic. Restored byte-identical afterwards.
  • Completeness proven, not merely unfound: rejectRIExchange needs nothing (no session-authed branch, unconditionally token-gated with subtle.ConstantTimeCompare), and a sweep of all 121 routes found no sibling gap.
  • Wrapping safe in both directions, with isPermissionDenied being a strict non-unwrapping assertion providing a second independent protection.
  • Rebase replayed faithfully; 20/20 checks; zero unresolved threads.

Filed from this review, not folded in

  • #1798 — a class-level CSRF guard over every AuthPublic money route driven through Router.Route, plus removal of the vacuous test together with the dead csrfExemptWhenTokenOnly block it pins. Deleting the test alone would leave unreachable production code with zero coverage, which is worse than today.
  • #1797 — /api/info is prefix-matched as public while /api/info/deployment is AuthUser, so validateSecurityContext skips authentication for an endpoint returning the AWS account ID and Secrets Manager ARN, saved only by Router.Routes defense in depth.

Test hygiene was kept out of this PR deliberately: it does not reduce the exposure, and every extra commit on a p0 is another CI cycle and CodeRabbit round while the vulnerability is live.

@cristim
cristim merged commit ddeeb98 into main Aug 11, 2026
20 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/s Hours impact/all-users Affects every user priority/p0 Drop everything; same-day fix severity/critical Major harm when it happens triaged Item has been triaged type/security Security finding urgency/now Drop other things

Projects

None yet

Development

Successfully merging this pull request may close these issues.

sec(api): RI-exchange session-authed approve has no CSRF enforcement despite a comment claiming parity with purchases

1 participant