revert(auth): the newest registration does not own an unverified account (E9.5 review round 2)

Round 1 fixed the address pre-emption chain by letting a later registration replace the password on
an unverified account and delete its outstanding verify tokens. Round 2 read that code and showed it
was worse than what it fixed, so it goes back out.

It closes nothing. The rule is symmetric and an attacker can simply register last — on a timer,
inside the five-per-five-minutes budget — while the owner registers once. What it does add is two
primitives an unauthenticated caller did not have: overwrite a real registrant's chosen password,
and delete the confirmation link already sitting in their inbox (the approval mail's 7-day token is
an ordinary verify token, so the blanket delete took that too). Worst of all it fired on the rows
SEC-10 left stripped — password_hash NULL, password_revoked_at set, active — installing an
attacker's password where the entire state means "there is none", and clearing the stamp that is the
only reason password-reset still accepts them. That is a straight takeover which did not exist
before, in the code written to prevent one.

The owner's recourse needed no new primitive and already worked: password-reset accepts a
pre-empted account, proves the mailbox, rewrites the hash and bumps the session epoch. What remains
is the bounded window between a confirmation click and that reset, which is SEC-16 — logged with
this attempt written down, so the next person does not reach for it again.

Two smaller round-2 findings go with it. `upsert_pending_invite`'s new exact-spelling tie-break ran
before any status preference, so it could deterministically pick a DENIED sibling over a pending one
and reopen it — the very outcome its comment claimed to prevent; the status preference now comes
first. And `send_invitation` activated the account before the send it might have to report as
failed, so a request the UI called a failure had quietly let someone in; activation now follows a
mail that actually left. The verify token minted to render that mail still commits either way — it
is single-use, expires on its own and was mailed nowhere, and the note says so rather than leaving
the next reader to wonder whether it was missed.
This commit is contained in:
2026-09-04 22:54:52 +02:00
parent 563fa0513d
commit 540ca244c8
3 changed files with 117 additions and 89 deletions
+71 -47
View File
@@ -87,68 +87,92 @@ def test_a_weeks_link_is_described_in_days_not_hours(wire):
assert "24 óra" in wire[1][2]
# --- the pre-emption guard: while unverified, the newest registration owns the account -------------
# --- what a second registration may and may not do -------------------------------------------------
def test_registering_again_takes_the_account_back_from_a_pre_emptor(db, client, smtp_on, wire):
"""The hole E9.5 S1's argument left open, and the guard that replaces it.
def test_a_later_registration_never_touches_an_existing_password(db, client, smtp_on, wire):
"""The rule E9.5's review round 2 put back, having tried the opposite and found it worse.
S1 stopped admin approval revoking a preset password, reasoning that `password_login` refuses an
unverified account before it reads `is_active`. True — but S6's approval mail exists to get the
real owner to confirm the address, and that click opens the gate for whatever password is on the
row. So the owner must be able to take the row back, and re-registering is how.
"While the address is unverified, the newest registration owns it" looked like a fix for address
pre-emption. It is symmetric — an attacker can always be last — so it closed nothing, and it
handed an unauthenticated caller the power to overwrite a real registrant's chosen password and
delete the confirmation link already sitting in their inbox. On a SEC-10-stripped row it would
have installed a password where the whole point is that there is none. Reverted; the owner's
recourse is password-reset, which proves the mailbox. Residual logged as SEC-16.
"""
client.post("/auth/register", json={"email": "victim@example.com", "password": "pre-emptor pw"})
db.expire_all()
user = db.execute(select(User).where(User.email == "victim@example.com")).scalar_one()
squatted = user.password_hash
assert squatted is not None
client.post(
"/auth/register", json={"email": "victim@example.com", "password": "the owner's pw"}
"/auth/register", json={"email": "victim@example.com", "password": "the first password"}
)
db.expire_all()
user = db.execute(select(User).where(User.email == "victim@example.com")).scalar_one()
assert user.password_hash != squatted
# One account, not two — the canonical pre-check still holds.
assert (
len(db.execute(select(User).where(User.email_canonical == "victim@example.com")).all()) == 1
)
def test_the_pre_emptors_verify_link_dies_with_their_password(db, client, smtp_on, wire):
client.post("/auth/register", json={"email": "victim@example.com", "password": "pre-emptor pw"})
db.expire_all()
user = db.execute(select(User).where(User.email == "victim@example.com")).scalar_one()
first = db.execute(select(AuthToken.id).where(AuthToken.user_id == user.id)).scalars().all()
assert len(first) == 1
client.post(
"/auth/register", json={"email": "victim@example.com", "password": "the owner's pw"}
)
db.expire_all()
live = db.execute(select(AuthToken).where(AuthToken.user_id == user.id)).scalars().all()
# Exactly one token, and not the one already in the pre-emptor's hands: otherwise they could
# confirm the address themselves and re-open the gate for a password that is no longer theirs.
assert len(live) == 1 and live[0].id != first[0]
def test_a_confirmed_account_is_not_re_opened_by_a_later_registration(
db, client, smtp_on, make_user
):
# The guard is scoped to the UNVERIFIED window. Once the address is confirmed the account is its
# owner's, and a stranger posting the form must not touch the password.
user = make_user(email="done@example.com", password=_PW) # already verified
before = user.password_hash
client.post("/auth/register", json={"email": "done@example.com", "password": "not yours"})
client.post(
"/auth/register", json={"email": "victim@example.com", "password": "the second password"}
)
db.expire_all()
assert db.get(User, user.id).password_hash == before
def test_a_later_registration_leaves_a_link_already_in_flight_alive(db, client, smtp_on, wire):
client.post(
"/auth/register", json={"email": "victim@example.com", "password": "the first password"}
)
db.expire_all()
user = db.execute(select(User).where(User.email == "victim@example.com")).scalar_one()
first = db.execute(select(AuthToken.id).where(AuthToken.user_id == user.id)).scalars().all()
client.post(
"/auth/register", json={"email": "victim@example.com", "password": "the second password"}
)
db.expire_all()
live = db.execute(select(AuthToken.id).where(AuthToken.user_id == user.id)).scalars().all()
# The re-send adds a link; it must not invalidate the one the owner may already be holding.
assert set(first) <= set(live)
def test_a_stripped_account_keeps_its_recovery_stamp(db, client, smtp_on, make_user):
"""Prod carries accounts SEC-10 left with no password and a `password_revoked_at` stamp. That
stamp is the only reason password-reset still accepts them; an anonymous POST must not be able to
clear it, nor to put a password on a row whose whole state is "there is none"."""
stripped = make_user(
email="stripped@example.com",
password=None,
email_verified=False,
is_active=True,
password_revoked_at=datetime.now(timezone.utc),
)
client.post(
"/auth/register", json={"email": "stripped@example.com", "password": "an attacker password"}
)
db.expire_all()
row = db.get(User, stripped.id)
assert row.password_hash is None and row.password_revoked_at is not None
def test_a_denied_sibling_is_not_reopened_while_a_pending_row_exists(db, client):
# Canonical siblings exist on prod. Picking the denied one reopens it and re-notifies every
# admin, putting one person in the queue twice — and the exact-spelling tie-break made that
# deterministic rather than merely possible.
db.add_all(
[
Invite(email="pair.one@gmail.com", status="pending"),
Invite(email="pairone@gmail.com", status="denied"),
]
)
db.commit()
client.post("/auth/request-access", json={"email": "pairone@gmail.com"})
db.expire_all()
rows = {i.email: i.status for i in db.execute(select(Invite)).scalars().all()}
assert rows == {"pair.one@gmail.com": "pending", "pairone@gmail.com": "denied"}
# --- the env bootstrap answers the same question as the gate --------------------------------------