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
+}