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