fix: admin/monitoring: UI can crash with malformed upload
Massimo Melina committed
Jan 23, 2026 at 00:52 UTC
3da0e17c1c5ffdf2f206d759a77498afaa45eca0
2 files changed
+45
-2
src/api.monitor.ts
+2
-2
@@ -72,8 +72,8 @@ export default {
72
user: getCurrentUsername(ctx),
73
agent: shortenAgent(ctx.get('user-agent')),
74
archive: s.archive,
75
- ...s.browsing ? { op: 'browsing', path: decodeURIComponent(s.browsing) }
76
- : s.uploadPath ? { op: 'upload', path: decodeURIComponent(s.uploadPath) }
75
+ ...s.browsing ? { op: 'browsing', path: safeDecodeURIComponent(s.browsing) }
76
+ : s.uploadPath ? { op: 'upload', path: safeDecodeURIComponent(s.uploadPath) }
77
: {
78
op: !s.considerAsGui && (ctx.state.archive || ctx.state.vfsNode) ? 'download' : undefined,
79
path: safeDecodeURIComponent(ctx.originalUrl),
tests/test.ts
+43
@@ -459,6 +459,20 @@ describe('admin', () => {
459
})
460
test('plugins.missing', reqApi('set_plugin', { id: 'missing-plugin', enabled: true }, { status: 400, re: /miss/ }, { auth }))
461
test('plugins.update.missing', reqApi('update_plugin', { id: 'missing-plugin' }, 404, { auth }))
462
+ test('monitor.connections safe path decode', async () => {
463
+ const body = makeReadableThatTakes(1000)
464
+ const size = body.length
465
+ const rawName = '%2'
466
+ const uploadPromise = reqUpload(`${UPLOAD_ROOT}${pathEncode(rawName)}`, () => true, body, size)()
467
+ try {
468
+ await wait(200)
469
+ const res = await readEventStreamOnce(`${API}get_connections`, { auth })
470
+ await uploadPromise
471
+ if (res.status !== 200)
472
+ throw `unexpected status ${res.status}`
473
+ }
474
+ finally { await rmAny(resolve(__dirname, rawName)) }
475
+ })
476
test('plugins.start_stop', async () => {
477
const id = 'download-counter'
478
await reqApi('stop_plugin', { id }, 200, { auth })()
@@ -592,6 +606,35 @@ function req(url: string, test:Tester, { baseUrl, throttle, ...requestOptions }:
606
}
607
}
608
609
+async function readEventStreamOnce(url: string, { baseUrl, ...requestOptions }: XRequestOptions & { baseUrl?: string }={}) {
610
+ const res = await httpStream((baseUrl || defaultBaseUrl) + url, {
611
+ path: url,
612
+ httpThrow: false,
613
+ headers: { accept: 'text/event-stream', ...requestOptions.headers },
614
+ ...requestOptions,
615
+ })
616
+ const data = await new Promise<string>((resolve, reject) => {
617
+ const timer = setTimeout(() => {
618
+ res.destroy()
619
+ reject(new Error('event stream timeout'))
620
+ }, 2000)
621
+ res.once('data', chunk => {
622
+ clearTimeout(timer)
623
+ resolve(String(chunk))
624
+ res.destroy()
625
+ })
626
+ res.once('end', () => {
627
+ clearTimeout(timer)
628
+ resolve('')
629
+ })
630
+ res.once('error', err => {
631
+ clearTimeout(timer)
632
+ reject(err)
633
+ })
634
+ })
635
+ return { status: res.statusCode, data }
636
+}
637
+
638
function reqApi(api: string, params: object, test:Tester, options:any={}) {
639
const isGet = api.startsWith('/')
640
return req(API+api, test, {