@samitouri / QOSami-HFS / commits / ac07a062

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