Skip to content

feat(dashboard): rate-limit login to block password brute-force (#109)#110

Merged
mohamedboukari merged 1 commit into
mainfrom
feat/109-dashboard-login-rate-limit
Jun 7, 2026
Merged

feat(dashboard): rate-limit login to block password brute-force (#109)#110
mohamedboukari merged 1 commit into
mainfrom
feat/109-dashboard-login-rate-limit

Conversation

@mohamedboukari

Copy link
Copy Markdown
Owner

Summary

Closes #109. The dashboard login (POST /dashboard/login) had no brute-force protection — a single shared DASHBOARD_PASSWORD (no username) that unlocks every tenant's mail could be guessed at full request speed. The per-API-key limiter only covers /api/v1 and can't key on a login request.

This adds a per-IP, in-memory sliding-window throttle on failed logins: 5 failures / 15 minutes by default → HTTP 429 with Retry-After. A successful login clears the counter, and the locked-out response disables the password field + submit button so it's visually clear retrying won't help until the window passes.

Client-IP resolution (spoof-resistant)

Per MDN/OWASP guidance, the leftmost X-Forwarded-For entry is attacker-controlled and must never be used for rate limiting. The client IP is resolved by counting trusted proxy hops from the right:

  • DASHBOARD_TRUSTED_PROXY_HOPS=0 (default) → ignore the header, use the raw socket IP (spoof-proof; correct when directly exposed).
  • N ≥ 1 → take the N-th X-Forwarded-For entry from the right (the address your trusted proxy observed). Set to 1 behind a single nginx/Caddy/Cloudflare. Falls back to the socket IP if the header is missing/short.

Only valid if the origin is reachable solely through the proxy (the existing "never expose :3000" rule keeps X-Forwarded-For trustworthy).

Changes

  • New src/middleware/login-rate-limit.ts — config-free limiter (resolveClientIp, isLoginRateLimited, recordFailedLogin, clearLoginAttempts, prune + cleanup lifecycle), mirroring the SMTP per-IP and HTTP per-key limiter patterns.
  • src/config.tsdashboard.trustedProxyHops + dashboard.loginRateLimit (enabled/max/windowSec).
  • src/pages/pages.plugin.tsx — login POST resolves IP, returns 429 + Retry-After on lockout, records failures, clears on success; logs the IP per outcome.
  • src/pages/routes/login.tsxLoginPage gains a disabled prop (input + button disabled & dimmed) used only on the lockout render.
  • src/index.ts — cleanup sweep wired into startup/shutdown.
  • Teststest/unit/login-rate-limit.test.ts (IP resolution incl. leftmost-spoof, thresholds, window expiry, prune); e2e 429 + per-IP isolation + success-clears + disabled-form cases; render test for the disabled state.
  • DocsCHANGELOG (Unreleased), .env.example, docs/dashboard.md, docs/self-hosting.md, THREAT_MODEL.md (mitigated control + proxy/scaling notes), SECURITY.md, ARCHITECTURE.md.

New env: DASHBOARD_TRUSTED_PROXY_HOPS, DASHBOARD_LOGIN_RATE_LIMIT_{ENABLED,MAX,WINDOW}. State is in-memory and per-replica (same caveat as the API limiter).

Testing

  • bunx tsc --noEmit — clean
  • bun test test/unit test/e2e — 385 pass
  • bun run test:integration — 100 pass
  • lint clean; knip shows only pre-existing unused exports
  • Manual: 6th wrong attempt → 429 + Retry-After + disabled form; correct password clears the counter.

The dashboard login had no throttle: a single shared DASHBOARD_PASSWORD
(no username) that unlocks every tenant's mail could be guessed at full
request speed. The per-API-key limiter only covers /api/v1 and can't key
on a login request.

Add a per-IP, in-memory sliding-window limiter for POST /dashboard/login
(5 failures / 15 min by default -> HTTP 429 + Retry-After; a successful
login clears the counter). The locked-out response disables the password
field and submit button so it's clear retrying won't help until the
window passes. The client IP is resolved with a spoof-resistant,
count-from-the-right read of X-Forwarded-For gated by
DASHBOARD_TRUSTED_PROXY_HOPS (default 0 = raw socket IP) -- the leftmost
header entry is attacker-controlled and never trusted.

New env: DASHBOARD_TRUSTED_PROXY_HOPS, DASHBOARD_LOGIN_RATE_LIMIT_{ENABLED,MAX,WINDOW}.
State is in-memory and per-replica (same caveat as the API limiter).

Closes #109
@mohamedboukari mohamedboukari merged commit a04fcbb into main Jun 7, 2026
7 checks passed
@mohamedboukari mohamedboukari deleted the feat/109-dashboard-login-rate-limit branch June 7, 2026 11:32
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.

Dashboard login has no brute-force protection (no per-IP rate limit / lockout)

1 participant