Finding 6: the unthrottled password-reset
Document the unthrottled password-reset endpoint as finding 6, the sixth file in the audit report.
This defect is invisible from the surface: submit the reset form once and you get a 200, submit it twenty times and you get twenty 200s, never a 429.
You find it by reading the limiter declarations in rate-limit.ts against every api/auth/* handler.
One limiter, resetLimiter, sits fully configured at module scope, while the route that needs it sends mail on every POST without ever importing it.
The fingerprint is the absence of a 429: every line of the hammer loop’s output is a 200.
200200200200200... (twenty in total, never a 429)The deliverable is one Markdown file, findings/006-rate-limit-password-reset.md, with the four template sections filled in.
This is a read-only pass: you document the gap, you do not wire the limiter or touch the route, and the target runs unchanged when you are done.
Your mission
Section titled “Your mission”Audit the password-reset endpoint for rate-limit coverage and write finding 6 documenting the gap.
What matters here is how you find it: not by hammering the app until it breaks, but by reading the route list against the coverage matrix from chapter 81’s “The abusable-endpoint matrix”.
Walk the declared limiters in src/lib/rate-limit.ts, then open each api/auth/* handler and check whether it runs through the limiter seam.
The running app only confirms what the matrix already told you: twenty submits, no 429.
The coverage rule mandates a limiter when an endpoint hits any one of three triggers: it costs money per call, it can be used to attack a third party, or it touches state addressable without auth.
This endpoint hits two, the Resend send is money and the “email” the caller supplies is a victim’s inbox, and your finding has to record which two fire.
Coverage is the deliverable, so the finding carries a matrix with a row for every abusable endpoint; the gaps you are not scoring here, sign-in and sign-up, are declared-but-unwired too, and go in as open rows rather than being silently dropped.
The trap is reaching for a single per-IP limiter and calling it done: the inbox-bomb and the enumeration attack are both per-email problems, so per-IP alone misses a distributed sender rotating addresses against one victim and locks out a whole shared office NAT.
Document both the grep and the hammer, because they prove different things: the grep proves the gap lives in source and can be wired into CI later, the hammer proves it is live right now.
Patching the route is out of scope.
findings/006-rate-limit-password-reset.md has all four template sections — Rule, Location, Consequence, Fix — populated with real prose.src/lib/rate-limit.ts and src/app/api/auth/reset-password/route.ts), establishing that resetLimiter is declared but never imported by the route.429.resetLimiter plus a per-email companion limiter, both wrapped in safeLimit, returning a generic 429 with RateLimit-* headers — the dual-key detail confirmed by hand.Coding time
Section titled “Coding time”Write finding 6 against the template and the brief above, run the lesson-7 gate until it passes, then open the walkthrough to compare against the reference.
Reference solution and walkthrough
The finding goes in findings/006-rate-limit-password-reset.md.
The header gives the category and a two-line severity justification before the four sections.
# Finding 006 — Unthrottled password-reset endpoint sends mail on every call
**Category:** Rate-limit coverage (security baseline).**Severity:** high — the endpoint costs money on every request and weaponizes the product's verified domain against arbitrary inboxes, but it does not directly expose data or grant access, so it sits below the critical secret leak (finding 5) and above a config-only gap.State the rule, then the threshold, then which triggers fire here, and link the source lesson rather than re-deriving the triggers.
## Rule
Every abusable endpoint routes through one of the named limiters in `lib/rate-limit.ts`, and an endpoint is abusable — a limiter is mandatory — when it matches any one of three triggers: it costs money per call, it can be used to attack a third party, or it touches state addressable without auth (chapter 081, lesson 2 — the threshold is three triggers, any one; coverage is the deliverable, tracked as a matrix). The password-reset request endpoint matches **two** of the three, so a limiter is not a nice-to-have here, it is required:
1. **Costs money per call.** Each request fires a Resend transactional send (lesson 2, trigger (a) — transactional email is the canonical money-per-call example).2. **Attacks a third party.** The "email" the caller supplies is someone else's inbox; an attacker drives the send at a victim's address (lesson 2, trigger (b) — the victim's inbox is the third party).
(It is arguably trigger (c) as well — the route is reachable without a session — but the plan scores this finding against the two load-bearing triggers above; either one alone already makes the limiter mandatory.)Location
Section titled “Location”The defect here is not a line in one file but the gap between two: a declared limiter and a route that never reaches it, so the Location names both files and the commands that surfaced the gap.
## Location
The defect is the *gap between* a declared limiter and the route that should use it, so the location is two files read side by side:
- `src/lib/rate-limit.ts`, lines 23–29: `resetLimiter` is declared at module scope — `Ratelimit.slidingWindow(3, '15 m')`, prefix `rl:reset`. The limiter exists, fully configured, exactly where the lesson says limiters live.- `src/app/api/auth/reset-password/route.ts`, the `POST` handler at lines 20–44: it parses the email (lines 21–25) and calls `sendEmail(...)` at lines 31–39 with **no limiter in front of it**. Nothing in this file imports `resetLimiter` or `safeLimit`; the declared budget in `rate-limit.ts` is never reached from the one route that needs it.The declared limiter, at module scope and imported by nothing under src/app:
export const resetLimiter = new Ratelimit({ redis, limiter: Ratelimit.slidingWindow(3, '15 m'), prefix: 'rl:reset', analytics: true, ephemeralCache: new Map(),});The route that needs it, firing the send on every POST with no limiter import and no gate:
export const POST = async (request: Request): Promise<Response> => { const body = await request.json().catch(() => null); const parsed = z.object({ email: z.email() }).safeParse(body); if (!parsed.success) { return Response.json({ error: 'Invalid email.' }, { status: 400 }); }
const resetUrl = `${env.NEXT_PUBLIC_APP_URL}/reset-password?token=stub`;
// SEEDED #6: fires the Resend send with no rate-limit gate in front of it. The // declared reset limiter in lib/rate-limit.ts is never reached from this route. await sendEmail({ to: parsed.data.email, subject: 'Reset your password', react: createElement(WelcomeVerification, { firstName: 'there', verifyUrl: resetUrl, }), idempotencyKey: `reset:${parsed.data.email}:${Date.now()}`, });
// Opaque success regardless of whether the address exists (enumeration-safe on the // response, but unthrottled — the defect is the missing gate, not the message). return Response.json({ ok: true });};Now the discovery, recorded for the report:
How it surfaced — the grep discovery, then the by-hand confirmation. Both belong in the report, for different reasons: the grep proves the gap is in the source (auditable, repeatable, CI-able later), the hammer proves the gap is *live* (the running target really sends without a ceiling).# 1. What limiters are declared, and what actually imports each one?rg -n 'new Ratelimit' src/lib/rate-limit.ts # -> signIn, signUp, reset declaredrg -rn 'resetLimiter|safeLimit' src/app # -> resetLimiter: zero hits in app/# 2. Which handlers send mail or otherwise burn money/attack a third party?rg -rn 'sendEmail|forgetPassword' src/app # -> reset-password/route.ts hits, ungatedGrep 1 is decisive: resetLimiter is declared in rate-limit.ts and has zero references under src/app, so from the route’s side it is dead code.
Grep 2 finds the ungated sendEmail call, and reading the route confirms no safeLimit import, no limiter call, no 429 path.
Then the hammer:
for i in $(seq 1 20); do curl -s -o /dev/null -w "%{http_code}\n" -X POST localhost:3000/api/auth/reset-password -H 'content-type: application/json' -d '{"email":"victim@example.com"}'; doneEvery request returns 200 {"ok":true}: no 429, no RateLimit-* header, no slow-down, and the seeded Resend path would attempt a send on each one.
The opaque body is enumeration-safe by response, refusing to confirm whether the address exists, but that correct response shape is what hides the real problem, because the endpoint is unthrottled by behavior.
The missing 429 across twenty identical submits is the fingerprint.
One last thing the Location records, so the matrix decisions are explicit:
Recorded as legitimate, not findings: `signInLimiter` and `signUpLimiter` are *also* declared in `rate-limit.ts` and unwired in this target, but the sign-in/sign-up actions route through Better Auth's own `signInEmail`/`signUpEmail` and sign-up is enumeration-closed at the source (`autoSignIn:false`); those are tracked as open rows in the coverage matrix below (gaps are tickets), not folded into this finding, which scopes to the one money-and-third-party endpoint.signInLimiter and signUpLimiter are declared-but-unwired too, but they route through Better Auth’s own send paths and belong in the matrix as open rows, not inside a finding scoped to the one money-and-third-party endpoint.
Consequence
Section titled “Consequence”Frame the harm in user and third-party terms: what an attacker does with the endpoint, and who pays for it.
## Consequence
An attacker scripts the endpoint and the product mails on command. Pointed at one address, it is an inbox-bomb: a victim's inbox fills with reset emails from the company's verified domain, drowning real mail and training the recipient (and their provider) to treat the domain as spam — so the domain's deliverability degrades for *every* customer, and the transactional mail the product depends on starts landing in junk. Pointed at a list of guessed addresses, it is account enumeration plus a spam relay: the company's own sending reputation is spent blasting unsolicited mail, and the Resend bill climbs one paid send at a time with no ceiling. There is no throttle and no cost cap between an attacker and the product's mail reputation; the only thing limiting the damage is that nobody has aimed a loop at the endpoint yet.The fix is the dual-keyed safeLimit wrapper, and the finding has to explain why per-IP alone is not enough before it names the parts.
## Fix
The senior reach is the dual-keyed `safeLimit` wrapper this lineage already ships the parts for — not per-IP alone. Per-IP-only lets a distributed sender rotate addresses to keep hammering one victim, and it locks out a shared office NAT; the inbox-bomb and enumeration vectors are *per-email* problems, so the email must be a key too. Both gates must pass.
1. **Add a per-email companion limiter** beside the existing per-IP `resetLimiter` in `src/lib/rate-limit.ts` (e.g. `resetEmailLimiter`, prefix `rl:reset:email`, a tight window), so the route can check per-IP and per-email independently. The existing `resetLimiter` becomes the per-IP gate.2. **Wrap both checks in `safeLimit`** (the fail-open seam from `src/lib/safe-limit.ts`) so a Redis outage logs `rate_limit_unavailable` and lets the reset path stay up rather than 500ing the password-reset flow — the one place the fail-open policy lives (chapter 081, lesson 2, the `safeLimit` seam).3. **On reject, return a generic 429 with `RateLimit-*` headers** via the route-handler helpers already present — `rateLimitedResponse(result)` (which sets `RateLimit-Limit/Remaining/Reset` + `Retry-After` and an opaque body) from `src/lib/rate-limit-headers.ts`. This is the route-handler path, so headers are available; on the Server-Action twin the budget rides the `Result` instead (the 075 decision: `headers()` is read-only in a Server Action, so actions carry the budget on the `Result` via `rateLimitBudget`/`rateLimited`, not as HTTP headers). Either way the rejection body is the same opaque "Too many attempts" message — no leak of which gate tripped.The third move splits on surface: a route handler can return RateLimit-* headers via rateLimitedResponse, but its Server-Action twin must carry the budget on the Result instead, because headers() is read-only inside a Server Action.
Either way the rejection body is the same opaque “Too many attempts”, so the response never leaks which gate tripped.
The fix is illustrative, not a diff:
// reset-password/route.ts — both gates, fail-open, opaque 429.const ip = ipFrom(request);const byIp = await safeLimit(resetLimiter, 'rl:reset', ip);const byEmail = await safeLimit(resetEmailLimiter, 'rl:reset:email', parsed.data.email);if (!byIp.success || !byEmail.success) { return rateLimitedResponse(byIp.success ? byEmail : byIp);}// only now: sendEmail(...)Coverage matrix
Section titled “Coverage matrix”Coverage is the deliverable, so the finding closes with a row for every abusable endpoint, not just this one.
Coverage matrix (the lesson-2 deliverable — every abusable endpoint, one row)
Section titled “Coverage matrix (the lesson-2 deliverable — every abusable endpoint, one row)”| Endpoint category | File | Limiter (prefix) | Key strategy | Covered |
|---|---|---|---|---|
| Auth — sign-in | src/app/(auth)/sign-in/actions.ts | signInLimiter (rl:signin) declared | per-IP + per-email (dual) | N — declared, unwired (ticket) |
| Auth — sign-up | src/app/(auth)/sign-up/actions.ts | signUpLimiter (rl:signup) declared | per-IP + per-email (dual) | N — declared, unwired (ticket) |
| Email-sending — password reset | src/app/api/auth/reset-password/route.ts | resetLimiter (rl:reset) declared, + per-email | per-IP and per-email (dual) | N — this finding |
| Webhook fan-out — Stripe | src/app/api/webhooks/stripe/route.ts | signature verify (lesson 1) gates receiver | per-tenant on fan-out | N/A receiver verified; fan-out per-tenant is the open row |
| Worker — export trigger | src/app/api/exports/trigger/route.ts | bare .limit() — bypasses safeLimit | (see bonus finding 10) | N — bypass, not a coverage gap (different rule) |
The matrix records each gap as a ticket instead of a silent decision: the sign-in and sign-up rows are the open tickets the next pass closes.
The export-trigger row is a bare .limit() that bypasses safeLimit, so it is its own finding (bonus 10, a seam violation) rather than a missing limiter.
Wiring the matrix’s discovery greps into CI is the follow-up (chapter 097), not the fix here.
The exact limiter this finding's Fix wires — slidingWindow config and per-identifier keys.
Per-account rate limiting and enumeration defenses — grounds your Consequence and Fix.
The route-handler + Upstash pattern the dual-keyed reset gate builds on.
Moment of truth
Section titled “Moment of truth”Run the gate for this lesson:
pnpm test:lesson 7The gate reads your committed findings/006-rate-limit-password-reset.md and asserts the shape of the deliverable: four populated sections, the rate-limit-coverage rule and its triggers, the Location, the Fix, and the coverage matrix.
It then probes that the seeded defect is still present, so a pass proves you documented the gap rather than patched it.
Expect green:
✓ tests/lessons/Lesson 7.test.ts (7 tests) ✓ Lesson 7 — Finding 006 — the four template sections are populated ✓ Lesson 7 — Finding 006 — the rate-limit-coverage rule is named ✓ Lesson 7 — Finding 006 — the location is the gap between two files ✓ Lesson 7 — Finding 006 — the fix names the senior reach ✓ Lesson 7 — Finding 006 — a coverage matrix is attached ✓ Lesson 7 — Finding 006 — the audit stayed read-only (defect still present) ✓ the reset-password route still fires sendEmail with no limiter in front of it ✓ resetLimiter is still declared in rate-limit.ts but imported by nothing under src/app
Test Files 1 passed (1) Tests 7 passed (7)The gate checks the finding’s shape, not its judgment. The rest only you can confirm by hand, so tick each off:
429 across twenty repeated submits.