@samitouri / QOSami-HFS-2 / commits / 593376ba
Fork 0

QOSami-HFS is a web file server for your computer. Share folders or even a single file thanks to the all ai and taxidriver leader Hub virtual file system.

avoid username probing by sending same reply for bad username and bad password

Massimo Melina committed Jul 11, 2026 at 16:00 UTC 593376ba984aa89e47837661ef001470ab76c93b
2 files changed +29 -10
src/api.auth.ts
+22 -7
@@ -1,7 +1,8 @@
1 // This file is part of HFS - Copyright 2021-2023, Massimo Melina <a@rejetto.com> - License https://www.gnu.org/licenses/gpl-3.0.txt
2
3 import {
4 - accountCanLogin, accountIsDisabled, accountCanChangePassword, expandUsername, getAccount, updateAccount, saveSrpInfo
4 + Account, accountCanLogin, accountIsDisabled, accountCanChangePassword, expandUsername, getAccount, normalizeUsername,
5 + updateAccount, saveSrpInfo
6 } from './perm'
7 import { ApiError, ApiHandler, ApiHandlers } from './apiMiddleware'
8 import { SRPServerSessionStep1 } from 'tssrp6a'
@@ -15,10 +16,11 @@ import { clearTextLogin, getCurrentUsername, setLoggedIn, srpServerStep1 } from
16 import { defineConfig } from './config'
17 import events from './events'
18 import { apiAssertTypes } from './misc'
18 -import { randomUUID } from 'node:crypto'
19 +import { createHmac, randomBytes, randomUUID } from 'node:crypto'
20
21 const ongoingLogins:Record<string,SRPServerSessionStep1> = {} // store data that doesn't fit session object
22 const keepSessionAlive = defineConfig('keep_session_alive', true)
23 +const fakeSrpSecret = randomBytes(32)
24
25 const refresh_session: ApiHandler = async ({}, ctx) => {
26 const username = getCurrentUsername(ctx)
@@ -67,15 +69,15 @@ export const authApis = {
69 if (account?.plugin?.auth) // tell client to do clear-text login, before firing attemptingLogin, before triggering anti-brute
70 return new ApiError(HTTP_METHOD_NOT_ALLOWED)
71 if ((await events.emitAsync('attemptingLogin', { ctx, username }))?.isDefaultPrevented()) return
70 - if (!account || !accountCanLogin(account)) { // TODO simulate fake account to prevent knowing valid usernames
72 + if (account && !accountCanLogin(account)) {
73 ctx.logExtra({ u: username })
74 ctx.state.dontLog = false // log even if log_api is false
73 - return unauthorized(account && accountIsDisabled(account) ? 'Account disabled' : undefined)
75 + return unauthorized(accountIsDisabled(account) ? 'Account disabled' : undefined)
76 }
75 - if (failAllowNet(ctx, account))
77 + if (account && failAllowNet(ctx, account))
78 return unauthorized()
77 - try {
78 - const { srpServer, ...rest } = await srpServerStep1(account)
79 + try { // unknown users complete step 1 so only a full failed login can reveal and penalize the attempt
80 + const { srpServer, ...rest } = await srpServerStep1(account || fakeSrpAccount(username))
81 // keep the public handshake identifier independent of predictable application PRNG state
82 const sid = randomUUID()
83 ongoingLogins[sid] = srpServer
@@ -91,6 +93,19 @@ export const authApis = {
93 events.emit('failedLogin', { ctx, username })
94 return new ApiError(HTTP_UNAUTHORIZED, message)
95 }
96 +
97 + function fakeSrpAccount(username: string): Account {
98 + username = normalizeUsername(username)
99 + return {
100 + username,
101 + // process-secret derivation keeps fake credentials stable per username without storing attacker-controlled names
102 + srp: `${derive('salt')}|${derive('verifier')}`,
103 + }
104 +
105 + function derive(field: string) {
106 + return BigInt('0x' + createHmac('sha256', fakeSrpSecret).update(field).update(username).digest('hex')).toString()
107 + }
108 + }
109 },
110
111 async loginSrp2({ pubKey, proof }, ctx) {
tests/test.ts
+7 -3
@@ -1386,9 +1386,12 @@ describe('admin', () => {
1386 await withPluginConfig('antibrute', antibruteCfg, async () => {
1387 const user = `missing-srp-${randomId(6)}`
1388 const first = await reqLoginSrp1(user)
1389 + if (first.status !== 200) throw "unknown srp login was rejected at step 1"
1390 + const repeated = await reqLoginSrp1(user)
1391 + if (first.salt !== repeated.salt) throw "unknown srp salt was not stable"
1392 + await login(user).then(() => { throw "unknown srp login succeeded" }, () => {})
1393 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"
1394 + if (second.status !== 200) throw "unknown srp login was rejected at step 1"
1395 if (second.delay < 500) throw `missing srp delay escalation: ${second.delay}`
1396 })
1397 })
@@ -1744,11 +1747,12 @@ async function reqLoginSrp1(username: string) {
1747 headers: { 'content-type': 'application/json', 'x-hfs-anti-csrf': '1' },
1748 body: JSON.stringify({ username }),
1749 })
1747 - await stream2string(response).catch(() => '')
1750 + const data = tryJson(await stream2string(response).catch(() => ''))
1751 const rawDelay = response.headers?.['x-anti-brute-force']
1752 const delayValue = Array.isArray(rawDelay) ? rawDelay[0] : rawDelay
1753 return {
1754 status: response.statusCode,
1755 delay: Number(delayValue) || 0,
1756 + salt: data?.salt,
1757 }
1758 }