Repository navigation
sec(purchase): pin the approval token in the token approve/cancel CAS; compare revoke token first - #1242
Draft
IAmJSD wants to merge 3 commits into
Draft
sec(purchase): pin the approval token in the token approve/cancel CAS; compare revoke token first#1242IAmJSD wants to merge 3 commits into
IAmJSD wants to merge 3 commits into
Conversation
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>
Contributor
|
Important Draft PR not reviewedDraft PRs are not automatically reviewed by default.
To automatically review draft PRs, update your CodeRabbit configuration: reviews:
auto_review:
drafts: true
Comment |
This was referenced Oct 22, 2026
fix(history): flag a failed execution class listing as truncated and expose the actionable cap
#1276
Draft
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Root cause
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 (TransitionExecutionStatustorunning, andCancelExecutionAtomic). 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.validateRevokeTokenrevealed 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: newCancelExecutionAtomicTokenPinned. It isCancelExecutionAtomicwithapproval_token_hash = hash(token) AND (expires_at IS NULL OR expires_at > NOW())in the sameUPDATEas the status change. A lost pin on a row that is still pending/notified returnsErrApprovalTokenChanged. A lost status race still returns(false, currentStatus, nil). The unpinnedCancelExecutionAtomic(used by the session cancel and the revoke paths) is unchanged: the new method shares its body and adds aNULL-able token parameter.purchase:ApproveExecutionnow claims throughTransitionExecutionStatusTokenPinned(..., "running", ..., token)viaclaimRunningForApprove.CancelExecutionnow cancels throughCancelExecutionAtomicTokenPinned. Session approve, direct execute and run-now pass no token and are unchanged.api:tokenActionErrormapsconfig.ErrApprovalTokenChangedto 403 "invalid approval token" (it was a 500), matching whatapproveWithDelayalready returns.validateRevokeTokennow compares the hash first, then checks expiry, then the window.Concurrency: under READ COMMITTED, a pinned
UPDATEthat waits behind an uncommitted rotation re-evaluates itsWHEREagainst the rotated row and matches nothing. The rotation's ownWHERE 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/nullbecause this machine's default AWS profile is invalid.Fail before (on
origin/mainc8bdd46): 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':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.... but got nil. the revoked token must not cancelround 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 linkexpected: 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 newTestPostgresStoreDB_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 throughpg_stat_activity) andTestPostgresStoreDB_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.claimRunningForApprovealways unpinned andCancelExecutionback onCancelExecutionAtomic: the purchase unit tests panic on the unregistered unpinned call, and the integration tests fail.ErrApprovalTokenChangedmapping removed:TestHandleRequest_TokenActions_TokenRotatedBeforeCASIs403fails (500 instead of 403).go build ./...,go vet ./...,go vet -tags integration ./internal/... ./cmd/...: clean.--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.shon the changed files: clean.gofmt -l: clean.--register-aws, in a throwaway repo with an isolated HOME, with the changed files plus.gitallowedtracked: 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
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 newCancelExecutionAtomicTokenPinnedmock 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 #590TransitionExecutionStatusTokenPinnedmock, so a revert to the unpinned call fails loudly. "Not called" guards on the token path now name the pinned method.TestHandler_revokePurchase_ExpiredTokenstored the raw token asApprovalTokeninstead 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_WritesCanonicalStatusexpects the new 4th SQL arg (NULLfor the unpinned cancel).Out of scope / notes
clearApprovalTokenstill clears the token with a full-rowSavePurchaseExecutionof a re-read row. That is pre-existing and touches only an already-canceled row. Not changed here.revokeViaSessionmovescompletedtorevocation_requestedwith a plain CAS aftervalidateRevokeToken'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.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