fix: bad encoding in zip

Massimo Melina committed May 14, 2026 at 16:03 UTC 3b11bb94b4102abcf846cddae851d556948c45fb
4 files changed +11 -5
src/cross.ts
+4
@@ -486,6 +486,10 @@ export function pathEncode(s: string, all=false) {
486 export function pathDecode(s: string) {
487 return decodeURI(s).replace(/%23/g, '#')
488 }
489 +export function pathDecodeSegments(s: string, map: (segment: string) => string = String) {
490 + // decode segment by segment so reserved escapes are decoded without turning encoded slashes into separators
491 + return s.split('/').map(x => map(safeDecodeURIComponent(x)).replaceAll('/', '%2F')).join('/')
492 +}
493
494 // run at a specific point in time, also solving the limit of setTimeout, which doesn't work with +32bit delays
495 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, deleteStoredFileAttrs, filterMapGenerator, isValidFileName, loadFileCached, pathEncode,
24 - safeDecodeURIComponent, try_,
24 + pathDecodeSegments, safeDecodeURIComponent, try_,
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'
@@ -36,7 +36,7 @@ export async function zipStreamFromFolder(node: VfsNode, ctx: Koa.Context) {
36 if (nodeIsFolder(subNode)) { // a directory needs to be walked
37 if (hasPermission(subNode, 'can_list', ctx) && hasPermission(subNode, 'can_archive', ctx)) {
38 yield subNode // it could be empty
39 - yield* walkNode(subNode, { ctx, prefixPath: decodeURI(uri) + '/', requiredPerm: 'can_archive' })
39 + yield* walkNode(subNode, { ctx, prefixPath: pathDecodeSegments(uri) + '/', requiredPerm: 'can_archive' })
40 }
41 continue
42 }
@@ -93,4 +93,4 @@ declare module "koa" {
93 interface DefaultState {
94 archive?: string
95 }
96 -}
\ No newline at end of file
96 +}
tests/test.ts
+2
@@ -241,6 +241,8 @@ describe('basics', () => {
241 test('zip.partial.resume', req('/f1/?get=zip', { re:/^page/, length:zipSize-zipOfs }, { headers: { Range: `bytes=${zipOfs}-` } }) )
242 test('zip.partial.end', req('/f1/f2/?get=zip', { re:/^6/, length:10 }, { headers: { Range: 'bytes=-10' } }) )
243 test('zip.list.compacted folders', req('/f1/?get=zip&list=page%2Fgpl.png%2F%2F%00index.html', /page\/gpl.png.+page\/index.html/))
244 + test('zip.list.selected folder decodes prefix', req('/tests/?get=zip&list=C%253A', data =>
245 + String(data).includes('C:/gpl.png') && !String(data).includes('C%3A/gpl.png')))
246 test('zip.list.bad encoding', req('/f1/?get=zip&list=%E0%A4%A//%00', { status: 200, length: 22 })) // basically empty
247 test('zip.list.null filename', req('/f1/?get=zip&list=%00', 400)) // tries to name the output with null-byte
248 test('zip.masked deep', req('/cantSearchForMasksDeep/?get=zip', {