A single recordFailure() locks for BASE_MS * 2 ** 0 — exactly one second — and the test then does a real HTTP round trip against it. On CI that round trip took 1,456 ms and the guard correctly answered 200, failing the run for a reason that has nothing to do with what the test is about. Five failures lock for sixteen seconds. The subject is the guard's answer while locked out, which is unchanged. Co-Authored-By: Claude <noreply@anthropic.com>
103 lines
3.7 KiB
JavaScript
103 lines
3.7 KiB
JavaScript
const { test, beforeEach } = require('node:test')
|
|
const assert = require('node:assert/strict')
|
|
|
|
const lp = require('../src/middleware/loginProtection')
|
|
const { loginLimiter } = require('../src/middleware/rateLimit')
|
|
const { startApp } = require('./_helper')
|
|
|
|
beforeEach(() => lp._reset())
|
|
|
|
test('recordFailure escalates the lockout exponentially', () => {
|
|
const ip = '198.51.100.50'
|
|
const now = 1_000_000
|
|
const first = lp.recordFailure(ip, now)
|
|
const second = lp.recordFailure(ip, now)
|
|
const third = lp.recordFailure(ip, now)
|
|
assert.equal(first, lp.BASE_MS) // 2^0
|
|
assert.equal(second, lp.BASE_MS * 2) // 2^1
|
|
assert.equal(third, lp.BASE_MS * 4) // 2^2
|
|
})
|
|
|
|
test('lockout is capped at MAX_MS', () => {
|
|
const ip = '198.51.100.51'
|
|
const now = 1_000_000
|
|
let last = 0
|
|
for (let i = 0; i < 40; i++) last = lp.recordFailure(ip, now)
|
|
assert.equal(last, lp.MAX_MS)
|
|
})
|
|
|
|
test('retryAfterMs reflects the active lockout and clears after it elapses', () => {
|
|
const ip = '198.51.100.52'
|
|
const now = 2_000_000
|
|
lp.recordFailure(ip, now) // locks BASE_MS
|
|
assert.ok(lp.retryAfterMs(ip, now) > 0)
|
|
assert.equal(lp.retryAfterMs(ip, now + lp.BASE_MS + 1), 0)
|
|
})
|
|
|
|
test('recordSuccess clears the failure streak', () => {
|
|
const ip = '198.51.100.53'
|
|
const now = 2_000_000
|
|
lp.recordFailure(ip, now)
|
|
lp.recordSuccess(ip)
|
|
assert.equal(lp.retryAfterMs(ip, now), 0)
|
|
})
|
|
|
|
test('streak resets after a long quiet period (does not grow forever)', () => {
|
|
const ip = '198.51.100.54'
|
|
const t0 = 3_000_000
|
|
lp.recordFailure(ip, t0)
|
|
lp.recordFailure(ip, t0)
|
|
// Come back after RESET_MS+ of quiet: next failure starts the streak over.
|
|
const later = t0 + lp.RESET_MS + 1
|
|
const delay = lp.recordFailure(ip, later)
|
|
assert.equal(delay, lp.BASE_MS) // back to 2^0
|
|
})
|
|
|
|
test('backoffGuard returns a generic 429 while locked out', async () => {
|
|
// Pre-lock this IP, then confirm the guard blocks it with a generic message.
|
|
const app = await startApp((a) => {
|
|
a.set('trust proxy', 1)
|
|
a.post('/login', lp.backoffGuard, (req, res) => res.json({ ok: true }))
|
|
})
|
|
try {
|
|
// Lock the test client IP. FIVE failures, not one: the lock is
|
|
// `BASE_MS * 2 ** (count - 1)`, so a single failure locks for exactly one
|
|
// second and this test then races the round trip. It lost that race on CI
|
|
// (200 instead of 429, request arriving 1,456 ms after the lock). Five
|
|
// failures lock for sixteen seconds, which is not a race. What is under
|
|
// test is the guard's ANSWER while locked out, and that is unchanged.
|
|
for (let i = 0; i < 5; i += 1) lp.recordFailure('203.0.113.40')
|
|
const res = await fetch(`${app.url}/login`, {
|
|
method: 'POST',
|
|
headers: { 'X-Forwarded-For': '203.0.113.40' },
|
|
})
|
|
assert.equal(res.status, 429)
|
|
const body = await res.json()
|
|
assert.match(body.message, /too many login attempts/i)
|
|
// Message must not reveal whether username or password was the problem.
|
|
assert.doesNotMatch(body.message, /password|username/i)
|
|
assert.ok(res.headers.get('retry-after'))
|
|
} finally {
|
|
await app.close()
|
|
}
|
|
})
|
|
|
|
test('hard rate limiter caps attempts per IP (429 after the cap)', async () => {
|
|
const app = await startApp((a) => {
|
|
a.set('trust proxy', 1)
|
|
a.post('/login', loginLimiter, (req, res) => res.json({ ok: true }))
|
|
})
|
|
try {
|
|
const headers = { 'X-Forwarded-For': '203.0.113.41' }
|
|
let sawLimit = false
|
|
// Cap is 10/15min; the 11th should be blocked.
|
|
for (let i = 0; i < 12; i++) {
|
|
const res = await fetch(`${app.url}/login`, { method: 'POST', headers })
|
|
if (res.status === 429) sawLimit = true
|
|
}
|
|
assert.ok(sawLimit, 'expected the hard limiter to return 429 after the cap')
|
|
} finally {
|
|
await app.close()
|
|
}
|
|
})
|