plugin/antibrute: limit burst

Massimo Melina committed Mar 21, 2026 at 15:12 UTC b599c11e762d9eb1074006eae3ac19cd33405003
2 files changed +273 -19
plugins/antibrute/plugin.js
+150 -19
@@ -1,4 +1,4 @@
1 -exports.version = 3.1
1 +exports.version = 3.2
2 exports.description = "Introduce increasing delays between login attempts."
3 exports.apiRequired = 9.6 // addBlock
4
@@ -8,42 +8,173 @@ exports.config = {
8 blockAfter: { type: 'number', xs: 6, min: 1, max: 9999, defaultValue: 100, label: "Block IP after", unit: "attempts", helperText: "localhost excluded" },
9 blockForHours: { type: 'number', xs: 6, min: 0, defaultValue: 24, label: "Block for", unit: "hours" },
10 exclude: { type: 'string', defaultValue: '', label: "Exclude IPs", helperText: "Net mask syntax" },
11 + maxQueuePerIp: { type: 'number', min: 1, max: 9999, defaultValue: 32, label: "Max queued per IP" },
12 + maxQueuePerAccount: { type: 'number', min: 1, max: 9999, defaultValue: 16, label: "Max queued per account" },
13 + maxQueueGlobal: { type: 'number', min: 1, max: 999999, defaultValue: 512, label: "Max queued globally" },
14 }
15 exports.configDialog = {
16 maxWidth: 'xs',
17 }
18
19 const byIp = {}
20 +const byAccount = {}
21 +const laneByIp = {}
22 +const laneByAccount = {}
23 +const UNKNOWN_ACCOUNT = 'unknown\t'
24
25 exports.init = api => {
26 const { isLocalHost, HOUR, netMatches } = api.misc
27 + const { makeQ } = api.require('./makeQ')
28 + const gateQ = makeQ(1)
29 + let waitingGlobal = 0
30 + const QUEUE_FULL = Symbol('queue_full')
31 api.events.multi({
21 - async attemptingLogin({ ctx }) {
32 + async attemptingLogin({ ctx, username }) {
33 const { ip } = ctx
23 - const now = new Date
24 - const rec = byIp[ip] ||= { attempts: 0, next: now }
25 - const max = api.getConfig('max') * 1000
26 - const delay = Math.min(max, 1000 * api.getConfig('increment') * ++rec.attempts)
27 - const wait = rec.next - now
28 - rec.next = new Date(+rec.next + delay)
29 - if (rec.attempts > api.getConfig('blockAfter') && !isLocalHost(ctx) && !isExcluded(ip)) {
30 - const hours = api.getConfig('blockForHours')
31 - api.addBlock({ ip, comment: "From antibrute plugin", expire: hours ? new Date(now.getTime() + hours * HOUR) : undefined })
34 + const account = getAccountKey(username)
35 + const ipRec = getRecord(byIp, ip)
36 + const accountRec = getRecord(byAccount, account)
37 + let admitted = false
38 + try {
39 + await runGate(() => {
40 + if (ipRec.waiting >= api.getConfig('maxQueuePerIp')
41 + || accountRec.waiting >= api.getConfig('maxQueuePerAccount')
42 + || waitingGlobal >= api.getConfig('maxQueueGlobal'))
43 + throw QUEUE_FULL
44 + // reserve all buckets atomically so we never exceed limits due to parallel requests
45 + ipRec.waiting++
46 + accountRec.waiting++
47 + waitingGlobal++
48 + admitted = true
49 + })
50 + // serialize waits per ip and per account so parallel bursts can't consume the same penalty window
51 + await runInLane(getLane(laneByIp, ip), () =>
52 + runInLane(getLane(laneByAccount, account), async () => {
53 + const now = Date.now()
54 + const wait = Math.max(0, ipRec.next - now, accountRec.next - now)
55 + if (wait <= 0) return
56 + api.log('delaying', ip, 'for', Math.round(wait / 1000))
57 + ctx.set('x-anti-brute-force', wait)
58 + await new Promise(resolve => setTimeout(resolve, wait))
59 + }))
60 + }
61 + catch (e) {
62 + if (e === QUEUE_FULL) {
63 + ctx.status = 429
64 + return api.events.stop
65 + }
66 + throw e
67 }
33 - clearTimeout(rec.timer)
34 - if (wait > 0) {
35 - api.log('delaying', ip, 'for', Math.round(wait / 1000))
36 - ctx.set('x-anti-brute-force', wait)
37 - await new Promise(resolve => setTimeout(resolve, wait))
68 + finally {
69 + if (admitted) {
70 + ipRec.waiting--
71 + accountRec.waiting--
72 + waitingGlobal--
73 + }
74 + armCleanup(byIp, ip, ipRec)
75 + armCleanup(byAccount, account, accountRec)
76 + dropLaneIfIdle(laneByIp, ip)
77 + dropLaneIfIdle(laneByAccount, account)
78 }
39 - rec.timer = setTimeout(() => delete byIp[ip], 24 * HOUR) // no memory leak
79 + },
80 + failedLogin({ ctx, username }) {
81 + const { ip } = ctx
82 + const account = getAccountKey(username)
83 + const now = Date.now()
84 + const ipRec = getRecord(byIp, ip)
85 + const accountRec = getRecord(byAccount, account)
86 + const ipAttempts = increasePenalty(ipRec, now)
87 + increasePenalty(accountRec, now)
88 + if (ipAttempts > api.getConfig('blockAfter') && !isLocalHost(ctx) && !isExcluded(ip)) {
89 + const hours = api.getConfig('blockForHours')
90 + api.addBlock({ ip, comment: "From antibrute plugin", expire: hours ? new Date(now + hours * HOUR) : undefined })
91 + }
92 + armCleanup(byIp, ip, ipRec)
93 + armCleanup(byAccount, account, accountRec)
94 + dropLaneIfIdle(laneByIp, ip)
95 + dropLaneIfIdle(laneByAccount, account)
96 },
97 login(ctx) {
42 - if (ctx.state.account)
43 - delete byIp[ctx.ip] // reset if login was successful
98 + if (ctx.state.account) {
99 + const { ip } = ctx
100 + const account = getAccountKey(ctx.state.account.username)
101 + resetRecord(byIp, ip)
102 + resetRecord(byAccount, account)
103 + dropLaneIfIdle(laneByIp, ip)
104 + dropLaneIfIdle(laneByAccount, account)
105 + }
106 }
107 })
108
109 + function getRecord(container, key) {
110 + return container[key] ||= { failures: 0, next: 0, waiting: 0 }
111 + }
112 +
113 + function increasePenalty(rec, now) {
114 + const attempts = ++rec.failures
115 + const max = api.getConfig('max') * 1000
116 + const delay = Math.min(max, attempts * api.getConfig('increment') * 1000)
117 + rec.next = Math.max(now, rec.next) + delay
118 + return attempts
119 + }
120 +
121 + function armCleanup(records, key, rec) {
122 + clearTimeout(rec.timer)
123 + rec.timer = setTimeout(() => {
124 + // keep records while there are in-flight admissions, otherwise later releases may touch deleted state
125 + if (rec.waiting)
126 + return armCleanup(records, key, rec)
127 + delete records[key]
128 + }, 24 * HOUR) // no memory leak
129 + }
130 +
131 + function runGate(job) {
132 + return new Promise((resolve, reject) => {
133 + gateQ.add(async () => {
134 + try { resolve(await job()) }
135 + catch (e) { reject(e) }
136 + })
137 + })
138 + }
139 +
140 + function getLane(container, key) {
141 + return container[key] ||= makeQ(1)
142 + }
143 +
144 + function runInLane(q, job) {
145 + return new Promise((resolve, reject) => {
146 + q.add(async () => {
147 + try { resolve(await job()) }
148 + catch (e) { reject(e) }
149 + })
150 + })
151 + }
152 +
153 + function dropLaneIfIdle(container, key) {
154 + const q = container[key]
155 + if (q?.isWorking() || q?.queueSize()) return
156 + delete container[key]
157 + }
158 +
159 + function resetRecord(container, key) {
160 + const rec = container[key]
161 + if (!rec) return
162 + if (rec.waiting) {
163 + // successful login must clear penalties without dropping admission counters still needed by concurrent requests
164 + rec.failures = 0
165 + rec.next = 0
166 + return
167 + }
168 + delete container[key]
169 + }
170 +
171 + function getAccountKey(username) {
172 + // fold unknown usernames together to avoid unbounded memory growth from random names
173 + if (!username || !api.getAccount(String(username)))
174 + return UNKNOWN_ACCOUNT
175 + return String(username).toLowerCase()
176 + }
177 +
178 function isExcluded(ip) {
179 const mask = api.getConfig('exclude')
180 if (!mask) return false
tests/test.ts
+123
@@ -1228,6 +1228,95 @@ describe('admin', () => {
1228 return req(`/~/plugins/${id}/../../../tests/config.yaml`, 404)()
1229 .finally(() => reqApi('stop_plugin', { id }, 200, { auth })())
1230 })
1231 + const antibruteCfg = {
1232 + increment: 1, max: 60,
1233 + blockAfter: 9999, maxQueuePerIp: 128,
1234 + maxQueuePerAccount: 128, maxQueueGlobal: 512,
1235 + }
1236 + test('antibrute.valid basic auth has no progressive delay', async () => {
1237 + await withPluginConfig('antibrute', antibruteCfg, async () => {
1238 + const first = await reqBasicAuth('/for-admins/', auth)
1239 + const second = await reqBasicAuth('/for-admins/', auth)
1240 + if (first.status !== 200) throw "first request failed"
1241 + if (second.status !== 200) throw "second request failed"
1242 + if (first.delay !== 0) throw "first request delayed"
1243 + if (second.delay !== 0) throw "second request delayed"
1244 + })
1245 + })
1246 + test('antibrute.valid burst has no anti-brute delay', async () => {
1247 + await withPluginConfig('antibrute', antibruteCfg, async () => {
1248 + const burst = await Promise.all(_.times(20, () => reqBasicAuth('/for-admins/', auth)))
1249 + if (burst.some(x => x.status !== 200)) throw `unexpected statuses in valid burst: ${burst.map(x => x.status)}`
1250 + if (burst.some(x => x.delay !== 0)) throw `unexpected delay in valid burst: ${burst.map(x => x.delay)}`
1251 + })
1252 + })
1253 + test('antibrute.valid burst x100 has no anti-brute delay', async () => {
1254 + await withPluginConfig('antibrute', antibruteCfg, async () => {
1255 + const burst = await Promise.all(_.times(100, () => reqBasicAuth('/for-admins/', auth)))
1256 + if (burst.some(x => x.status !== 200)) throw `unexpected statuses in x100 valid burst: ${burst.map(x => x.status)}`
1257 + if (burst.some(x => x.delay !== 0)) throw `unexpected delay in x100 valid burst: ${burst.map(x => x.delay)}`
1258 + })
1259 + })
1260 + test('antibrute.failed basic auth escalates delay', async () => {
1261 + await withPluginConfig('antibrute', antibruteCfg, async () => {
1262 + const first = await reqBasicAuth('/for-admins/', `${username}:wrong-password`)
1263 + const second = await reqBasicAuth('/for-admins/', `${username}:wrong-password`)
1264 + if (first.status !== 401) throw "first wrong login was not rejected"
1265 + if (second.status !== 401) throw "second wrong login was not rejected"
1266 + if (second.delay < 500) throw `missing delay escalation: ${second.delay}`
1267 + })
1268 + })
1269 + test('antibrute.successful login resets penalty', async () => {
1270 + await withPluginConfig('antibrute', antibruteCfg, async () => {
1271 + await reqBasicAuth('/for-admins/', `${username}:wrong-password`)
1272 + const penalized = await reqBasicAuth('/for-admins/', `${username}:wrong-password`)
1273 + const success = await reqBasicAuth('/for-admins/', auth)
1274 + const afterReset = await reqBasicAuth('/for-admins/', `${username}:wrong-password`)
1275 + if (penalized.status !== 401) throw "penalized wrong login status mismatch"
1276 + if (penalized.delay < 500) throw `missing pre-reset delay: ${penalized.delay}`
1277 + if (success.status !== 200) throw "successful login failed"
1278 + if (afterReset.status !== 401) throw "post-reset wrong login status mismatch"
1279 + if (afterReset.delay !== 0) throw `delay not reset after successful login: ${afterReset.delay}`
1280 + })
1281 + })
1282 + test('antibrute.burst serializes wrong logins with delays', async () => {
1283 + await withPluginConfig('antibrute', {
1284 + ...antibruteCfg,
1285 + increment: 1,
1286 + max: 1,
1287 + maxQueuePerIp: 3,
1288 + maxQueuePerAccount: 3,
1289 + maxQueueGlobal: 3,
1290 + }, async () => {
1291 + // seed penalty so the first burst request keeps queue slots busy
1292 + await reqBasicAuth('/for-admins/', `${username}:wrong-password`)
1293 + const started = Date.now()
1294 + const burst = await Promise.all(_.times(3, () => reqBasicAuth('/for-admins/', `${username}:wrong-password`)))
1295 + const elapsed = Date.now() - started
1296 + const delays = burst.map(x => x.delay)
1297 + if (burst.some(x => x.status !== 401)) throw `unexpected statuses in burst: ${burst.map(x => x.status)}`
1298 + if (delays.some(x => x <= 0)) throw `missing delay in burst: ${delays.join(',')}`
1299 + // the wall clock check proves requests waited in series instead of sharing one penalty window
1300 + if (elapsed < 2500) throw `burst was not serialized: ${elapsed}`
1301 + })
1302 + })
1303 + test('antibrute.queue limit rejects overflowing logins before credentials are checked', async () => {
1304 + await withPluginConfig('antibrute', {
1305 + ...antibruteCfg,
1306 + increment: 1,
1307 + max: 1,
1308 + maxQueuePerIp: 1,
1309 + maxQueuePerAccount: 1,
1310 + maxQueueGlobal: 1,
1311 + }, async () => {
1312 + // seed penalty so the queue slot remains occupied long enough to overflow
1313 + await reqBasicAuth('/for-admins/', `${username}:wrong-password`)
1314 + const burst = await Promise.all(_.times(3, () => reqBasicAuth('/for-admins/', auth)))
1315 + const counts = _.countBy(burst, 'status')
1316 + if (counts[200] !== 1 || counts[429] !== 2)
1317 + throw `unexpected queue limit statuses: ${burst.map(x => x.status)}`
1318 + })
1319 + })
1320 })
1321
1322 function login(usr: string, pwd=password) {
@@ -1440,3 +1529,37 @@ async function curlWithStatus(cmd: string) {
1529 throw "missing status in curl output"
1530 return { status: Number(out.slice(idx + 8)), body: out.slice(0, idx) }
1531 }
1532 +
1533 +async function withPluginConfig(id: string, config: object, cb: () => Promise<void>) {
1534 + const prev = await reqApi('get_plugin', { id }, res => res?.config && 'enabled' in res, { auth })()
1535 + // force a deterministic plugin lifecycle to avoid races where set_plugin returns before plugin init is completed
1536 + await reqApi('stop_plugin', { id }, 200, { auth })()
1537 + await reqApi('set_plugin', { id, enabled: false, config }, 200, { auth })()
1538 + await reqApi('start_plugin', { id }, 200, { auth })()
1539 + try {
1540 + await cb()
1541 + }
1542 + finally {
1543 + await reqApi('stop_plugin', { id }, 200, { auth })()
1544 + await reqApi('set_plugin', { id, enabled: false, config: prev.config }, 200, { auth })()
1545 + if (prev.enabled)
1546 + await reqApi('start_plugin', { id }, 200, { auth })()
1547 + }
1548 +}
1549 +
1550 +async function reqBasicAuth(url: string, credentials: string) {
1551 + const authorization = 'Basic ' + Buffer.from(credentials).toString('base64')
1552 + const response = await httpStream(defaultBaseUrl + url, {
1553 + path: url,
1554 + httpThrow: false,
1555 + jar: {},
1556 + headers: { authorization },
1557 + })
1558 + await stream2string(response).catch(() => '')
1559 + const rawDelay = response.headers?.['x-anti-brute-force']
1560 + const delayValue = Array.isArray(rawDelay) ? rawDelay[0] : rawDelay
1561 + return {
1562 + status: response.statusCode,
1563 + delay: Number(delayValue) || 0,
1564 + }
1565 +}