fix: plugins: event failedLogin was missing for bad usernames CVE-2026-61503
Massimo Melina committed
Jul 11, 2026 at 15:24 UTC
ac07a062ca85ba3bf6b464a1ccd05291c7495d98
2 files changed
+45
-2
src/api.auth.ts
+7
-2
@@ -70,10 +70,10 @@ export const authApis = {
70
if (!account || !accountCanLogin(account)) { // TODO simulate fake account to prevent knowing valid usernames
71
ctx.logExtra({ u: username })
72
ctx.state.dontLog = false // log even if log_api is false
73
- return new ApiError(HTTP_UNAUTHORIZED, account && accountIsDisabled(account) ? 'Account disabled' : undefined)
73
+ return unauthorized(account && accountIsDisabled(account) ? 'Account disabled' : undefined)
74
}
75
if (failAllowNet(ctx, account))
76
- return new ApiError(HTTP_UNAUTHORIZED)
76
+ return unauthorized()
77
try {
78
const { srpServer, ...rest } = await srpServerStep1(account)
79
// keep the public handshake identifier independent of predictable application PRNG state
@@ -86,6 +86,11 @@ export const authApis = {
86
catch (code: any) {
87
return new ApiError(code)
88
}
89
+
90
+ function unauthorized(message?: string) {
91
+ events.emit('failedLogin', { ctx, username })
92
+ return new ApiError(HTTP_UNAUTHORIZED, message)
93
+ }
94
},
95
96
async loginSrp2({ pubKey, proof }, ctx) {
tests/test.ts
+38
@@ -1382,6 +1382,26 @@ describe('admin', () => {
1382
if (second.delay < 500) throw `missing delay escalation: ${second.delay}`
1383
})
1384
})
1385
+ test('antibrute.failed loginSrp1 escalates delay', async () => {
1386
+ await withPluginConfig('antibrute', antibruteCfg, async () => {
1387
+ const user = `missing-srp-${randomId(6)}`
1388
+ const first = await reqLoginSrp1(user)
1389
+ const second = await reqLoginSrp1(user)
1390
+ if (first.status !== 401) throw "first unknown srp login was not rejected"
1391
+ if (second.status !== 401) throw "second unknown srp login was not rejected"
1392
+ if (second.delay < 500) throw `missing srp delay escalation: ${second.delay}`
1393
+ })
1394
+ })
1395
+ test('antibrute.valid loginSrp1 does not count as failed login', async () => {
1396
+ await withPluginConfig('antibrute', antibruteCfg, async () => {
1397
+ const first = await reqLoginSrp1(username)
1398
+ const second = await reqBasicAuth('/for-admins/', `${username}:wrong-password`)
1399
+ if (first.status !== 200) throw "valid srp step1 was rejected"
1400
+ if (first.delay !== 0) throw `valid srp step1 was delayed: ${first.delay}`
1401
+ if (second.status !== 401) throw "wrong login was not rejected"
1402
+ if (second.delay !== 0) throw `valid srp step1 was counted as failed login: ${second.delay}`
1403
+ })
1404
+ })
1405
test('antibrute.successful login resets penalty', async () => {
1406
await withPluginConfig('antibrute', antibruteCfg, async () => {
1407
await reqBasicAuth('/for-admins/', `${username}:wrong-password`)
@@ -1714,3 +1734,21 @@ async function reqBasicAuth(url: string, credentials: string) {
1734
delay: Number(delayValue) || 0,
1735
}
1736
}
1737
+
1738
+async function reqLoginSrp1(username: string) {
1739
+ const response = await httpStream(defaultBaseUrl + API + 'loginSrp1', {
1740
+ path: API + 'loginSrp1',
1741
+ method: 'POST',
1742
+ httpThrow: false,
1743
+ jar: {},
1744
+ headers: { 'content-type': 'application/json', 'x-hfs-anti-csrf': '1' },
1745
+ body: JSON.stringify({ username }),
1746
+ })
1747
+ await stream2string(response).catch(() => '')
1748
+ const rawDelay = response.headers?.['x-anti-brute-force']
1749
+ const delayValue = Array.isArray(rawDelay) ? rawDelay[0] : rawDelay
1750
+ return {
1751
+ status: response.statusCode,
1752
+ delay: Number(delayValue) || 0,
1753
+ }
1754
+}