fix: path traversal with ?lang parameter CVE-2026-61505
Massimo Melina committed
Jul 11, 2026 at 11:47 UTC
10f4c9942e194ac779a0aa630051406ed5c89af1
3 files changed
+21
-4
src/api.lang.ts
+2
-2
@@ -6,7 +6,7 @@ import glob from 'fast-glob'
6
import { readFile, rm, writeFile } from 'fs/promises'
7
import { HTTP_BAD_REQUEST, HTTP_NOT_ACCEPTABLE, HTTP_SERVER_ERROR } from './const'
8
import { apiAssertTypes, tryJson } from './misc'
9
-import { code2file, file2code } from './lang'
9
+import { code2file, file2code, normalizeLangCode } from './lang'
10
import EMBEDDED_TRANSLATIONS from './langs/embedded'
11
import { SendListReadable } from './SendList'
12
@@ -61,6 +61,6 @@ const apis: ApiHandlers = {
61
export default apis
62
63
function validateCode(code: string) {
64
- if (!/^(\w\w)(-\w\w)*$/.test(code))
64
+ if (normalizeLangCode(code) !== code.toLowerCase())
65
throw new ApiError(HTTP_BAD_REQUEST, 'bad code/filename')
66
}
src/lang.ts
+6
-2
@@ -13,6 +13,11 @@ import _ from 'lodash'
13
const PREFIX = 'hfs-lang-'
14
const SUFFIX = '.json'
15
16
+export function normalizeLangCode(code: string) {
17
+ code = code.toLowerCase().split(';', 1)[0]!.trim()
18
+ return /^[a-z]{2,3}(-[a-z0-9]{2,8})*$/.test(code) ? code : ''
19
+}
20
+
21
export function code2file(code: string) {
22
return PREFIX + code.toLowerCase() + SUFFIX
23
}
@@ -32,7 +37,7 @@ export async function getLangData(ctxOrLangCsv: Koa.Context | string) {
37
}
38
const csv = ctxOrLangCsv.toLowerCase()
39
return cache.try(csv, async () => {
35
- const langs = csv.split(',')
40
+ const langs = csv.split(',').map(normalizeLangCode).filter(Boolean)
41
const ret: Dict = {}
42
let i = 0
43
while (i < langs.length) {
@@ -77,4 +82,3 @@ defineConfig(CFG.force_lang, '', v => {
82
})
83
undo = res.unwatch
84
})
80
-
tests/test.ts
+13
@@ -142,6 +142,19 @@ describe('basics', () => {
142
test('traversal.backslash', req('/f1/page/..%5c..%5cREADME.md', 404))
143
test('traversal.to-admin', req('/f1/page/%2e%2e/%2e%2e/for-admins/alfa.txt', 404))
144
test('traversal.mixed-dots', req('/f1/page/.%2e/%2e./README.md', 404))
145
+ test('traversal.lang', async () => {
146
+ const pathNoExt = 'tmp-secret'
147
+ const fullPath = resolve(__dirname, pathNoExt + '.json')
148
+ await mkdir(dirname(fullPath), { recursive: true })
149
+ try {
150
+ const marker = 'TRAVERSAL_READ'
151
+ await writeFile(fullPath, JSON.stringify({ translate: { 'Not found': marker } })) // translating Not found exposes the read through the fallback 404 page when GUI assets are absent
152
+ await req('/?lang=x/../../' + pathNoExt, data => !String(data).includes(marker), {
153
+ headers: { 'user-agent': 'Mozilla/5.0' },
154
+ })()
155
+ }
156
+ finally { await rmAny(fullPath) }
157
+ })
158
test('traversal.overlong-utf8', req('/f1/page/%c0%ae%c0%ae/%c0%ae%c0%ae/README.md', 404))
159
test('bad url encoding', req('/f1/%E0%A4%A', 404))
160
test('not-found.default page', req('/missing-default-404', /found<\/h1>/))