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