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