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
+39 -2
View File
@@ -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