Repository navigation
fix(api): pin the approval token inside the delayed-approval status CAS - #943
Conversation
The email-link approve with a purchase delay validated the token against the execution loaded at the start of the request, then scheduled the row with a status-only CAS. A token rotated by a re-notification (or expired) between the load and the CAS could still schedule the purchase. Add TransitionExecutionStatusTokenPinned, which checks approval_token_hash and approval_token_expires_at in the same UPDATE as the status change and reports a lost token pin as ErrApprovalTokenChanged. The delayed token path uses it and answers 403; the session path keeps the plain status CAS. Closes LeanerCloud#590 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 d11b9c1: draft: blocks ready
No BLOCKING findings.
NON-BLOCKING
- internal/api/handler_purchases.go:1113: the pinned-vs-unpinned CAS is chosen by
approvalToken != "". Today the token path cannot reach it with an empty token (purchase.ValidateApprovalToken, approvals.go:125, rejects an empty token first), so there is no fail-open now. But a future refactor that drops the pre-check would silently fall back to the unpinned CAS. Consider an explicit bool or two named call sites. Follow-up only. - A token that expires in the milliseconds between the pre-check and the CAS now answers 403 instead of 410 (stated in the PR body). Harmless.
What I attacked
- Replay: after a successful pinned CAS the row is
scheduled; replaying the same token passes the snapshot pre-check but the CAS fails on status andtransitionRejection(store_postgres.go, new helper) returns ErrExecutionNotInExpectedStatus (409), not the token error. Covered by the PR's real-Postgres test. - Rotated or expired token: the SQL predicate (
approval_token_hash = $7 AND (expires IS NULL OR expires > NOW()), store_postgres.go:1246) is evaluated in the same UPDATE as the status change, so rotation between snapshot and CAS cannot win. A NULL stored hash makes the predicate NULL (no match), and an empty token is refused before SQL (store_postgres.go, TokenPinned). The hash isHashApprovalToken(sha256 hex), the same function used to store tokens (approval_token.go:20). - Concurrent approve vs cancel: both go through status-CAS on
status = ANY(pending, notified); the loser gets ErrExecutionNotInExpectedStatus (409). The probe cannot misreport a cancel as a token error because the token error needs the row to still be in an allowed status. Race window inside the probe: the status may change between UPDATE and probe; that yields at worst a 409 or 403, never a schedule. - Post-CAS write:
SavePurchaseExecutionupserts the row returned by the CAS (fresh), so a rotation after the CAS cannot overwrite the new hash with a stale one. Session path passes "" and is unchanged (only two callers ofapproveWithDelay: handler_purchases.go:773 token, :862 session). - Old tokens invalidated: the token is invalidated for approval by rotation (RotatePendingApprovalToken) and by the status leaving pending/notified; the hash is retained after scheduling, which is pre-existing and harmless since status gates it.
- Real Postgres (testcontainers, postgres:16-alpine):
go test -tags integration ./internal/config -run TestPostgresStoreDB_TransitionExecutionStatusTokenPinnedpasses at the head (5 subtests). Unit:go test ./internal/api ./internal/config ./internal/purchasepasses (4655 tests). - Mutation probes on the SQL (real Postgres), reverted after each: (1) token predicate replaced by TRUE: 4 subtests FAIL (rotated, expired, wrong/empty, no stored token); (2) expiry clause removed: the expired subtest FAILs. This is fail-before behavior equal to main's status-only CAS.
- Scratch: testcontainers containers are removed by ryuk; I started no other databases.
Gate status (origin/main Review gate)
- Independent review: this comment, against the SHA above.
- CI on the SHA: all checks pass (Snyk skipped; pre-commit and CI - Build & Test success); mergeStateStatus CLEAN. CodeRabbit skipped (draft); no threads.
- Labels: match issue #590 (effort/s, impact/few, priority/p3, severity/low, triaged, type/bug, urgency/eventually).
- Local verification: real-Postgres integration test for the affected CAS plus handler mocks. No real email-link end-to-end run; no cloud calls.
Blocks ready
- Token pinned inside the same UPDATE as the status change; errors distinguish token loss from status loss.
- Fail-before/pass-after demonstrated on real Postgres by mutation of the SQL predicate.
- CI green; labels mirrored.
|
Gate record for d11b9c1 (origin/main CLAUDE.md "Review gate"):
Marking ready (owner-authorized) and merging at d11b9c1 with --match-head-commit. |
|
Addressed the first non-blocking note at d3b1d18, one commit on top of the reviewed d11b9c1. No rebase. Pinned vs unpinned CAS chosen by
403 vs 410 on expiry in the race window: left as is, as stated in the PR body. Checks on macOS, with AWS config and credentials set to /dev/null:
These are unit tests with mocks. The SQL predicate is unchanged, so the real-Postgres integration evidence from the review still applies. 🤖 Generated with Claude Code |
scheduleApprovedExecution picked the pinned CAS from approvalToken != "", so a future refactor that let an empty token reach it would silently fall back to the unpinned status-only CAS. Pass an explicit approvalGate (tokenApproval / sessionApproval) instead; an empty token on the email-link path now hits the pinned CAS, which refuses it. Addresses review note on LeanerCloud#943. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Root cause
When a purchase delay is configured,
approveViaTokenchecks the email-link token against the execution it loaded at the start of the request (purchase.ValidateApprovalToken, added for #586). ThenapproveWithDelay→scheduleApprovedExecutionschedules the row withTransitionExecutionStatus, and that CAS filters on status only. If a re-notification (RotatePendingApprovalToken) rotated the token, or the token expired, between the load and the CAS, the old token could still schedule the purchase. The immediate path doesn't have this gap.Fix
TransitionExecutionStatusTokenPinned(inconfig.StoreInterface, implemented inPostgresStore). It addsapproval_token_hash = sha256(token) AND (approval_token_expires_at IS NULL OR approval_token_expires_at > NOW())to the sameUPDATE … WHERE status = ANY(...), so the token is checked against the row inside the transition.config.ErrApprovalTokenChanged. A lost status CAS still returnsErrExecutionNotInExpectedStatus, and a probe DB error still propagates. The zero-row probe moved intotransitionRejectionto keep gocyclo under 10. An empty token never matches.scheduleApprovedExecution/approveWithDelaynow take the raw token. The email-link path passes it and uses the pinned CAS. The session path passes""and keeps the plain status CAS.ErrApprovalTokenChangedmaps to 403 "invalid approval token", the same answer as a wrong token, and the row is neither scheduled nor stamped.Closes #590
Verification
All evidence comes from unit, mock and local-Postgres tests. Nothing calls a cloud API or makes a purchase. Unit runs used
AWS_CONFIG_FILE=/dev/null AWS_SHARED_CREDENTIALS_FILE=/dev/nullbecause the default AWS profile on this machine is invalid.New and updated tests:
TestPostgresStoreDB_TransitionExecutionStatusTokenPinnedininternal/config/store_postgres_db_test.go.RotatePendingApprovalTokenrotates it, then the pinned CAS with the old token returnsErrApprovalTokenChangedand the row staysnotified.ErrExecutionNotInExpectedStatus, not the token error.internal/api/handler_purchases_delay_token_test.go:TokenRotatedBeforeCASIs403: the snapshot holds a valid token and the pinned CAS reports a rotation. The handler answers 403 and never callsSavePurchaseExecutionor the unpinned CAS.ValidTokenSchedulesnow requires the pinned CAS to receive the supplied raw token, and the unpinned CAS must not be called.store_postgres_pgxmock_test.go: the arg count for the transition query went from 6 to 7.Fail-before / pass-after, shown by reverting each part of the fix (mutation probes):
TRUE(main's behavior)TransitionExecutionStatus(main's behavior)ValidTokenSchedulesfails (unmocked unpinned call)ErrApprovalTokenChangednot mapped inapproveWithDelayTokenRotatedBeforeCASIs403fails: expected 403, actual 500Commands (all pass with the fix):
Overlap with #935 (
fix/issue-597)I did a trial merge of this branch (rebased on current
main, 2f41346) withfork/fix/issue-597on a throwaway branch.handler_purchases.goandhandler_purchases_test.goauto-merged with no conflicts. On the merged result,go build ./...,go vet ./...,go test ./... -count=1, the delayed-approval handler tests, the token-pinned real-Postgres test, golangci-lint and gocyclo all pass. This PR leaves #935's code alone: the CSRF check, the 404 concealment and the orphan check.Out of scope / not done
approval_token_hashat scheduling time is not done here. Replay of a scheduled row is already blocked by the status CAS, and clearing the hash would also need a review of theSavePurchaseExecutionfull-row write that follows the CAS.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