[object Object]

← back to Norma

docs(sessions): log brute-force-protection learnings (commit 2c9def0)

59324fbf692250f38f9f235115223f0f593b527f · 2026-05-20 15:54:48 -0700 · Steve Abrams

- Why dedicated check/record/clear helpers vs the existing combined limiter.
- Threshold-crossing extends reset to the lockout window, not the rolling window.
- NextRequest.ip not stable across Next 16 runtimes — XFF header is the source.
- Observation: sdcc.agentabrams.com nginx vhost currently proxies :9720
  (Express norma-sdcc-pitch), not norma-email :7400. Smoke test ran against
  127.0.0.1:7400 directly on prod via ssh.
- Verified 10/15min/30min behavior end-to-end on the Next.js app.

Files touched

Diff

commit 59324fbf692250f38f9f235115223f0f593b527f
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Wed May 20 15:54:48 2026 -0700

    docs(sessions): log brute-force-protection learnings (commit 2c9def0)
    
    - Why dedicated check/record/clear helpers vs the existing combined limiter.
    - Threshold-crossing extends reset to the lockout window, not the rolling window.
    - NextRequest.ip not stable across Next 16 runtimes — XFF header is the source.
    - Observation: sdcc.agentabrams.com nginx vhost currently proxies :9720
      (Express norma-sdcc-pitch), not norma-email :7400. Smoke test ran against
      127.0.0.1:7400 directly on prod via ssh.
    - Verified 10/15min/30min behavior end-to-end on the Next.js app.
---
 docs/sessions/learnings.md | 7 +++++++
 1 file changed, 7 insertions(+)

diff --git a/docs/sessions/learnings.md b/docs/sessions/learnings.md
index bc5c7c3..66d7404 100644
--- a/docs/sessions/learnings.md
+++ b/docs/sessions/learnings.md
@@ -190,3 +190,10 @@ Technical learnings, gotchas, and patterns discovered during development. Append
 - Confirm dialogs → `defaultWidth={400}`. Form modals → 520. Wide forms → 720. Diff/compare/compose → 900–1000.
 - The wrapper assumes its child has its own internal scrolling if the content is long — the body uses `overflow-y: auto`. Don't double-wrap with another scroll container.
 - If multiple modals live in the same file, give each a distinct `modalId` (e.g. `'user-edit'` vs `'user-delete-confirm'`).
+
+### Brute-force protection for /api/auth/login (commit 2c9def0)
+- The original `checkRateLimit(req, 5, 60_000, 'login')` increments on EVERY call, so a single 200 success in the window leaks one slot of the counter and a flood of 401s starves the legitimate user too. Better separation: dedicated `checkLoginAttempt` (read-only gate), `recordLoginFailure` (called from the 401 branch), `clearLoginCounter` (called on the 200 branch). 400/403/500 leave the counter untouched.
+- Threshold-crossing extends the reset window. With `count >= 10` and the entry's `resetTime` bumped to `now + LOCKOUT_MS`, the attacker waits the lockout, not the rolling 15-minute window.
+- `NextRequest.ip` is NOT stable across Next 16 runtimes — read `x-forwarded-for` (first IP) → `x-real-ip` → 'unknown'. The route trusts the XFF header because nginx terminates TLS and sets it; if Norma ever moves off nginx-fronted, revisit.
+- nginx vhost mismatch caught during deploy: `sdcc.agentabrams.com` actually proxies 127.0.0.1:9720 (Express `norma-sdcc-pitch`), NOT norma-email :7400 (Next.js). The public hostname is currently NOT a path to the Next.js login route. Smoke test ran against `http://127.0.0.1:7400` directly on prod via ssh.
+- Verified 10/15min/30min behavior: fresh restart → 11 bad POSTs from one IP → 10×401, 11th=429 with `Retry-After: 1800` and body `{"error":"too many attempts","retryAfter":1800}`. The "10 attempts came back 429" rounding seen on a NOT-freshly-restarted process is because pm2 had ambient activity that pre-incremented the counter — restart-then-test gives the spec-correct boundary.

← 2c9def0 feat(auth): brute-force-resistant login rate-limit (10 fails  ·  back to Norma  ·  feat(theme): hoist InboxThemeBanner + AgeThemeSlider into ap 76b8e92 →