Repository navigation
fix(api): answer a hidden execution like a missing one on approve, cancel and revoke - #935
Conversation
…ncel and revoke A signed-in caller could still tell a missing execution from one they may not act on. A missing ID got 404 "execution not found". An existing one got a 403 RBAC or contact_email denial, a 404 "Not found" from the account-scope gate, a 409 from the approve orphan preflight, or 403 "CSRF validation failed" when the CSRF header was missing. The routes now validate CSRF before the lookup for every session caller, token branch included. Final authorization denials (scope miss, RBAC 403, contact_email 401/403) map to the same 404 a missing ID gets. The orphan 409 only runs once the session is authorized. Constraint denials and 4-eyes still answer 403, since they reach only callers who already hold approve-* for the execution. Closes LeanerCloud#597 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Note Currently processing new changes in this PR. This may take a few minutes, please wait... ⚙️ Run configuration
📒 Files selected for processing: 11
✨ Finishing Touches
Comment |
cristim
left a comment
There was a problem hiding this comment.
Independent review of 8441e6f: clean
Reviewer: fresh cold-briefed agent (non-author), dispatched by claude-cc-pr-merge-2. This is a security review of an auth + purchase-approval path.
Findings (none blocking)
- Stale comments at
internal/api/handler_purchases.go:660-662and:1386("with no session ... returns 401"). That branch is unreachable now thatrequireSessionBeforeLookupguarantees a session. - CSRF is validated twice per request (
:703and:871,:1459,:1665).auth.ValidateCSRFToken(internal/auth/service.go:369) is idempotent, so the cost is one extra session lookup. The second check is redundant defence in depth (probe p9). - Residual oracle for callers holding the approve verb: the constraint-denied 403 and the 4-eyes 403 are deliberately not concealed. These callers already passed the scope gate and can list executions. Acceptable, and consistent with the PR's design.
- Timing and transient errors: an existing ID triggers extra
GetCloudAccount/GetGlobalConfigcalls, so a transient failure gives 500 vs 404, for token-bearing callers only. - Test gap: the oracle matrix never pairs a valid CSRF header with the contact-email caller. That success path is covered only by
TestTokenApprove_SessionRequiresCSRF/valid_CSRF_header. - Pause, resume, run, delete and retry routes were not audited; the PR lists them as out of scope.
Authorization matrix checked (approve / cancel / revoke; missing ID is 404 execution not found both before and after)
| Signed-in caller | Before | After |
|---|---|---|
| No session | 401 | unchanged |
| No CSRF header | 403 if exists / 404 if missing | 403 CSRF validation failed for every ID, before lookup (:699-705) |
| No RBAC, no token | 403 permission denied |
404 concealed |
| approve-own/cancel-own, non-creator | 403 | 404 concealed |
| Scoped out | 404 Not found (different body) |
404 execution not found |
| Token, not the contact email / no contact email | 403 | 404 concealed |
| Orphan, unauthorized (approve) | 409 before authz | 404 (orphan check at :880, after authz) |
| Orphan, authorized | 409 | 409 |
| Authorized with CSRF | success | success |
| Contact-email recipient with token + CSRF | success | success |
| Contact-email recipient with token, no CSRF | success (old bypass) | 403 CSRF |
| Backend errors | 500 | 500 |
Authorization is not weakened: every RBAC, scope, contact-email and 4-eyes denial still errors, and concealExecutionDenial rewrites only the code and body. API-key callers still get 401. The frontend sends X-CSRF-Token on every apiRequest (frontend/src/api/client.ts:183-184, :211). Purchases go through it (purchases.ts:57,70,112), as does the deep link (purchases-deeplink.ts:121).
Removed test assertions audited
All removals are intentional behaviour changes:
TestTokenOnlyApprove_BypassesCSRFis replaced by the strongerTestTokenApprove_SessionRequiresCSRF.- Forbidden assertions became
requireConcealedDenial, which still pins the hidden cause. IsNotFoundErrorbecameisNotFound404, slightly looser but still a 404 with a fixed message.- The Lambda form-body test still proves the token is decoded.
- The roughly 30 added CSRF mocks are additive.
What I attacked
go vetis clean.go test ./internal/api/... ./internal/server/...passes 3876.- Fail-before: with main's
handler_purchases.goplus a stub type, the new tests show 156--- FAILlines.
| # | Mutation (reverted) | Result |
|---|---|---|
| p1 | drop pre-lookup CSRF | 38 fail |
| p2 | drop approve-own RBAC | 1 fail (thin) |
| p3 | drop scope check | 11 fail |
| p4 | token branch raw 403 | 22 fail |
| p5 | skip contact-email match | 1 fail (thin) |
| p6 | orphan preflight back before authz | 10 fail |
| p7 | revoke denial unconcealed | 12 fail |
| p8 | cancel denial unconcealed | 14 fail |
| p9 | drop second CSRF check | 0 fail (redundant, finding 2) |
| p10 | revoke token denial unconcealed | 19 fail |
Worktree cleaned: yes.
|
Gate record for 8441e6f (claude-cc-pr-merge-2):
|
Root cause
Since #594, a caller with no session gets the same 401 on approve, cancel and revoke whether or not the execution exists. A signed-in caller could still tell a missing ID from an existing one:
execution not foundpermission denied: requires ...cannot approve/cancel another user's ...execution not foundNot found(different body)not the authorized approver/no per-account contact email configuredreferences an account that no longer exists(ran before authorization)CSRF validation failed, or success on the token branchCSRF only ran inside the session sub-paths, after the lookup. The orphan preflight ran in
loadApproveExecution, before any authorization.Fix (
internal/api/handler_purchases.go)The decision the issue asks for: yes, for signed-in callers too, "you may not act on this" now answers the same as "this does not exist".
requireSessionBeforeLookupnow also validates CSRF, so any session caller without a valid header gets the same403 CSRF validation failed(errCSRFRejected) for every ID. This includes the email-link token branch. Every caller there now has a session (sec(api): unauthenticated cancel/revoke distinguish missing (404) from existing (401) executions #435), and the session bearer is the credential being protected. This matches the intent of sec(auth): enforce per-permission Constraints on ladder/config/RI-exchange-config/marketplace/revoke (SEC-01 follow-up to #60) #404's "session present means CSRF applies". The frontendapiRequestalready sendsX-CSRF-Tokenon every POST, the deep-link flow included.concealExecutionDenialmaps a final authorization denial to the same404 execution not foundthatgetExecutionOr404returns for a missing ID. "Final" means a scope-misserrNotFound, an RBAC 403, or a contact_email 401/403 fromauthorizeApprovalAction. The mapping is applied at the approve, cancel and revoke session-denial branches and at the three token-branchauthorizeApprovalActioncalls. It returns aconcealedDenialErrorthat responds exactly like the missing case and keeps the hidden cause, for an Info log and for tests.constraintDeniedError(approve-any/own Constraints) and the 4-eyes 403. Those only reach callers who already hold the approve verb for the execution and are in scope.loadApproveExecutionis replaced bygetExecutionOr404. The fix(plans): a plan with no matching recommendations schedules zero-count purchases instead of failing #609 orphan check (rejectOrphanExecution) now runs inapprovePurchaseViaSessiononce the session is authorized. The token branch needs no copy: an orphan has no per-account contact_email, soauthorizeApprovalActionnever authorizes it, andpurchase.ApproveExecutionruns the same predicate itself.Legitimate flows are unchanged: an admin or RBAC holder with CSRF, and the contact-email recipient on the email link with CSRF.
Behavior changes reviewers should weigh
TestTokenOnlyApprove_BypassesCSRFpinned the old behavior and is replaced byTestTokenApprove_SessionRequiresCSRF. That test checks that no header gives 403 and the execution is never read, and that a valid header still approves. Non-browser callers that use a session bearer without a CSRF token on these three routes would now get 403. API-key callers are unaffected:tryGetSessionreturns nil for them, so they already got 401 (sec(api): unauthenticated cancel/revoke distinguish missing (404) from existing (401) executions #435).execution not foundinstead of the specific reason, for example "set the cloud account's contact_email". The reason is logged at Info (purchase action denied, answered as execution not found: ...).Tests
TestHandleRequest_TokenActions_SignedInCannotTellMissingFromExisting(handler_purchases_token_oracle_test.go). It runs throughHandleRequestand covers approve, cancel and revoke × {pending, completed, Azure orphan} × token {none, valid, wrong} × 6 signed-in callers: no RBAC with CSRF, no RBAC without CSRF, approve-own/cancel-own non-creator, scoped-out, admin without CSRF, contact email without CSRF. That is 153 subtests. Each one asserts identical status, headers and body for the existing and the missing ID. Every status write in that fixture loses its CAS, so even a regression cannot reach a purchase.TestTokenApprove_SessionRequiresCSRF(described above).requireConcealedDenial(t, err, "<original reason>"). It asserts the 404 body and that the hidden cause is still the gate the test is about. These are:session_denial_final_test.go(approve/cancel/revoke), the RejectsMismatchedSession / RejectsMissingContactEmail / RejectsGlobalNotifyWhenContactSet approve tests, the CancelOwn / NoVerb / LegacyNullCreator / DeepLink contact-gate cancel tests,revokePurchase_SessionNoPermissionNoTokenandapprovePurchase_OutOfScope.isNotFound404.internal/serverTestHandleLambdaHTTPEvent_DecodesBase64FormBodynow proves body-token decoding with the post-authorizationinvalid revocation token403, because the two 403s it used to tell apart are now both concealed.ValidateCSRFTokenmock because CSRF now runs before the lookup.Verification
All of this is unit/mock evidence: testify mocks plus the real
purchase.Managerover a mock store. No real cloud calls or purchases were made. I did not run the frontend against a deployed backend, so the deep-link flow sendingX-CSRF-Tokenis verified by readingfrontend/src/api/client.ts, not end to end.Tests ran with
AWS_CONFIG_FILE=/dev/null AWS_SHARED_CREDENTIALS_FILE=/dev/nullbecause this machine's default AWS profile is invalid.Fail-before / pass-after: I put the
mainhandler_purchases.goback with the new tests. A one-line stub type was needed so the test helper compiles.SignedInCannotTellMissingFromExisting: 153/153 subtests FAIL.TestTokenApprove_SessionRequiresCSRF/no_CSRF_headerFAIL.Out of scope
approveViaTokenonly changed itsauthorizeApprovalActionerror mapping.errNotFound. I did not audit them for the same RBAC-403 vs 404 difference. That may deserve a follow-up issue.Closes #597
Maintainer: please mirror the issue's triage labels on this PR:
triaged,priority/p3,severity/low,urgency/eventually,impact/few,effort/s,type/bug.🤖 Generated with Claude Code