fix: a failure of the login could miss updating the session preventing further login attempts
Massimo Melina committed
Feb 9, 2026 at 10:31 UTC
9f04c08fbe7c3c06cf7fb9823c724fd443a85a88
6 files changed
+46
-14
src/auth.ts
+2
@@ -57,8 +57,10 @@ export async function setLoggedIn(ctx: Context, username: string | false) {
57
const s = ctx.session
58
if (!s)
59
return ctx.throw(HTTP_SERVER_ERROR,'session')
60
+ delete ctx.state.usernames
61
if (username === false) {
62
events.emit('logout', ctx)
63
+ delete ctx.state.account
64
delete s.username
65
delete s.allowNet
66
return
src/middlewares.ts
+4
-3
@@ -8,7 +8,7 @@ import { Readable } from 'stream'
8
import { applyBlock } from './block'
9
import { Account, accountCanLogin, getAccount, getFromAccount } from './perm'
10
import { Connection, normalizeIp, socket2connection, updateConnectionForCtx } from './connections'
11
-import { clearTextLogin, invalidateSessionBefore } from './auth'
11
+import { clearTextLogin, invalidateSessionBefore, setLoggedIn } from './auth'
12
import { constants } from 'zlib'
13
import { getHttpsWorkingPort } from './listen'
14
import { defineConfig } from './config'
@@ -82,7 +82,7 @@ export const someSecurity: Koa.Middleware = (ctx, next) => {
82
83
// limited to http proxies
84
export function getProxyDetected() {
85
- if (proxyDetected?.state.whenProxyDetected < Date.now() - DAY) // detection is reset after a day
85
+ if (Number(proxyDetected?.state.whenProxyDetected) < Date.now() - DAY) // detection is reset after a day
86
proxyDetected = undefined
87
return proxyDetected && { from: proxyDetected.socket.remoteAddress, for: proxyDetected.get('X-Forwarded-For') }
88
}
@@ -97,7 +97,7 @@ export const prepareState: Koa.Middleware = async (ctx, next) => {
97
ctx.state.connection = socket2connection(ctx.socket)!
98
const a = ctx.state.account = await urlLogin() || await getHttpAccount() || getAccount(ctx.session?.username, false)
99
if (a && (!accountCanLogin(a) || failAllowNet(ctx, a))) // enforce allow_net also after login
100
- ctx.state.account = undefined
100
+ await setLoggedIn(ctx, false)
101
ctx.state.revProxyPath = ctx.get('x-forwarded-prefix')
102
updateConnectionForCtx(ctx)
103
await next()
@@ -139,6 +139,7 @@ declare module "koa" {
139
account?: Account // user logged in
140
revProxyPath: string // must not have final slash
141
connection: Connection
142
+ whenProxyDetected?: Date
143
}
144
}
145
export const paramsDecoder: Koa.Middleware = async (ctx, next) => {
src/perm.ts
+6
@@ -223,4 +223,10 @@ export async function changeSrpHelper(account: Account, salt: string, verifier:
223
await updateAccount(account, account =>
224
saveSrpInfo(account, salt, verifier) )
225
return {}
226
+}
227
+
228
+declare module "koa" {
229
+ interface DefaultState {
230
+ usernames?: string[]
231
+ }
232
}
\ No newline at end of file
src/serveFile.ts
+5
-7
@@ -1,7 +1,7 @@
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 Koa from 'koa'
4
-import { createReadStream, stat } from 'fs'
4
+import { createReadStream, stat, Stats } from 'fs'
5
import { HTTP_BAD_REQUEST, HTTP_FORBIDDEN, HTTP_METHOD_NOT_ALLOWED, HTTP_NO_CONTENT, HTTP_NOT_FOUND, HTTP_NOT_MODIFIED,
6
HTTP_OK, HTTP_PARTIAL_CONTENT, HTTP_RANGE_NOT_SATISFIABLE, HTTP_TOO_MANY_REQUESTS, MIME_AUTO } from './const'
7
import { getNodeName, VfsNode } from './vfs'
@@ -130,6 +130,10 @@ declare module "koa" {
130
opProgress?: number
131
opTotal?: number
132
opOffset?: number
133
+ vfsNode?: VfsNode
134
+ includesLastByte?: boolean
135
+ fileSource?: string
136
+ fileStats?: Stats
137
}
138
}
139
@@ -167,12 +171,6 @@ export function applyRange(ctx: Koa.Context, totalSize=ctx.response.length) {
171
return { start, end }
172
}
173
170
-declare module "koa" {
171
- interface DefaultState {
172
- vfsNode?: VfsNode
173
- includesLastByte?: boolean
174
- }
175
-}
174
function downloadLimiter<T>(configMax: { get: () => number | undefined }, cbKey: (ctx: Koa.Context) => T | undefined) {
175
const map = new Map<T, number>()
176
return (ctx: Koa.Context) => {
tests/config.yaml
+4
@@ -34,6 +34,10 @@ vfs:
34
- name: page
35
source: ../page
36
default: index.html
37
+ - name: protected
38
+ source: ../page/gpl.png
39
+ can_read:
40
+ - rejetto
41
- name: for-admins
42
can_read:
43
- admins
tests/test.ts
+25
-4
@@ -243,12 +243,31 @@ describe('sessions', () => {
243
return login(username).then(() => { throw "in" }, () => {})
244
.finally(() => defaultBaseUrl = BASE_URL)
245
})
246
+ test('allow_net.cantLogin.url', reqList('protected', 401, {}, { baseUrl: BASE_URL_127, auth }))
247
test('httpStream.jar isolates host cookies', async () => {
248
const jar = {}
249
await reqApi('loginSrp1', { username }, res => Boolean(res?.salt && res?.pubKey), { jar })()
250
await reqApi('loginSrp2', { pubKey: '1', proof: '1' }, 409, { baseUrl: BASE_URL_127, jar })()
251
await reqApi('loginSrp2', { pubKey: '1', proof: '1' }, 401, { jar })()
252
})
253
+ test('allow_net.recovers after restriction is removed', async () => {
254
+ const user = `allow-net-${randomId(6)}`.toLowerCase()
255
+ const pwd = `pw-${randomId(8)}`
256
+ const userAuth = `${user}:${pwd}`
257
+ const userJar = {}
258
+ const adminReq = { auth, jar: {} }
259
+ try {
260
+ await reqApi('add_account', { username: user, overwrite: true, password: pwd }, res => res?.username === user, adminReq)()
261
+ await reqApi('refresh_session', {}, res => res?.username === user, { jar: userJar, auth: userAuth })()
262
+ await reqApi('set_account', { username: user, changes: { allow_net: '127.0.0.1' } }, 200, adminReq)() // block
263
+ await reqApi('refresh_session', {}, res => !res?.username, { jar: userJar })() // kicked out
264
+ await reqApi('set_account', { username: user, changes: { allow_net: '' } }, 200, adminReq)() // re-enable
265
+ await reqApi('refresh_session', {}, res => res?.username === user, { jar: userJar, auth: userAuth })()
266
+ }
267
+ finally {
268
+ await reqApi('del_account', { username: user }, 200, adminReq)().catch(() => {})
269
+ }
270
+ })
271
})
272
273
describe('accounts', () => {
@@ -650,9 +669,11 @@ type Tester = number
669
cb?: TesterFunction
670
}
671
672
+type ReqOptions = XRequestOptions & { throttle?: number, baseUrl?: string }
673
+
674
const jar = {}
675
655
-function req(url: string, test:Tester, { baseUrl, throttle, ...requestOptions }: XRequestOptions & { throttle?: number, baseUrl?: string }={}) {
676
+function req(url: string, test:Tester, { baseUrl, throttle, ...requestOptions }: ReqOptions={}) {
677
// passing 'path' keeps it as it is, avoiding internal resolving
678
let abortable // copy abortable interface to returned promise
679
return () => Object.assign(
@@ -738,7 +759,7 @@ async function readEventStreamOnce(url: string, { baseUrl, ...requestOptions }:
759
return { status: res.statusCode, data }
760
}
761
741
-function reqApi(api: string, params: object, test:Tester, options:any={}) {
762
+function reqApi(api: string, params: object, test:Tester, options?: ReqOptions) {
763
const isGet = api.startsWith('/')
764
return req(API+api, test, {
765
body: JSON.stringify(params),
@@ -747,8 +768,8 @@ function reqApi(api: string, params: object, test:Tester, options:any={}) {
768
})
769
}
770
750
-function reqList(uri:string, tester:Tester, params?: object) {
751
- return reqApi('get_file_list', { uri, ...params }, tester)
771
+function reqList(uri:string, tester:Tester, params?: object, options?: ReqOptions) {
772
+ return reqApi('get_file_list', { uri, ...params }, tester, options)
773
}
774
775
function isInList(res:any, name:string) {