Skip to content

sec(purchase): pin the approval token in the token approve/cancel CAS; compare revoke token first - #1242

Merged
cristim merged 4 commits into
LeanerCloud:mainfrom
IAmJSD:fix/issue-1190
Oct 25, 2026
Merged

cristim merged 4 commits into
LeanerCloud:mainfrom
IAmJSD:fix/issue-1190

Conversation

@IAmJSD

@IAmJSD IAmJSD commented Oct 22, 2026

Copy link
Copy Markdown
Collaborator

Root cause

  1. Token approve/cancel race on rotation. The immediate email-link/SQS approve (ApproveExecution) and the email-link/SQS cancel (CancelExecution) checked the approval token against a snapshot (ValidateApprovalToken), then moved the row with a plain status CAS (TransitionExecutionStatus to running, and CancelExecutionAtomic). If a re-notification rotated the token (RotatePendingApprovalToken) between the read and the CAS, the old, revoked token still won: it claimed the row and bought, or canceled it. The delayed approve was already pinned by sec(api): delayed token approval validates the execution snapshot, not the token hash at the status change #590 (TransitionExecutionStatusTokenPinned); these two paths were not.
  2. validateRevokeToken revealed expiry to a wrong token. It checked the link's expiry and the revocation window before comparing the hash. A wrong token on a lapsed row got "revocation link has expired" (409) or "revocation window has closed" (403) instead of "invalid revocation token".

Fix

  • config: new CancelExecutionAtomicTokenPinned. It is CancelExecutionAtomic with approval_token_hash = hash(token) AND (expires_at IS NULL OR expires_at > NOW()) in the same UPDATE as the status change. A lost pin on a row that is still pending/notified returns ErrApprovalTokenChanged. A lost status race still returns (false, currentStatus, nil). The unpinned CancelExecutionAtomic (used by the session cancel and the revoke paths) is unchanged: the new method shares its body and adds a NULL-able token parameter.
  • purchase: ApproveExecution now claims through TransitionExecutionStatusTokenPinned(..., "running", ..., token) via claimRunningForApprove. CancelExecution now cancels through CancelExecutionAtomicTokenPinned. Session approve, direct execute and run-now pass no token and are unchanged.
  • api: tokenActionError maps config.ErrApprovalTokenChanged to 403 "invalid approval token" (it was a 500), matching what approveWithDelay already returns. validateRevokeToken now compares the hash first, then checks expiry, then the window.

Concurrency: under READ COMMITTED, a pinned UPDATE that waits behind an uncommitted rotation re-evaluates its WHERE against the rotated row and matches nothing. The rotation's own WHERE status IN ('pending','notified') refuses a row the action has already moved. So exactly one of the two can win. Both orderings and the blocked-on-lock case are tested on real PostgreSQL (below).

No migration.

Closes #1190

Verification

All of this is local, on macOS, with Docker testcontainers PostgreSQL 16 (real DB) plus mock/fixture providers and email. Nothing calls a cloud API. Tests ran with AWS_CONFIG_FILE=/dev/null AWS_SHARED_CREDENTIALS_FILE=/dev/null because this machine's default AWS profile is invalid.

Fail before (on origin/main c8bdd46): I copied the new tests that only use APIs already on main into a detached worktree of main and ran them there.

  • go test -tags integration ./internal/purchase/ -run 'TestApproveExecution_TokenRotatedBeforeClaimIsRefused|TestCancelExecution_TokenRotatedBeforeCancelIsRefused|TestCancelExecution_RacesRotation':
    • approve: Expected error with "approval token no longer matches the execution" in chain but got nil. the revoked token must lose the claim. On main the revoked token claimed the row and ran it. The positive control passes on both.
    • cancel: ... but got nil. the revoked token must not cancel
    • race: round 1: exactly one of the cancel (err=<nil>) and the rotation (true) may win, meaning both succeeded.
  • go test ./internal/api/ -run TestHandleRequest_RevokeViaEmail_WrongTokenOnLapsedRowIsInvalid: wrong token on an expired link expected: 403 actual: 409. Wrong token past the window was also refused with "window has closed" instead of invalid. The right-token cases pass on both.

Pass after (this branch):

  • go test -tags integration ./internal/config/ -count=1: ok. This is the whole package, including the new TestPostgresStoreDB_CancelExecutionAtomicTokenPinned, TestPostgresStoreDB_TokenPinnedActionBlockedBehindRotationLoses (cancel and claim-to-running each block on the row lock behind an uncommitted rotation and must lose; the test detects the wait through pg_stat_activity) and TestPostgresStoreDB_TokenPinnedActionRacesRotation (40 unsynchronized rounds per action with staggered starts; the test asserts both orderings happened, e.g. cancel: action won 18, rotation won 22).
  • go test -tags integration ./internal/purchase/ -count=1: ok (195s, whole package).
  • go test ./internal/api/ ./internal/purchase/ ./internal/config/ ./internal/mocks/ ./internal/server/ ./internal/analytics/ -count=1: all ok.
  • Mutation probes on this branch, each reverted afterwards:
    • store pins ignored (token hash passed as NULL): the config pinned-cancel, blocked-on-lock and race tests fail.
    • claimRunningForApprove always unpinned and CancelExecution back on CancelExecutionAtomic: the purchase unit tests panic on the unregistered unpinned call, and the integration tests fail.
    • ErrApprovalTokenChanged mapping removed: TestHandleRequest_TokenActions_TokenRotatedBeforeCASIs403 fails (500 instead of 403).
  • go build ./..., go vet ./..., go vet -tags integration ./internal/... ./cmd/...: clean.
  • golangci-lint v2.10.1 --build-tags integration (and without the tag) on the touched packages: 0 issues. gocyclo -over 10 -ignore "_test\.go" on the touched packages: silent. scripts/gosec-hook.sh on the changed files: clean. gofmt -l: clean.
  • git-secrets 1.3.0 --register-aws, in a throwaway repo with an isolated HOME, with the changed files plus .gitallowed tracked: clean, and a planted AKIA key in the same repo was flagged. scripts/test-git-secrets-allowlist.sh: 137 PASS, 0 FAIL.

Test changes worth a look

  • The existing mock tests of the token approve/cancel paths (approvals_test.go, coverage_extra_test.go, claimed_row_check_test.go, aws_no_token_redrive_test.go, messages_token_ttl_test.go, handler_purchases_token_errors_test.go, handler_purchases_cancel_constraints_test.go) now register the pinned store calls. The new CancelExecutionAtomicTokenPinned mock has no default and does not delegate, the same as the sec(api): delayed token approval validates the execution snapshot, not the token hash at the status change #590 TransitionExecutionStatusTokenPinned mock, so a revert to the unpinned call fails loudly. "Not called" guards on the token path now name the pinned method.
  • TestHandler_revokePurchase_ExpiredToken stored the raw token as ApprovalToken instead of its hash, so its "matching" token never matched. It passed only because expiry was checked first. The fixture now stores the hash.
  • TestPGXMock_CancelExecutionAtomic_WritesCanonicalStatus expects the new 4th SQL arg (NULL for the unpinned cancel).

Out of scope / notes

  • After a successful token cancel, clearApprovalToken still clears the token with a full-row SavePurchaseExecution of a re-read row. That is pre-existing and touches only an already-canceled row. Not changed here.
  • revokeViaSession moves completed to revocation_requested with a plain CAS after validateRevokeToken's snapshot check. Nothing rotates the token of a completed row today, so there is no rotation race to pin there. The issue does not name it.
  • On the token approve, a token that expires between the snapshot check and the CAS now gets 403 "invalid approval token" (lost pin) rather than 410. The delayed approve already behaves this way.
  • Known unrelated CI failure: govulncheck (GO-2026-6809) until fix(deps): bump Go to 1.26.10 for GO-2026-6809 #1236 merges.

No frontend changes.

Maintainer: please mirror the issue's triage labels on this PR: priority/p2, severity/medium, urgency/this-quarter, impact/few, effort/s, type/security, triaged.

🤖 Generated with Claude Code

IAmJSD and others added 3 commits October 22, 2026 02:22
CancelExecutionAtomicTokenPinned is CancelExecutionAtomic with the approval
token checked in the same UPDATE as the status change: the stored hash must
still match the caller's token and be unexpired. A token rotated by a
re-notification after the caller's snapshot check now loses the cancel with
ErrApprovalTokenChanged instead of canceling the row (issue LeanerCloud#1190), the
cancel-side twin of the LeanerCloud#590 token-pinned transition.

Real-PostgreSQL tests cover the pinned cancel's outcomes, a pinned action
blocked on the row lock behind a committed rotation, and an unsynchronized
rotation race for both the pinned cancel and the pinned claim to running.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…token cancel

The immediate email-link/SQS approve (ApproveExecution) and the email-link/SQS
cancel (CancelExecution) validated the token against a snapshot, then moved
the row with a plain status CAS. A re-notification rotating the token between
the two let the old, revoked token still claim the row to running (and buy)
or cancel it (issue LeanerCloud#1190, the LeanerCloud#590 class the delayed approve already closed).

ApproveExecution now claims through TransitionExecutionStatusTokenPinned and
CancelExecution cancels through CancelExecutionAtomicTokenPinned, so the token
is re-checked in the same UPDATE as the status change. The session, direct
execute and run-now paths are unchanged (not token-gated). The HTTP layer maps
the lost pin (config.ErrApprovalTokenChanged) to 403 "invalid approval token"
instead of a 500, as the delayed approve already did.

Real-PostgreSQL tests rotate the token between ApproveExecution's/
CancelExecution's snapshot read and their CAS and require the old token to be
refused (both fail on main), plus a cancel-vs-rotation race. Existing mock
tests of the token paths now register the pinned store calls.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d window

validateRevokeToken checked the link's expiry and the revocation window
before comparing the token hash, so a wrong token on a lapsed row got
"revocation link has expired" (409) or "revocation window has closed" (403)
instead of "invalid revocation token", telling a token guesser the link
exists and has lapsed (issue LeanerCloud#1190). The hash is now compared first, as the
approve path's ValidateApprovalToken does.

TestHandler_revokePurchase_ExpiredToken stored the raw token instead of its
hash, so its "matching" token never matched; it passed only because expiry
was checked first. The fixture now stores the hash.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

Caution

Review failed

The pull request is closed.

Finishing Touches
🧪 Generate unit tests (beta)
  • Create a new PR
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

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

Brings in main's CI fixes (LeanerCloud#1236, LeanerCloud#1252) and CI changes since, so this PR gets a fresh CI run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@cristim

cristim commented Oct 25, 2026

Copy link
Copy Markdown
Member

Independent review 2 (claude-fleet, cold): APPROVE 5895ce7

Findings (none blocking):

  • [non-blocking, test gap] internal/purchase/token_rotation_pin_integration_test.go - Nothing in the PR runs two concurrent token approvals with the same token, or races the full ApproveExecution against a rotation. The config package does cover the pinned claim racing a rotation.
    • I wrote both missing tests as scratch probes, and both pass on head. They would be worth adding.
  • [non-blocking, documented] internal/api/handler_purchases.go:971 - A token that expires between the snapshot check and the CAS now gets 403 instead of 410, on the immediate approve and on the cancel. The delayed approve already behaves this way.

Against #1190: this fixes the root cause.

  • The immediate token approve (approvals.go:86 -> claim.go:51-56) and the token cancel (approvals.go:517) now do the token check in the same UPDATE as the status change.
  • That covers both entry points, because both go through ApproveExecution/CancelExecution:
    • the HTTP email-link path (handler_purchases.go:1046, 1671);
    • the SQS path (messages.go:183, 197).

Attacked:

  • Other callers of the old unpinned CAS:
    • Session approve (handler_purchases.go:1138), DirectExecute and RunPlannedPurchaseNow carry no token. Correct.
    • Session cancel (:1749) carries no token. Correct.
    • The delayed approve was already pinned (:1384).
    • revokeViaSession uses a plain CAS on completed rows. The only rotation writer, RotatePendingApprovalToken (notifications.go:193), matches pending/notified rows only. Justified.
    • The RI exchange token checks have no rotation, so they are out of scope.
  • Concurrency on real Postgres (testcontainers):
    • The pin is tested both blocked on the row lock and unsynchronised.
      • Cancel and claim each won 18 of 40 rounds, and the rotation won 22, so both orderings happened.
      • The full CancelExecution against a rotation: cancel won 15, rotation 25.
    • My scratch probe of two same-token ApproveExecution calls ran 15 rounds, with exactly one success and one provider call per row.
    • My probe of ApproveExecution against a rotation ran 40 rounds (approve won 13, rotation 27). Exactly one side won every round, and a revoked token never reached the provider.
  • Mutation probes on a scratch copy, each restored and confirmed identical with cmp. All 9 were caught:
    • M1, the cancel pin passed as nil.
    • M2, the expiry condition dropped from the cancel SQL.
    • M3, cancelRejection no longer returns ErrApprovalTokenChanged.
    • M4, claimRunningForApprove always unpinned.
    • M5, ApproveExecution passes "" as the token.
    • M6, CancelExecution back on the unpinned CancelExecutionAtomic.
    • M7, the ErrApprovalTokenChanged case removed from tokenActionError.
    • M8, the revocation window checked before the hash.
    • M9, the hash check removed from validateRevokeToken.
  • Fail-before, on base:
    • Approve after rotation and cancel after rotation return nil where an error was expected.
    • Cancel racing rotation lets both sides win, and so does approve versus rotation.
    • A revoke with a wrong token gets 409 instead of 403.
    • The positive controls pass.

Verified locally, with Go 1.26.10 and no cloud credentials, under the heavy lock:

  • Head:
    • go build ./...: OK.
    • go vet on api, purchase, config, mocks, server and analytics, plus -tags integration on purchase and config: OK. gofmt -l: clean.
    • go test -count=1 on those packages: ok.
    • go test -tags integration ./internal/config/: ok.
    • The targeted purchase integration tests: pass. These are the PR's token tests, SingleClaim and NotifiedTransition.
  • Merged tree:
    • git merge-tree against origin/main 6af022f is clean, and main has changed none of the PR's production files.
    • go build ./..., go vet ./..., go vet -tags integration ./internal/... and the unit tests of api, purchase, config, mocks and server all pass.
  • No migration.

Reviewed by an independent agent with no author context (second reviewer). Review only: nothing was pushed, and the GitHub review state is unchanged.

@cristim

cristim commented Oct 25, 2026

Copy link
Copy Markdown
Member

VERDICT: APPROVE 5895ce7

Findings (none blocking):

  • nit internal/purchase/messages.go:183,197 - an SQS approve/cancel that loses the token pin returns config.ErrApprovalTokenChanged, which SQS redelivers until the DLQ. ErrInvalidApprovalToken already behaves this way, so this is not new, just noise.
  • nit internal/api/handler_purchases.go:971 - a token that expires between the snapshot check and the CAS now gets 403 "invalid approval token" instead of 410. The PR body says so, and approveWithDelay (sec(api): delayed token approval validates the execution snapshot, not the token hash at the status change #590) already does the same.
  • process nit: b0adbae has 414 changed lines, over the 400 cap. The other commits: c2d09ab 331, 756bf24 74. 5895ce7 merges main and is not counted.

Token paths checked:

  • Immediate approve, HTTP and SQS (ApproveExecution -> transitionApproveAndExecute -> claimRunningForApprove, claim.go:51-56): pinned through TransitionExecutionStatusTokenPinned. Hash equality and unexpiry are checked in the same UPDATE as the status move (store_postgres.go transitionExecutionStatus, the $7 clause).
  • Cancel, HTTP and SQS (CancelExecution, approvals.go:517): pinned through CancelExecutionAtomicTokenPinned, with the $4 clause in the same UPDATE (store_postgres.go:1449-1450).
  • Delayed approve (handler_purchases.go:1384): already pinned by sec(api): delayed token approval validates the execution snapshot, not the token hash at the status change #590.
  • Revoke (validateRevokeToken, handler_purchases.go:1977): the hash is now compared before expiry and the window. The CAS is unpinned, but RotatePendingApprovalToken only touches pending/notified rows, so a completed row has no rotation to race. The RI exchange approve/reject tokens are never rotated, and the PR does not touch them.
  • Constant time: the Go side uses config.ApprovalTokenMatches (subtle.ConstantTimeCompare on SHA-256 hex). The SQL side compares stored digests, so timing reveals nothing about the raw token.
  • Response codes: wrong token 403, expired at snapshot 410, rotated or expired between snapshot and CAS 403 (it was a 500 before), wrong status 409. On revoke, a wrong token is now 403 "invalid revocation token" even on an expired or out-of-window row. A correct token gets 409 when the link has expired and 403 when the window has closed.

Merge check: git merge-tree --write-tree origin/main(6776e27e) 5895ce78 is clean (tree 9c79069f). On the merged tree, run in a fresh mktemp dir with Go 1.26.10:

  • go build ./... and go vet -tags integration on config, purchase and api: clean.
  • Unit tests for api, purchase, config, mocks, server and analytics: 6579 passed.
  • -tags integration (testcontainers PG16), whole packages: config ok in 48s, purchase ok in 29s, api ok in 45s.
  • With -v, the new race tests really ran: "cancel: action won 18, rotation won 22", "approve claim: 18/22", and the purchase-level cancel race "15/25".

Attacked (mutation probes on the scratch copy, each reverted and the restore checked with cmp):

  1. Cancel pin dropped from the SQL ($4 IS NULL OR $4 IS NOT NULL): killed. CancelExecutionAtomicTokenPinned, BlockedBehindRotationLoses, ActionRacesRotation, TokenRotatedBeforeCancelIsRefused and CancelExecution_RacesRotation all failed.
  2. Expiry clause dropped from the cancel pin only: killed by the token_expired_after_the_snapshot_loses_the_cancel subtest.
  3. claimRunningForApprove always unpinned: killed. The integration test old_token_after_a_rotation failed, and the purchase unit tests panicked on the unregistered mock.
  4. validateRevokeToken restored to the old order (expiry and window first): killed by both subtests of TestHandleRequest_RevokeViaEmail_WrongTokenOnLapsedRowIsInvalid.
  5. ErrApprovalTokenChanged -> 403 mapping removed: killed by TestHandleRequest_TokenActions_TokenRotatedBeforeCASIs403 (approve and cancel).
    Also read: the READ COMMITTED reasoning (a blocked pinned UPDATE re-evaluates its WHERE against the rotated row), the rotatingStore fixture, the positive controls, and every caller of the new interface method.

CI on 5895ce7: every check passes (Snyk skipped). That includes the integration shards (api, config, purchase, server), Security Scanning and both Docker builds.

@cristim

cristim commented Oct 25, 2026

Copy link
Copy Markdown
Member

Local verification at 5895ce7845d56fd85dc4e4096919605d8551c801 merged onto current main (bfb4625b; merge-tree be6449de; macOS):

  • go build ./..., go vet -tags integration ./... and go test -race -short ./... pass (full suite).
  • Nothing was applied to any cloud.

@cristim

cristim commented Oct 25, 2026

Copy link
Copy Markdown
Member

Marked ready and merged by the owner's agent after 2 independent reviews and local verification (see the review and verification comments above, all at 5895ce7845d56fd85dc4e4096919605d8551c801).

@cristim
cristim marked this pull request as ready for review October 25, 2026 17:34
@cristim
cristim merged commit 2033cb8 into LeanerCloud:main Oct 25, 2026
33 of 34 checks passed
IAmJSD added a commit to IAmJSD/cloud-commitments-platform that referenced this pull request Oct 25, 2026
Bring the branch up to date with main again; main moved on while the
previous merge was being checked.

Conflicted files:
- internal/analytics/collector_test.go: main (LeanerCloud#1242) added a
  CancelExecutionAtomicTokenPinned stub to the hand-rolled analytics
  mockConfigStore. This branch replaces that mock with one embedding
  mocks.MockConfigStore, which main already gives the same method. Kept
  the branch's version; the stub is not needed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
IAmJSD added a commit to IAmJSD/cloud-commitments-platform that referenced this pull request Oct 26, 2026
Conflict in validateRevokeToken: LeanerCloud#1242 (on main) moved the token match
ahead of the expiry and window checks but kept 409 for an expired link;
this branch routes revoke through purchase.ValidateApprovalToken for the
403/410 mapping. Kept this branch's switch, which already matches the
token (constant time, against the stored digest) before the expiry and
the window, and merged the two doc comments. LeanerCloud#1242's test for that order
(TestHandleRequest_RevokeViaEmail_WrongTokenOnLapsedRowIsInvalid) now
expects 410 for the right token on an expired link, matching approve and
cancel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
IAmJSD added a commit to IAmJSD/cloud-commitments-platform that referenced this pull request Oct 26, 2026
Conflicts in internal/api/handler_purchases.go and its test come only
from LeanerCloud#1172's commit, which this branch is stacked on; this branch does
not touch those files. Took LeanerCloud#1172's resolution of the same merge
(c1919cc): its 403/410 validateRevokeToken switch, which keeps LeanerCloud#1242's
token-first order, and LeanerCloud#1242's order test expecting 410 for an expired
link.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
IAmJSD added a commit to IAmJSD/cloud-commitments-platform that referenced this pull request Oct 26, 2026
One conflict, in email.NotificationData: this branch adds
PartialPurchase (LeanerCloud#532) and main added OutcomeUnknown (LeanerCloud#1415) at the same
spot. Kept both fields. This branch did not carry LeanerCloud#1242's commit, so no
other resolution was needed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/s Hours impact/few Limited audience priority/p2 Backlog-worthy severity/medium Moderate harm triaged Item has been triaged type/security Security finding urgency/this-quarter Within the quarter

Projects

None yet

Development

Successfully merging this pull request may close these issues.

sec(purchase): token approve/cancel race on rotation, and revoke reveals expiry to a wrong token

2 participants