forked from dsh/panel-auth
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user