From 8823049b66a5e2b59078cdbf2088cc647bd0273f Mon Sep 17 00:00:00 2001 From: dsh Date: Sun, 16 Aug 2026 01:48:36 -0400 Subject: [PATCH] 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. --- README.md | 28 +++++++++++++ index.js | 117 ++++++++++++++++++++++++++++++++++++++++++++++++++---- test.mjs | 41 ++++++++++++++++++- 3 files changed, 176 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 2367b44..76d1e5f 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,34 @@ > IP 说明:面板经 Caddy 反代时,直连 socket 是回环地址;插件在检测到回环 > 来源时会取 `X-Forwarded-For` 的首个值作为真实 IP(Caddy 默认会带上该头)。 +## 防爆破(默认开启) + +- **按 IP 计数**:同一 IP 在 `failureWindowSeconds`(默认 300 秒)窗口内连续 + `maxFailures`(默认 5)次密码错误后进入锁定期。 +- **阶梯式锁定**:首次锁 `lockoutBaseSeconds`(默认 30 秒),再次触发翻倍, + 上限 `lockoutMaxSeconds`(默认 3600 秒)。 +- 锁定期内:登录页返回 `429 + Retry-After`("尝试次数过多"),API 返回 + `429 {"error":"too many attempts"}`,且**不再执行 scrypt 校验**(不消耗 CPU)。 +- **失败延迟**:每次密码错误额外等待 `failedLoginDelayMs`(默认 300ms), + 拖慢单连接暴力尝试。 +- **成功即清零**;锁定状态为进程内存态,面板重启后清空(重启面板需要 + root,攻击者无法自行重置)。 +- Basic 认证路径同样计入;IP 取自反代 `X-Forwarded-For` 的最后一跳 + (Caddy 会覆写伪造值,见下)。 +- 配置(`cordis.patch.yml` 的 `config` 中可调): + ```yaml + bruteProtection: true # 关闭设为 false + maxFailures: 5 + lockoutBaseSeconds: 30 + lockoutMaxSeconds: 3600 + failureWindowSeconds: 300 + failedLoginDelayMs: 300 + ``` + +> 局限:分布式攻击(每尝试换一个 IP)不受单 IP 锁定约束;这由 +> 300ms 失败延迟 + scrypt 慢哈希兜底。公网反代场景建议再配合 Caddy +> 层的 IP 白名单/云防火墙(如 Cloudflare)使用。 + ## 修改密码 ```bash diff --git a/index.js b/index.js index b9b8b37..088a337 100644 --- a/index.js +++ b/index.js @@ -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 } diff --git a/test.mjs b/test.mjs index 4170e09..38e6e72 100644 --- a/test.mjs +++ b/test.mjs @@ -6,7 +6,7 @@ import { strict as assert } from 'node:assert' import { mkdtempSync, readFileSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { createGuard, installGuard, createAuditWriter, renderLoginPage } from './index.js' +import { createGuard, installGuard, createAuditWriter, createLockout, renderLoginPage } from './index.js' import { hashPassword, verifyPassword } from './crypto.js' const tmp = mkdtempSync(join(tmpdir(), 'panel-auth-test-')) @@ -32,7 +32,8 @@ server.on('upgrade', (req, socket, head) => { socket.end('HTTP/1.1 101 Switching Protocols\r\nUpgrade: test\r\nConnection: Upgrade\r\n\r\n') }) -const disposer = installGuard(server, guard, { audit, loginPath: '/panel-auth/login', logoutPath: '/panel-auth/logout' }) +const lockout = createLockout({ maxFailures: 4, lockoutBaseSeconds: 1, lockoutMaxSeconds: 2, failureWindowSeconds: 60, enabled: true }) +const disposer = installGuard(server, guard, { audit, loginPath: '/panel-auth/login', logoutPath: '/panel-auth/logout', lockout, failedLoginDelayMs: 0 }) await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) const base = `http://127.0.0.1:${server.address().port}` @@ -129,6 +130,41 @@ r = await raw('/', { accept: 'text/html' }) assert.equal(r.status, 200) assert.match(await r.text(), /登录以继续/) +// 10b. brute force: lockout after maxFailures bad logins (correct creds also rejected) +lockout.clear('127.0.0.1') +for (let i = 0; i < 4; i++) { + r = await post('/panel-auth/login', 'username=admin&password=nope&next=%2F') + assert.equal(r.status, 403) +} +r = await post('/panel-auth/login', 'username=admin&password=s3cret-pass&next=%2F') +assert.equal(r.status, 429) +assert.ok(Number(r.headers.get('retry-after')) >= 1) +assert.match(await r.text(), /尝试次数过多/) + +// 10c. lockout expiry restores access +await new Promise((resolve) => setTimeout(resolve, 1200)) +r = await post('/panel-auth/login', 'username=admin&password=s3cret-pass&next=%2F') +assert.equal(r.status, 303) + +// 10d. Basic-auth path is counted too, and clears on success +lockout.clear('127.0.0.1') +for (let i = 0; i < 4; i++) { + r = await raw('/', { authorization: 'Basic ' + Buffer.from('admin:wrong').toString('base64') }) + assert.equal(r.status, 401) +} +r = await raw('/', { authorization: 'Basic ' + Buffer.from('admin:s3cret-pass').toString('base64') }) +assert.equal(r.status, 429) +assert.match(await r.text(), /too many attempts/) +lockout.clear('127.0.0.1') +r = await raw('/', { authorization: 'Basic ' + Buffer.from('admin:s3cret-pass').toString('base64') }) +assert.equal(r.status, 200) + +// 10e. X-Forwarded-For: the last hop wins as the IP key +lockout.clear('127.0.0.1') +await post('/panel-auth/login', 'username=admin&password=nope&next=%2F', { 'X-Forwarded-For': '1.2.3.4, 9.9.9.9' }) +const auditAfterXff = readFileSync(join(tmp, 'audit.jsonl'), 'utf8').trim().split('\n').map((line) => JSON.parse(line)) +assert.equal(auditAfterXff.at(-1).ip, '9.9.9.9') + // 11. audit log contains the expected events const auditLines = readFileSync(join(tmp, 'audit.jsonl'), 'utf8').trim().split('\n').map((line) => JSON.parse(line)) const events = auditLines.map((e) => e.event) @@ -142,6 +178,7 @@ assert.equal(fail.username, 'admin') const co = auditLines.find((e) => e.event === 'login-fail' && e.reason === 'cross-origin') assert.equal(co.origin, 'https://evil.example.com') assert.match(co.host, /^127\.0\.0\.1:/) +assert.ok(auditLines.some((e) => e.event === 'login-fail' && e.reason === 'rate-limited'), 'audit missing rate-limited entry') assert.equal(auditLines.every((e) => typeof e.ip === 'string' && typeof e.ts === 'string'), true) // 12. XSS: hostile `next` value is escaped in the page