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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user