Skip to content

fix(auth): do not send a code to an unregistered address - #186

Merged
argszero merged 1 commit into
mainfrom
fix/resend-code-unknown-address
Sep 12, 2026
Merged

argszero merged 1 commit into
mainfrom
fix/resend-code-unknown-address

Conversation

@argszero

Copy link
Copy Markdown
Owner

Summary

POST /api/auth/resend-code had no recipient-existence check, unlike its sibling POST /api/auth/forgot-password. Of the three send_code call sites in this module:

call site guards the recipient?
register n/a — it creates the users row first (legitimate)
forgot_password yesif !exists { return Ok(..) }, commented 「防枚举:统一返回 ok(不发送)」
resend_code no — it ran the rate limiter and then sent a code to any address

Two consequences:

  1. It silently violated the module's own documented anti-enumeration invariant — the rule exists in the module's prose and in a sibling's code, but not in this function.
  2. It was an unauthenticated outbound-mail primitive: any valid third-party address received a verification-code email every 60 s. The limit is per-address only (no per-IP limit), and send_with_retry attempts up to 3 SMTP sends per call. Bounded (60 s per address, 10-minute code lifetime), but a real abuse of the mail relay and a real inconsistency in the verification flow.

Reproduced on a live instance (SMTP mode): resend-code with a non-existent address → call 1 200, call 2 429. The 429 is the proof: it only appears when resend_too_soon is true, which requires a verification row created by the handler's own send_code — i.e. the first call did proceed to send. Control: forgot-password with the same non-existent address → 200, 200 (its guard short-circuits before the rate limiter).

Related Issue

None — this defect was found by a self-directed sweep of the email-verification / mail-sending surface, and no issue exists for it. No issue is fabricated and none is closed.

Changes

  • src/routes/mod.rs, resend_code: added the same existence gate its sibling has, before send_code — look up dao::find_user_by_email and, when absent, return the existing anti-enumeration response {status: "ok", email} with no send. No new 404/400 branch: that would create the very oracle the module avoids. The response is now identical whether or not the address exists, preserving the documented behavior.
  • Gate on existence, not on verified == 0: the resend button is the "didn't get the code" path for a user who has a users row but is not yet verified, so an existence gate keeps the legitimate flow working while closing the send-to-anyone hole (this matches forgot_password, which also serves unverified accounts).
  • No change to the 60 s rate-limit semantics for real resends.
  • No config/schema change; config/config.example.toml untouched (not applicable).

Tests

  • cargo test169 passed / 0 failed (baseline 168 + 1 new).
  • cargo fmt --check — clean.
  • cargo clippy --all-targets -- -D warnings — clean.
  • New test resend_code_unknown_email_not_sent, with a positive control: an unregistered address returns 200 without dev_code (and is not rate-limited, since no row is created); then a real registered address, rate-limit record cleared, still returns 200 with a dev_code — so the test cannot pass by simply always returning ok.
  • A/B proof: reverting only the handler guard makes the new test fail exactly at the property assertion (未注册邮箱不应发码), with the leaked dev_code visible in the failure output; both existing resend tests (resend_code_rate_limited, register_smtp_failure_502) stay green in both legs.

Checklist

  • Branch naming follows the convention (fix/)
  • Commit message uses Conventional Commits (fix(auth): …)
  • Single responsibility, minimal change (one existence check + one early return + one test)
  • No new i18n keys, no new gate

Not in scope: per-IP rate limiting for the mail endpoints — a separate, larger change that would need a store we do not have.

@argszero
argszero merged commit f16fe47 into main Sep 12, 2026
1 check passed
@argszero
argszero deleted the fix/resend-code-unknown-address branch September 12, 2026 12:00
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