Skip to content

fix(auth): write the verification code once, and check the resend limiter in the same critical section - #212

Merged
argszero merged 1 commit into
mainfrom
fix/verify-code-critical-section
Sep 13, 2026
Merged

argszero merged 1 commit into
mainfrom
fix/verify-code-critical-section

Conversation

@argszero

Copy link
Copy Markdown
Owner

Summary

POST /api/auth/register, /api/auth/resend-code and /api/auth/forgot-password wrote the email_verifications row in two steps, in two different lock scopes:

dao::store_verification_code(&conn, email, &hash, &expires_at)   // lock 1: expires_at = "+10 minutes"
...
conn.execute("UPDATE email_verifications SET expires_at = datetime('now', '+10 minutes') WHERE email = ?1")  // lock 2

store_verification_code bound expires_at as a parameter (?3), so lock 1 stored the literal string '+10 minutes'; only lock 2 made the row meaningful. The reader (find_valid_verification) matches expires_at > datetime('now'), and SQLite agrees with the old writer's value only in the second form — sqlite3 ":memory:" "SELECT ('+10 minutes' > datetime('now')), (datetime('now','+10 minutes') > datetime('now'));" returns 0|1. Between the two writes, therefore, the row exists but is already "expired" (a user who verifies inside that window gets 验证码不存在或已过期,请重新获取), and if the second write ever fails the code can never be verified while a resend hits the 60 s limiter. The invariant this PR encodes: the value the writer stores must be a value the reader's predicate can interpret, and no intermediate state may exist between two writes of the same row.

The same two-step shape disabled the 60 s resend limiter. The existence gate, the limiter check (dao::resend_too_soon) and the write each took the lock separately, with no lock and no .await spanning the check and the write, so on the multi-threaded runtime (#[tokio::main], default flavor) a burst of concurrent resends all passed the check and each one mailed its own verification code to the same address — an unauthenticated way to send N mails per burst. A scan of every top-level function shows these three handlers are the only functions that take the shared Arc<Mutex<Connection>> more than once (send_code 3, resend_code 2, forgot_password 2).

Related Issue

Changes

  • src/dao.rs::store_verification_code no longer takes expires_at; the statement computes it (datetime('now', '+10 minutes')), so the stored value is by construction one the reader's predicate can interpret. The second UPDATE disappears.
  • src/dao.rs::begin_resend_verification(conn, email, code_hash) -> Result<bool> is new: it checks resend_too_soon and writes inside one critical section; false means rate-limited and nothing was written (INSERT OR REPLACE resets created_at, so a successful write is the start of the next window).
  • src/routes/mod.rs::send_code now only mails (returning whether dev mode is on); the code and its hash come from new_verification_code(). Callers write inside their own lock scope: register alongside create_unverified_user (no "user created, code missing" gap), resend_code / forgot_password via begin_resend_verification, both answering 429 on false.
  • The anti-enumeration gate and its uniform ok response are untouched (C2034/C2035 behaviour byte-identical: an unknown address still gets ok, no record, no mail), as is the SMTP-failure path (502 + record cleared so an immediate resend is possible).
  • No schema change, no config change, no ui/ change, no new i18n keys. The 10-minute TTL and the 60 s window are unchanged.

Tests

  • cargo test — 211 passed (209 before, +2 new).

  • cargo fmt --check clean; cargo clippy --all-targets -- -D warnings clean.

  • New tests added.

  • verification_code_is_usable_exactly_as_written — after a single store_verification_code, find_valid_verification must already return the row (no second write is needed for the code to be readable), the hash round-trips, and the stored expires_at must not be the literal '+10 minutes'.

  • resend_limiter_and_write_share_one_critical_section — begin_resend_verification returns true then false on two consecutive calls, and the rate-limited call must not have replaced the stored code (otherwise concurrent resends would keep swapping codes under each other).

A/B teeth proof (each mutation applied alone, restored byte-identically afterwards, snapshot taken after cargo fmt):

  • M1 — the pre-fix writer (bind "+10 minutes" as a parameter, no second write): passed=205, and the red set contains the new writer/reader test plus every end-to-end flow that has to verify a code (register_verify_login_full_flow, login_normalizes_email_case, verify_wrong_code_expires_and_attempt_limit, forgot_reset_password_full_flow), i.e. the failure is exactly "the row is written but is not readable".
  • M2 — the pre-fix non-atomic limiter (write without checking): passed=208, red = resend_limiter_and_write_share_one_critical_section, resend_code_rate_limited, forgot_reset_password_full_flow — and the new writer/reader test stays green, so the two new tests measure two different properties.

Runtime evidence for the limiter (probe, not committed)

A temporary probe (test module only, reverted byte-identically) fired 32 concurrent POST /api/auth/resend-code requests at the real router with an existing, verified account whose created_at was backdated past the 60 s window, and counted the 200s — each 200 is one verification code mailed to the same address:

tree window result
pre-fix as shipped, no instrumentation at all ok=2, ok=4, ok=4 over three runs (one row in the table)
pre-fix the same gap widened to 300 ms ok=32 limited=0
post-fix — (no injection needed) ok=1 limited=31 on three consecutive runs

The 300 ms arm injects only a delay into the gap that already exists (the two lock scopes with nothing held between them); it creates no new window — the un-instrumented arm already reproduces the defect. After the fix the outcome is decided by the mutex rather than by timing, so no timing-dependent test is committed.

Checklist

  • Branch name follows the convention (fix/verify-code-critical-section)
  • Commit message uses Conventional Commits (fix(auth): ...)
  • Single responsibility, minimal change

…iter in the same critical section

`register` / `resend-code` / `forgot-password` wrote the `email_verifications`
row in two steps: `store_verification_code` bound the literal string
`"+10 minutes"` as the `expires_at` **parameter** (so that is what the column
held), and a second statement in a second lock scope rewrote it to
`datetime('now','+10 minutes')`. The reader (`find_valid_verification`) matches
`expires_at > datetime('now')`, so between the two writes the row exists but is
already "expired" — and if the second write fails the code can never be
verified, while a resend hits the 60 s limiter.

The limiter was checked in a different critical section than the write, with no
lock and no await spanning them, so concurrent resends all passed the check and
each mailed its own code to the same address (measured through the real router:
32 concurrent requests -> 2-4 codes with no instrumentation at all; 32/32 once
the existing lock-free gap is widened from microseconds to 300 ms).

- `dao::store_verification_code` takes no `expires_at`: the SQL owns the value,
  so writer and reader share one idiom.
- `dao::begin_resend_verification` checks the limiter and writes the new code
  inside one critical section (`false` = rate-limited, nothing written).
- `send_code` only mails; the caller writes inside its own lock scope, so the
  mail call (which retries) is never made while holding the lock.
@argszero
argszero merged commit ce68a9f into main Sep 13, 2026
1 check passed
@argszero
argszero deleted the fix/verify-code-critical-section branch September 13, 2026 14:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant