@samitouri / QOSami-HFS / commits / 39f603d7

fix: bad encoding in zip

Massimo Melina committed May 14, 2026 at 16:03 UTC 39f603d7ae07d41c283c34d15a7ecd8d361e4efa
4 files changed +11 -5
src/cross.ts
+4
@@ -467,6 +467,10 @@ export function pathEncode(s: string, all=false) {
467 export function pathDecode(s: string) {
468 return decodeURI(s).replace(/%23/g, '#')
469 }
470 +export function pathDecodeSegments(s: string, map: (segment: string) => string = String) {
471 + // decode segment by segment so reserved escapes are decoded without turning encoded slashes into separators
472 + return s.split('/').map(x => map(safeDecodeURIComponent(x)).replaceAll('/', '%2F')).join('/')
473 +}
474
475 // run at a specific point in time, also solving the limit of setTimeout, which doesn't work with +32bit delays
476 export function runAt(ts: number, cb: Callback) {
src/serveGuiAndSharedFiles.ts
+2 -2
@@ -21,7 +21,7 @@ import mount from 'koa-mount'
21 import { baseUrl } from './listen'
22 import {
23 asyncGeneratorToReadable, filterMapGenerator, isValidFileName, loadFileCached, pathEncode, safeDecodeURIComponent,
24 - try_,
24 + try_, pathDecodeSegments,
25 } from './misc'
26 import XXH from 'xxhashjs'
27 import fs from 'fs'
@@ -163,7 +163,7 @@ async function sendFolderList(node: VfsNode, ctx: Koa.Context) {
163 const base = prepend === undefined && baseUrl.get()
164 || URL.protocol + '//' + URL.host + ctx.state.revProxyPath
165 // redo the encoding our way, keeping unicode chars unchanged. decode each segment separately because decodeURI preserves reserved escapes like %3A, which pathEncode would double-encode
166 - prepend = base + ctx.path.split('/').map(x => pathEncode(safeDecodeURIComponent(x)).replaceAll('/', '%2F')).join('/')
166 + prepend = base + pathDecodeSegments(ctx.path, pathEncode)
167 }
168 const walker = walkNode(node, { ctx, depth: depth === '*' ? Infinity : Number(depth), parallelizeRecursion: false }) // parallelization produces out-of-order results, and we don't want it like that here
169 ctx.body = asyncGeneratorToReadable(filterMapGenerator(walker, async el => {
src/zip.ts
+3 -3
@@ -2,7 +2,7 @@
2
3 import { getNodeName, hasPermission, nodeIsFolder, nodeIsLink, urlToNode, VfsNode, walkNode, statusCodeForMissingPerm } from './vfs'
4 import Koa from 'koa'
5 -import { filterMapGenerator, isWindowsDrive, safeDecodeURIComponent, statWithTimeout, wantArray } from './misc'
5 +import { filterMapGenerator, isWindowsDrive, pathDecodeSegments, safeDecodeURIComponent, statWithTimeout, wantArray } from './misc'
6 import { QuickZipStream } from './QuickZipStream'
7 import { createReadStream } from 'fs'
8 import { defineConfig } from './config'
@@ -35,7 +35,7 @@ export async function zipStreamFromFolder(node: VfsNode, ctx: Koa.Context) {
35 if (nodeIsFolder(subNode)) { // a directory needs to be walked
36 if (hasPermission(subNode, 'can_list', ctx) && hasPermission(subNode, 'can_archive', ctx)) {
37 yield subNode // it could be empty
38 - yield* walkNode(subNode, { ctx, prefixPath: decodeURI(uri) + '/', requiredPerm: 'can_archive' })
38 + yield* walkNode(subNode, { ctx, prefixPath: pathDecodeSegments(uri) + '/', requiredPerm: 'can_archive' })
39 }
40 continue
41 }
@@ -92,4 +92,4 @@ declare module "koa" {
92 interface DefaultState {
93 archive?: string
94 }
95 -}
\ No newline at end of file
95 +}
tests/test.ts
+2
@@ -235,6 +235,8 @@ describe('basics', () => {
235 test('zip.partial', req('/f1/?get=zip', { re:/^page$/, length: zipLength }, { headers: { Range: `bytes=${zipOfs}-${zipOfs+zipLength-1}` } }) )
236 test('zip.partial.resume', req('/f1/?get=zip', { re:/^page/, length:zipSize-zipOfs }, { headers: { Range: `bytes=${zipOfs}-` } }) )
237 test('zip.partial.end', req('/f1/f2/?get=zip', { re:/^6/, length:10 }, { headers: { Range: 'bytes=-10' } }) )
238 + test('zip.list.selected folder decodes prefix', req('/tests/?get=zip&list=C%253A', data =>
239 + String(data).includes('C:/gpl.png') && !String(data).includes('C%3A/gpl.png')))
240 test('zip.list.bad encoding', req('/f1/?get=zip&list=%E0%A4%A//%00', { status: 200, length: 22 })) // basically empty
241 test('zip.list.null filename', req('/f1/?get=zip&list=%00', 400)) // tries to name the output with null-byte
242 test('zip.masked deep', req('/cantSearchForMasksDeep/?get=zip', {