feat(dashboard): rate-limit login to block password brute-force (#109)#110
Merged
Merged
Conversation
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Closes #109. The dashboard login (
POST /dashboard/login) had no brute-force protection — a single sharedDASHBOARD_PASSWORD(no username) that unlocks every tenant's mail could be guessed at full request speed. The per-API-key limiter only covers/api/v1and 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
429withRetry-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-Forentry 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 theN-thX-Forwarded-Forentry from the right (the address your trusted proxy observed). Set to1behind 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 keepsX-Forwarded-Fortrustworthy).Changes
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.ts—dashboard.trustedProxyHops+dashboard.loginRateLimit(enabled/max/windowSec).src/pages/pages.plugin.tsx— login POST resolves IP, returns429+Retry-Afteron lockout, records failures, clears on success; logs the IP per outcome.src/pages/routes/login.tsx—LoginPagegains adisabledprop (input + button disabled & dimmed) used only on the lockout render.src/index.ts— cleanup sweep wired into startup/shutdown.test/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.CHANGELOG(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— cleanbun test test/unit test/e2e— 385 passbun run test:integration— 100 pass429+Retry-After+ disabled form; correct password clears the counter.