Optimistic-lock TOCTOU in applyRemoteUpdate - stale remote write can clobber a newer local edit #96
Labels
No labels
bug
duplicate
enhancement
future
help wanted
invalid
question
wontfix
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
rbrooks/TeaLeaves#96
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Severity: Medium · Confidence: Medium · Effort: S · Category: code
Evidence
api/src/services/remoteContribution.ts:125-153-updated_atread via the pool outside the txn (:125); conflict decision (:135-137) and UPDATE (:149-152) happen later in a separate client txn with no re-check.Problem
The optimistic-lock read happens outside the transaction that performs the write, with no re-check. Between read and write, a concurrent local edit or another delivery-worker job can bump
updated_at; the stale remote update then overwrites the newer local edit - defeating the lock. Also inherently sensitive to inter-instance clock skew (host clock vs remotepublished).Impact
Silent loss of a local edit under concurrent federated writes. Narrow window, requires an active remote editor.
Fix
Do the check inside the txn with
SELECT . FOR UPDATE, or make the write conditional:UPDATE . WHERE id=$ AND updated_at <= $publishedAtand treat 0 rows as a conflict.Acceptance criteria
Filed from the 2026-07-15 codebase audit. Full report:
docs/.internal/report-2026-07-15.md(gitignored).Fixed in
763aa0b(v7.2.0 wave 1).Took the
SELECT … FOR UPDATEoption. The lock basis is now read inside the write transaction and the row is locked until commit:The pre-txn pool read is gone entirely (it was also a redundant second query). This serialises the two writers both ways: a concurrent local edit blocks until the remote write commits, and if the local edit committed first, we re-read its
updated_atunder the lock and correctly queue a conflict.Acceptance criteria:
api/src/test/integration/remoteUpdateLock.test.ts.The race test is deterministic rather than timing-based: it takes the row lock in the test's own transaction, calls
applyRemoteUpdate(which then blocks inside its txn onFOR UPDATE), commits a newer local edit, and asserts the result isconflictand the entry still readslocal edit. It fails against the old code — which readupdated_aton the pool before the txn, saw the pre-edit value, decided "no conflict", waited on the lock, and then overwrote the local edit — so it genuinely pins the regression. Two companion tests cover the ordinary apply and plain-stale-conflict paths.On the clock-skew sensitivity you noted: still inherent (the comparison is host
updated_atvs remotepublished), and unchanged by this fix. #102 widens the AP signature freshness window for related reasons; worth revisiting the lock's skew tolerance separately if federated editing gets real use.CI run 191 green (241/241).