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', {