fix: throttle password reset email requests #19

Merged
graham merged 2 commits from fix/w2-reset-throttle into main 2026-09-15 11:12:39 -05:00
Owner

Fixes W2 from bugs.md.

Adds Accounts.request_password_reset/3 with a two-bucket fixed-window throttle (per-source and per-address, hashed email keys), consulted before any database lookup. The LiveView resolves the client IP via RateLimiter.extract_socket_ip/1 at mount and renders one fixed response for every address, closing the mail-flood and enumeration vectors on the WebSocket path. Reset token minting now replaces outstanding tokens instead of accumulating them, and mail delivery moved into an unlinked task.

Verification: focused tests passed (53), full suite passed (2237), mix credo --strict, and commit hooks including Dialyzer passed.

Fixes W2 from bugs.md. Adds `Accounts.request_password_reset/3` with a two-bucket fixed-window throttle (per-source and per-address, hashed email keys), consulted before any database lookup. The LiveView resolves the client IP via `RateLimiter.extract_socket_ip/1` at mount and renders one fixed response for every address, closing the mail-flood and enumeration vectors on the WebSocket path. Reset token minting now replaces outstanding tokens instead of accumulating them, and mail delivery moved into an unlinked task. Verification: focused tests passed (53), full suite passed (2237), `mix credo --strict`, and commit hooks including Dialyzer passed.
fix: throttle password reset email requests
All checks were successful
Elixir CI / Build and test (pull_request) Successful in 56s
Elixir CI / Dialyzer (pull_request) Successful in 2m57s
Elixir CI / Build and Push Docker Image (pull_request) Has been skipped
skippy-bot/review Skippy review: clean — 2 non-blocking notes open
53c16b149c
skippy-bot left a comment

🤖 Skippy PR review

2 findings — none blocking.

Severity Location Issue
🔵 Suggestion lib/aprsme/accounts/password_reset_throttle.ex:53 Per-address bucket is never exercised from a second source
🔵 Suggestion lib/aprsme/accounts.ex:570 A failed reset mail is now completely unobservable

Reviewed 53c16b149c5c. Comment skippy review to re-run.

### 🤖 Skippy PR review **2 findings** — none blocking. | Severity | Location | Issue | | --- | --- | --- | | 🔵 Suggestion | `lib/aprsme/accounts/password_reset_throttle.ex:53` | Per-address bucket is never exercised from a second source | | 🔵 Suggestion | `lib/aprsme/accounts.ex:570` | A failed reset mail is now completely unobservable | <sub>Reviewed `53c16b149c5c`. Comment `skippy review` to re-run.</sub> <!-- skippy-pr-review -->
@ -533,0 +567,4 @@
case get_user_by_email(email) do
%User{} = user ->
url = mint_reset_password_url(user, reset_password_url_fun)
_task = Task.start(fn -> UserNotifier.deliver_reset_password_instructions(user, url) end)
First-time contributor

🔵 Suggestion — A failed reset mail is now completely unobservable

UserNotifier.deliver_reset_password_instructions/2 returns {:error, reason} when Mailer.deliver/1 fails, and the unlinked task drops it: no flash (correct — the old error flash only fired for registered addresses, so it was itself the enumeration oracle) and now no log either. During a mail-provider outage every reset request looks successful to the user and leaves no trace for ops. Log the failure inside the task — this module has no require Logger yet — keyed on the user id or the address hash rather than the raw address, to keep the throttle's no-PII-in-logs stance. The confirmation path has the same shape (line 449), so ignore if that silence was a deliberate call there too.

**🔵 Suggestion — A failed reset mail is now completely unobservable** `UserNotifier.deliver_reset_password_instructions/2` returns `{:error, reason}` when `Mailer.deliver/1` fails, and the unlinked task drops it: no flash (correct — the old error flash only fired for registered addresses, so it was itself the enumeration oracle) and now no log either. During a mail-provider outage every reset request looks successful to the user and leaves no trace for ops. Log the failure inside the task — this module has no `require Logger` yet — keyed on the user id or the address hash rather than the raw address, to keep the throttle's no-PII-in-logs stance. The confirmation path has the same shape (line 449), so ignore if that silence was a deliberate call there too.
skippy-bot marked this conversation as resolved
@ -0,0 +50,4 @@
end
defp check_address(hash) do
case RateLimiter.hit(email_key(hash), @window, @email_limit) do
First-time contributor

🔵 Suggestion — Per-address bucket is never exercised from a second source

The address bucket is only ever hit from the one source the added LiveView test drives, so nothing in the suite fails if the address key later becomes source-scoped (e.g. "pwreset:email:#{ip}:#{hash}") — a realistic change, since scoping it per source would remove the "any client can silence a victim's reset mail" trade-off. That change silently restores the mail-flood vector this PR closes and every added test still goes green. resend_confirmation_instructions/3 has dedicated coverage for exactly this in test/aprsme/accounts_test.exs ("the cooldown follows the address across sources", "the per-source budget refuses a client spraying many addresses") using the private unique_ip/0 helper. Port both for request_password_reset/3: after 1..3 requests for user.email from one unique_ip(), a 4th from a fresh unique_ip() must return :ok and leave the minted token unchanged.

**🔵 Suggestion — Per-address bucket is never exercised from a second source** The address bucket is only ever hit from the one source the added LiveView test drives, so nothing in the suite fails if the address key later becomes source-scoped (e.g. `"pwreset:email:#{ip}:#{hash}"`) — a realistic change, since scoping it per source would remove the "any client can silence a victim's reset mail" trade-off. That change silently restores the mail-flood vector this PR closes and every added test still goes green. `resend_confirmation_instructions/3` has dedicated coverage for exactly this in `test/aprsme/accounts_test.exs` ("the cooldown follows the address across sources", "the per-source budget refuses a client spraying many addresses") using the private `unique_ip/0` helper. Port both for `request_password_reset/3`: after `1..3` requests for `user.email` from one `unique_ip()`, a 4th from a *fresh* `unique_ip()` must return `:ok` and leave the minted token unchanged.
skippy-bot marked this conversation as resolved
fix: cover reset throttle across sources, log failed reset mail
All checks were successful
Elixir CI / Dialyzer (pull_request) Successful in 41s
skippy-bot/review Skippy review: clean — no open findings
Elixir CI / Build and test (pull_request) Successful in 1m42s
Elixir CI / Build and Push Docker Image (pull_request) Has been skipped
359151b1e4
First-time contributor

Resolved 2 of 2 earlier findings:

  • PasswordResetThrottle per-address bucket is now exercised from a second source: the address bucket follows the address across sources fails if the address key ever becomes source-scoped (it would mint a 4th token).
  • deliver_reset_instructions/2 logs {:error, reason} from the unlinked task, keyed on user.id, so a provider outage is no longer invisible.

0 still open. Nothing new in 53c16b1..359151b (comment reflow plus those two changes only).

Reviewed 359151b1e450.

Resolved 2 of 2 earlier findings: - `PasswordResetThrottle` per-address bucket is now exercised from a second source: `the address bucket follows the address across sources` fails if the address key ever becomes source-scoped (it would mint a 4th token). - `deliver_reset_instructions/2` logs `{:error, reason}` from the unlinked task, keyed on `user.id`, so a provider outage is no longer invisible. 0 still open. Nothing new in `53c16b1..359151b` (comment reflow plus those two changes only). <sub>Reviewed `359151b1e450`.</sub> <!-- skippy-pr-review -->
graham merged commit 220cea6689 into main 2026-09-15 11:12:39 -05:00
graham deleted branch fix/w2-reset-throttle 2026-09-15 11:12:39 -05:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
graham/aprs.me!19
No description provided.