feat: brute-force protection with per-IP escalating lockout

- Failures counted per client IP (X-Forwarded-For last hop behind the
  proxy); after maxFailures within the window the IP is locked out,
  doubling per repeat up to lockoutMaxSeconds.
- Locked IPs get 429 + Retry-After (login page / JSON for API), and the
  scrypt verification is skipped entirely while locked (no CPU burn).
- Fixed failedLoginDelayMs delay on every bad credential attempt.
- Basic-auth path counts and clears identically; success resets the IP.
- All thresholds configurable; in-memory state only.
- Tests: lockout, expiry restore, basic-path counting, XFF last-hop key.
This commit is contained in:
dsh
2026-08-16 01:48:36 -04:00
parent 000ca41501
commit 8823049b66
3 changed files with 176 additions and 10 deletions
+109 -8
View File
@@ -74,6 +74,62 @@ export function defaultAuditPath() {
return resolve(home, 'panel-auth-audit.jsonl')
}
/**
* In-memory brute-force lockout keyed by client IP.
* - After `maxFailures` failures inside `failureWindowSeconds`, the IP is
* locked out for `lockoutBaseSeconds`; repeated lockouts double the
* duration up to `lockoutMaxSeconds`.
* - State is per-process only (a panel restart clears it — restarting the
* panel requires root, so an attacker cannot reset it themselves).
*/
export function createLockout({ maxFailures = 5, lockoutBaseSeconds = 30, lockoutMaxSeconds = 3600, failureWindowSeconds = 300, enabled = true } = {}) {
const records = new Map()
const nowMs = () => Date.now()
function prune() {
const now = nowMs()
const cutoff = now - failureWindowSeconds * 1000
for (const [ip, rec] of records) {
if (rec.lockedUntil < now && rec.firstAt < cutoff) records.delete(ip)
}
if (records.size > 10000) {
const it = records.keys()
while (records.size > 8000) records.delete(it.next().value)
}
}
function status(ip) {
if (!enabled) return { locked: false, retryAfter: 0 }
prune()
const rec = records.get(ip)
if (!rec) return { locked: false, retryAfter: 0 }
const now = nowMs()
if (rec.lockedUntil > now) {
return { locked: true, retryAfter: Math.max(1, Math.ceil((rec.lockedUntil - now) / 1000)) }
}
return { locked: false, retryAfter: 0 }
}
function recordFailure(ip) {
if (!enabled || !ip) return
const now = nowMs()
let rec = records.get(ip)
if (!rec || now - rec.firstAt > failureWindowSeconds * 1000) {
rec = { count: 0, firstAt: now, strikes: 0, lockedUntil: 0 }
records.set(ip, rec)
}
rec.count += 1
if (rec.count >= maxFailures) {
rec.strikes += 1
const lockMs = Math.min(lockoutBaseSeconds * 1000 * 2 ** (rec.strikes - 1), lockoutMaxSeconds * 1000)
rec.lockedUntil = now + lockMs
rec.count = 0
rec.firstAt = now
}
}
function clear(ip) {
records.delete(ip)
}
return { status, recordFailure, clear }
}
// ── guard ────────────────────────────────────────────────────────────────────
export function createGuard({ getConfig, logger }) {
@@ -355,8 +411,8 @@ function clientIp(req) {
if (loopback) {
const forwarded = req.headers['x-forwarded-for']
if (typeof forwarded === 'string' && forwarded.length > 0) {
const first = forwarded.split(',')[0].trim()
if (first.length > 0) return first
const parts = forwarded.split(',').map((part) => part.trim()).filter((part) => part.length > 0)
if (parts.length > 0) return parts[parts.length - 1]
}
}
return remote
@@ -410,12 +466,16 @@ function readBody(req, maxBytes) {
// ── server wrap ──────────────────────────────────────────────────────────────
function sleep(ms) {
return new Promise((resolveSleep) => setTimeout(resolveSleep, ms))
}
/**
* Atomically wrap the panel's http.Server: replace its 'request' and
* 'upgrade' listeners with an auth gate that forwards to the originals.
* Returns a disposer that restores the exact original listeners.
*/
export function installGuard(server, guard, { audit, loginPath, logoutPath }) {
export function installGuard(server, guard, { audit, loginPath, logoutPath, lockout, failedLoginDelayMs = 300 }) {
const origRequest = server.listeners('request')
const origUpgrade = server.listeners('upgrade')
@@ -458,8 +518,7 @@ export function installGuard(server, guard, { audit, loginPath, logoutPath }) {
const host = req.headers.host
const ip = clientIp(req)
const ua = req.headers['user-agent'] ?? ''
if (
typeof origin === 'string' &&
if (typeof origin === 'string' &&
origin !== 'null' &&
typeof host === 'string' &&
!sameHost(origin, host)
@@ -476,6 +535,15 @@ export function installGuard(server, guard, { audit, loginPath, logoutPath }) {
serveLogin(req, res, '非法请求来源', 403, queryNext)
return
}
if (lockout) {
const lock = lockout.status(ip)
if (lock.locked) {
audit.write({ event: 'login-fail', username: '', ip, ua, reason: 'rate-limited' })
res.setHeader('Retry-After', String(lock.retryAfter))
serveLogin(req, res, `尝试次数过多,请 ${lock.retryAfter} 秒后再试`, 429, queryNext)
return
}
}
const body = await readBody(req, 8192)
if (body === null) {
audit.write({ event: 'login-fail', username: '', ip: clientIp(req), ua: req.headers['user-agent'] ?? '', reason: 'body-too-large' })
@@ -494,6 +562,7 @@ export function installGuard(server, guard, { audit, loginPath, logoutPath }) {
if (guard.checkCredentials(username, password)) {
const value = guard.issueCookieValue(username)
const settings = guard.cookieSettings()
if (lockout) lockout.clear(ip)
audit.write({ event: 'login-ok', username, ip, ua })
res.statusCode = 303
res.setHeader('Set-Cookie', `${settings.name}=${value}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${settings.ttl}`)
@@ -502,7 +571,9 @@ export function installGuard(server, guard, { audit, loginPath, logoutPath }) {
res.end()
return
}
if (lockout) lockout.recordFailure(ip)
audit.write({ event: 'login-fail', username, ip, ua, reason: 'bad-credentials' })
if (failedLoginDelayMs > 0) await sleep(failedLoginDelayMs)
serveLogin(req, res, '用户名或密码错误', 403, next)
}
@@ -531,18 +602,36 @@ export function installGuard(server, guard, { audit, loginPath, logoutPath }) {
handleLogout(req, res)
return
}
const ip = clientIp(req)
const hasCreds = typeof req.headers.authorization === 'string'
// Check the lockout BEFORE verifying credentials: a locked-out IP must
// not burn CPU on scrypt.
if (hasCreds && lockout) {
const lock = lockout.status(ip)
if (lock.locked) {
audit.write({ event: 'login-fail', username: '', ip, ua: req.headers['user-agent'] ?? '', reason: 'rate-limited' })
res.statusCode = 429
res.setHeader('Retry-After', String(lock.retryAfter))
res.setHeader('Content-Type', 'application/json; charset=utf-8')
res.setHeader('Cache-Control', 'no-store')
res.end(`{"error":"too many attempts","retryAfter":${lock.retryAfter}}`)
return
}
}
const who = guard.authenticate(req)
if (who === null) {
if (hasCreds && lockout) lockout.recordFailure(ip)
if (acceptsHtml(req)) {
audit.write({ event: 'challenge', username: '', ip: clientIp(req), ua: req.headers['user-agent'] ?? '', path: pathname })
audit.write({ event: 'challenge', username: '', ip, ua: req.headers['user-agent'] ?? '', path: pathname })
serveLogin(req, res, '', 200, req.url ?? '/')
return
}
audit.write({ event: 'reject', username: '', ip: clientIp(req), ua: req.headers['user-agent'] ?? '', method: req.method ?? '', path: pathname })
audit.write({ event: 'reject', username: '', ip, ua: req.headers['user-agent'] ?? '', method: req.method ?? '', path: pathname })
challenge(res)
return
}
if (!who.open && who.via === 'basic') {
if (lockout) lockout.clear(ip)
const value = guard.issueCookieValue(who.username)
const settings = guard.cookieSettings()
const existing = res.getHeader('Set-Cookie')
@@ -587,6 +676,10 @@ export function installGuard(server, guard, { audit, loginPath, logoutPath }) {
}
}
function positiveInt(value, fallback) {
return Number.isFinite(value) && value >= 0 ? Math.floor(value) : fallback
}
// ── plugin ───────────────────────────────────────────────────────────────────
export default function panelAuth(ctx, config) {
@@ -596,6 +689,14 @@ export default function panelAuth(ctx, config) {
const audit = createAuditWriter(auditPath)
const loginPath = typeof cfg.loginPath === 'string' && cfg.loginPath.length > 0 ? cfg.loginPath : DEFAULTS.loginPath
const logoutPath = typeof cfg.logoutPath === 'string' && cfg.logoutPath.length > 0 ? cfg.logoutPath : DEFAULTS.logoutPath
const lockout = createLockout({
enabled: cfg.bruteProtection !== false,
maxFailures: Math.max(1, positiveInt(cfg.maxFailures, 5)),
lockoutBaseSeconds: Math.max(1, positiveInt(cfg.lockoutBaseSeconds, 30)),
lockoutMaxSeconds: Math.max(1, positiveInt(cfg.lockoutMaxSeconds, 3600)),
failureWindowSeconds: Math.max(1, positiveInt(cfg.failureWindowSeconds, 300)),
})
const failedLoginDelayMs = positiveInt(cfg.failedLoginDelayMs, 300)
console.log('[panel-auth] audit log: ' + auditPath)
let disposer
let timer
@@ -608,7 +709,7 @@ export default function panelAuth(ctx, config) {
const tryWrap = () => {
const server = ctx.webServer.server
if (server === undefined) return false
disposer = installGuard(server, guard, { audit, loginPath, logoutPath })
disposer = installGuard(server, guard, { audit, loginPath, logoutPath, lockout, failedLoginDelayMs })
console.log('[panel-auth] guard installed: panel now requires a password')
return true
}