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) {