main
js 187 lines 7.28 KB
Raw
1 exports.version = 3.2
2 exports.description = "Introduce increasing delays between login attempts."
3 exports.apiRequired = 9.6 // addBlock
4
5 exports.config = {
6 increment: { type: 'number', min: 1, defaultValue: 5, unit: "seconds", helperText: "How longer user must wait for each login attempt" },
7 max: { type: 'number', min: 1, defaultValue: 60, label: "Max delay", unit: "seconds", helperText: "Max seconds to delay before next login is allowed" },
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({
32 async attemptingLogin({ ctx, username }) {
33 const { ip } = ctx
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 }
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 }
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) {
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
181 try { return netMatches(ip, mask) }
182 catch (e) {
183 api.log("bad exclude mask:", String(e))
184 return false
185 }
186 }
187 }