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
}