security(P01): close first-admin register race (P0) + fail-closed corrupt user store (P1)

STRIDE adversarial review of the P1 auth surface found:

P0 — Elevation of Privilege / Spoofing (register race, D-104
violation): routes.register called create_user, which re-checks only
EMAIL uniqueness under the store lock — not STORE EMPTINESS. The
atomic create_first_admin existed but was dead code. Two concurrent
register POSTs with distinct emails on a fresh deployment BOTH
succeeded as admin (verified by live probe: two 201s + two admin
roles). Any attacker racing a fresh deployment gains admin. Fix:
register now routes through create_first_admin, whose emptiness
check + insert are atomic under the store lock (the only await —
the argon2 to_thread hash — precedes the check, so it remains
single-worker race-free per the plan constraint).

P1 — Tampering/DoS (corrupt users.json silently reopens
registration): a corrupt users.json made UserStore._load swallow
the error and report empty(); register then succeeded on top of
existing accounts and the next persist overwrote every account
(auth-reset-by-corruption, verified by live probe). Fix: fail
closed — the corrupt file is moved aside (*.corrupt.<hex>) and
construction raises RuntimeError; an operator recovers instead of
an attacker registering. Session stores keep the fail-safe
direction (corrupt sessions.json = everyone logged out = no
access, not all access), now pinned by test.

New adversarial tests (181 -> 184 green):
- test_concurrent_register_yields_exactly_one_admin: 8-way
  concurrent register storm on a fresh store — exactly one 201,
  losers have no account (regression guard for this exact bug).
- test_corrupt_user_store_fails_closed + move-aside recovery.
- test_corrupt_session_store_fails_toward_no_access.

Other STRIDE findings (verified GREEN, no action): token_urlsafe(32)
+ sha256-at-rest, httpOnly SameSite=Lax no-Domain cookie, CSRF on
all non-GET including auth POSTs, uniform login 401s, argon2 fully
in to_thread, cross-owner 404 on every run endpoint, no
password_hash in any response, deactivation kills sessions, token
rotation at login, dev_bridge loopback-only, sweep never fails
startup. Login timing enumeration noted for P5 review.

---ci---
phase: 1
milestone: v0.3
status: execute
persona: security-engineer
task: adversarial-review
escalations: none
resolution: P0 fixed in-commit (create_first_admin race), P1 fixed
---/ci---
This commit is contained in:
coreci
2026-09-29 21:22:28 +00:00
parent dee34a28d4
commit 90221fbc49
4 changed files with 93 additions and 5 deletions
+5 -2
View File
@@ -57,14 +57,17 @@ def build_auth_router(
dependencies=[Depends(require_csrf_header)],
)
async def register(body: RegisterBody) -> UserPublic:
# Fast-path 403 for the common case; the authoritative
# emptiness re-check happens under the store lock inside
# create_first_admin (D-104) so a concurrent duplicate
# register cannot both succeed.
if not users.empty():
raise HTTPException(status_code=403, detail="registration closed")
try:
user = await users.create_user(
user = await users.create_first_admin(
email=body.email,
password=body.password,
display_name=body.display_name or body.email.split("@")[0],
role="admin", # first user is admin by construction (D-104)
)
except UserError as exc:
raise HTTPException(status_code=403, detail=str(exc)) from exc
+12 -2
View File
@@ -58,8 +58,18 @@ class UserStore:
return
try:
raw = json.loads(self._path.read_text(encoding="utf-8"))
except json.JSONDecodeError:
return
except json.JSONDecodeError as exc:
# FAIL CLOSED on a corrupt user store. Swallowing it would
# silently report an empty store, reopening registration on
# top of existing accounts — the next persist would then
# overwrite them (auth-reset-by-corruption). The file is
# renamed aside so an operator can recover it; startup is
# refused rather than silently degrading.
salvage = self._path.with_name(f"{self._path.name}.corrupt.{secrets.token_hex(4)}")
os.replace(self._path, salvage)
raise RuntimeError(
f"corrupt user store {self._path}; moved aside to {salvage}"
) from exc
for record in raw.get("users", []):
try:
user = User.model_validate(record)
+43
View File
@@ -122,6 +122,49 @@ async def test_register_requires_csrf_header(tmp_path: Path) -> None:
assert response.json()["detail"] == "missing request header"
async def test_concurrent_register_yields_exactly_one_admin(tmp_path: Path) -> None:
"""D-104 adversarial pin: a simultaneous register storm on a fresh
store must produce exactly one 201/admin — the emptiness check +
insert are atomic under the store lock (create_first_admin), so a
raced duplicate cannot both win. Regression guard for the
create_user-based route that let N concurrent registers through."""
racers = [(f"racer{i}@example.org", f"racing-password-{i:02d}") for i in range(8)]
async with await _client(tmp_path) as client:
responses = await asyncio.gather(
*(
client.post(
"/api/auth/register",
json={"email": email, "password": password},
headers=_HEADERS,
)
for email, password in racers
)
)
winners = [(r, racer) for r, racer in zip(responses, racers, strict=True) if r.status_code == 201]
losers = [r for r in responses if r.status_code == 403]
assert len(winners) == 1, "exactly one register may win the race"
assert len(losers) == 7
assert winners[0][0].json()["role"] == "admin"
# The winner can log in; every losing racer has no account.
_, (winner_email, winner_password) = winners[0]
login = await client.post(
"/api/auth/login",
json={"email": winner_email, "password": winner_password},
headers=_HEADERS,
)
assert login.status_code == 200
for email, password in racers:
if email == winner_email:
continue
denied = await client.post(
"/api/auth/login",
json={"email": email, "password": password},
headers=_HEADERS,
)
assert denied.status_code == 401, "losing racer must have no account"
# -------------------------------------------------------------------- login
+33 -1
View File
@@ -228,4 +228,36 @@ async def test_session_store_persists_across_reload(tmp_path: Path) -> None:
reloaded = SessionStore(path)
assert reloaded.get(token) is not None
reloaded.revoke(token)
assert SessionStore(path).get(token) is None # revocation persisted too
assert SessionStore(path).get(token) is None # revocation persisted too
# ------------------------------------------------------- corruption posture
def test_corrupt_user_store_fails_closed(tmp_path: Path) -> None:
"""A corrupt users.json must NOT silently become an empty store:
swallowing it would reopen registration on top of existing
accounts and the next persist would overwrite them. The store
must refuse to start (file moved aside for operator recovery)."""
path = tmp_path / "users.json"
path.write_text("{not json", encoding="utf-8")
with pytest.raises(RuntimeError, match="corrupt user store"):
UserStore(path)
salvage = list(tmp_path.glob("users.json.corrupt.*"))
assert salvage, "the corrupt file must be moved aside, not deleted"
assert not path.exists()
def test_corrupt_session_store_fails_toward_no_access(tmp_path: Path) -> None:
"""A corrupt sessions.json degrades to 'everyone logged out' —
the fail-safe direction for sessions (no access, not all access).
Startup must still succeed; sessions are re-created on next login."""
path = tmp_path / "sessions.json"
path.write_text("{not json", encoding="utf-8")
sessions = SessionStore(path) # must not raise
token = sessions.generate_token()
sessions.create(token, "abcd2345")
assert SessionStore(path).get(token) is not None # recovers on rewrite