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:
@@ -35,6 +35,34 @@
|
|||||||
> IP 说明:面板经 Caddy 反代时,直连 socket 是回环地址;插件在检测到回环
|
> IP 说明:面板经 Caddy 反代时,直连 socket 是回环地址;插件在检测到回环
|
||||||
> 来源时会取 `X-Forwarded-For` 的首个值作为真实 IP(Caddy 默认会带上该头)。
|
> 来源时会取 `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
|
```bash
|
||||||
|
|||||||
@@ -74,6 +74,62 @@ export function defaultAuditPath() {
|
|||||||
return resolve(home, 'panel-auth-audit.jsonl')
|
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 ────────────────────────────────────────────────────────────────────
|
// ── guard ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function createGuard({ getConfig, logger }) {
|
export function createGuard({ getConfig, logger }) {
|
||||||
@@ -355,8 +411,8 @@ function clientIp(req) {
|
|||||||
if (loopback) {
|
if (loopback) {
|
||||||
const forwarded = req.headers['x-forwarded-for']
|
const forwarded = req.headers['x-forwarded-for']
|
||||||
if (typeof forwarded === 'string' && forwarded.length > 0) {
|
if (typeof forwarded === 'string' && forwarded.length > 0) {
|
||||||
const first = forwarded.split(',')[0].trim()
|
const parts = forwarded.split(',').map((part) => part.trim()).filter((part) => part.length > 0)
|
||||||
if (first.length > 0) return first
|
if (parts.length > 0) return parts[parts.length - 1]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return remote
|
return remote
|
||||||
@@ -410,12 +466,16 @@ function readBody(req, maxBytes) {
|
|||||||
|
|
||||||
// ── server wrap ──────────────────────────────────────────────────────────────
|
// ── server wrap ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function sleep(ms) {
|
||||||
|
return new Promise((resolveSleep) => setTimeout(resolveSleep, ms))
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Atomically wrap the panel's http.Server: replace its 'request' and
|
* Atomically wrap the panel's http.Server: replace its 'request' and
|
||||||
* 'upgrade' listeners with an auth gate that forwards to the originals.
|
* 'upgrade' listeners with an auth gate that forwards to the originals.
|
||||||
* Returns a disposer that restores the exact original listeners.
|
* 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 origRequest = server.listeners('request')
|
||||||
const origUpgrade = server.listeners('upgrade')
|
const origUpgrade = server.listeners('upgrade')
|
||||||
|
|
||||||
@@ -458,8 +518,7 @@ export function installGuard(server, guard, { audit, loginPath, logoutPath }) {
|
|||||||
const host = req.headers.host
|
const host = req.headers.host
|
||||||
const ip = clientIp(req)
|
const ip = clientIp(req)
|
||||||
const ua = req.headers['user-agent'] ?? ''
|
const ua = req.headers['user-agent'] ?? ''
|
||||||
if (
|
if (typeof origin === 'string' &&
|
||||||
typeof origin === 'string' &&
|
|
||||||
origin !== 'null' &&
|
origin !== 'null' &&
|
||||||
typeof host === 'string' &&
|
typeof host === 'string' &&
|
||||||
!sameHost(origin, host)
|
!sameHost(origin, host)
|
||||||
@@ -476,6 +535,15 @@ export function installGuard(server, guard, { audit, loginPath, logoutPath }) {
|
|||||||
serveLogin(req, res, '非法请求来源', 403, queryNext)
|
serveLogin(req, res, '非法请求来源', 403, queryNext)
|
||||||
return
|
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)
|
const body = await readBody(req, 8192)
|
||||||
if (body === null) {
|
if (body === null) {
|
||||||
audit.write({ event: 'login-fail', username: '', ip: clientIp(req), ua: req.headers['user-agent'] ?? '', reason: 'body-too-large' })
|
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)) {
|
if (guard.checkCredentials(username, password)) {
|
||||||
const value = guard.issueCookieValue(username)
|
const value = guard.issueCookieValue(username)
|
||||||
const settings = guard.cookieSettings()
|
const settings = guard.cookieSettings()
|
||||||
|
if (lockout) lockout.clear(ip)
|
||||||
audit.write({ event: 'login-ok', username, ip, ua })
|
audit.write({ event: 'login-ok', username, ip, ua })
|
||||||
res.statusCode = 303
|
res.statusCode = 303
|
||||||
res.setHeader('Set-Cookie', `${settings.name}=${value}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${settings.ttl}`)
|
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()
|
res.end()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (lockout) lockout.recordFailure(ip)
|
||||||
audit.write({ event: 'login-fail', username, ip, ua, reason: 'bad-credentials' })
|
audit.write({ event: 'login-fail', username, ip, ua, reason: 'bad-credentials' })
|
||||||
|
if (failedLoginDelayMs > 0) await sleep(failedLoginDelayMs)
|
||||||
serveLogin(req, res, '用户名或密码错误', 403, next)
|
serveLogin(req, res, '用户名或密码错误', 403, next)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -531,18 +602,36 @@ export function installGuard(server, guard, { audit, loginPath, logoutPath }) {
|
|||||||
handleLogout(req, res)
|
handleLogout(req, res)
|
||||||
return
|
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)
|
const who = guard.authenticate(req)
|
||||||
if (who === null) {
|
if (who === null) {
|
||||||
|
if (hasCreds && lockout) lockout.recordFailure(ip)
|
||||||
if (acceptsHtml(req)) {
|
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 ?? '/')
|
serveLogin(req, res, '', 200, req.url ?? '/')
|
||||||
return
|
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)
|
challenge(res)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!who.open && who.via === 'basic') {
|
if (!who.open && who.via === 'basic') {
|
||||||
|
if (lockout) lockout.clear(ip)
|
||||||
const value = guard.issueCookieValue(who.username)
|
const value = guard.issueCookieValue(who.username)
|
||||||
const settings = guard.cookieSettings()
|
const settings = guard.cookieSettings()
|
||||||
const existing = res.getHeader('Set-Cookie')
|
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 ───────────────────────────────────────────────────────────────────
|
// ── plugin ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export default function panelAuth(ctx, config) {
|
export default function panelAuth(ctx, config) {
|
||||||
@@ -596,6 +689,14 @@ export default function panelAuth(ctx, config) {
|
|||||||
const audit = createAuditWriter(auditPath)
|
const audit = createAuditWriter(auditPath)
|
||||||
const loginPath = typeof cfg.loginPath === 'string' && cfg.loginPath.length > 0 ? cfg.loginPath : DEFAULTS.loginPath
|
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 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)
|
console.log('[panel-auth] audit log: ' + auditPath)
|
||||||
let disposer
|
let disposer
|
||||||
let timer
|
let timer
|
||||||
@@ -608,7 +709,7 @@ export default function panelAuth(ctx, config) {
|
|||||||
const tryWrap = () => {
|
const tryWrap = () => {
|
||||||
const server = ctx.webServer.server
|
const server = ctx.webServer.server
|
||||||
if (server === undefined) return false
|
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')
|
console.log('[panel-auth] guard installed: panel now requires a password')
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { strict as assert } from 'node:assert'
|
|||||||
import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
|
import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
|
||||||
import { tmpdir } from 'node:os'
|
import { tmpdir } from 'node:os'
|
||||||
import { join } from 'node:path'
|
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'
|
import { hashPassword, verifyPassword } from './crypto.js'
|
||||||
|
|
||||||
const tmp = mkdtempSync(join(tmpdir(), 'panel-auth-test-'))
|
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')
|
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))
|
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
|
||||||
const base = `http://127.0.0.1:${server.address().port}`
|
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.equal(r.status, 200)
|
||||||
assert.match(await r.text(), /登录以继续/)
|
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
|
// 11. audit log contains the expected events
|
||||||
const auditLines = readFileSync(join(tmp, 'audit.jsonl'), 'utf8').trim().split('\n').map((line) => JSON.parse(line))
|
const auditLines = readFileSync(join(tmp, 'audit.jsonl'), 'utf8').trim().split('\n').map((line) => JSON.parse(line))
|
||||||
const events = auditLines.map((e) => e.event)
|
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')
|
const co = auditLines.find((e) => e.event === 'login-fail' && e.reason === 'cross-origin')
|
||||||
assert.equal(co.origin, 'https://evil.example.com')
|
assert.equal(co.origin, 'https://evil.example.com')
|
||||||
assert.match(co.host, /^127\.0\.0\.1:/)
|
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)
|
assert.equal(auditLines.every((e) => typeof e.ip === 'string' && typeof e.ts === 'string'), true)
|
||||||
|
|
||||||
// 12. XSS: hostile `next` value is escaped in the page
|
// 12. XSS: hostile `next` value is escaped in the page
|
||||||
|
|||||||
Reference in New Issue
Block a user