diff --git a/build/panel.js b/build/panel.js index 4198713f1..73ab39ec1 100644 --- a/build/panel.js +++ b/build/panel.js @@ -74,7 +74,14 @@ export function panelBuildPlugin({ outDir = PANEL_OUT_DIR } = {}) { const { readFile } = await import('node:fs/promises'); const { extname, resolve, sep } = await import('node:path'); const root = resolve(server.config.root, outDir); - const relative = decodeURIComponent(pathname.slice(PANEL_BASE.length)); + let relative; + try { + relative = decodeURIComponent(pathname.slice(PANEL_BASE.length)); + } catch { + res.statusCode = 400; + res.end('Bad path'); + return; + } const file = resolve(root, relative || 'index.html'); if (file !== root && !file.startsWith(root + sep)) return next(); try { diff --git a/src/app/globePanelRuntime.js b/src/app/globePanelRuntime.js index 2263686a4..cc93ba524 100644 --- a/src/app/globePanelRuntime.js +++ b/src/app/globePanelRuntime.js @@ -110,7 +110,10 @@ export function panelRuntime(config) { headers: part.headers, }; })(); - if (shared) held.set(path, loading); + if (shared) { + held.set(path, loading); + loading.catch(() => held.delete(path)); + } const result = loading.then(respond); if (!init.signal) return result; return Promise.race([ @@ -152,25 +155,28 @@ export function panelRuntime(config) { const fileUrls = new Map(); /** * A data: URL holding an app file, for elements that load by URL. Hosts - * allow data: images where some refuse blob: ones. + * allow data: images where some refuse blob: ones. Files of the panel + * build never change and are kept; others, such as live camera frames + * that change address on every refresh, are not. */ function appFileUrl(path) { - if (!fileUrls.has(path)) - fileUrls.set( - path, - appFetch(path) - .then((response) => response.blob()) - .then( - (blob) => - new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onload = () => resolve(reader.result); - reader.onerror = () => reject(reader.error); - reader.readAsDataURL(blob); - }), - ), + if (fileUrls.has(path)) return fileUrls.get(path); + const loading = appFetch(path) + .then((response) => response.blob()) + .then( + (blob) => + new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result); + reader.onerror = () => reject(reader.error); + reader.readAsDataURL(blob); + }), ); - return fileUrls.get(path); + if (path.startsWith(config.panelBase)) { + fileUrls.set(path, loading); + loading.catch(() => fileUrls.delete(path)); + } + return loading; } function installRequests() { diff --git a/src/tooling/panelBuild.test.mjs b/src/tooling/panelBuild.test.mjs index a7a2ab15b..830be7e95 100644 --- a/src/tooling/panelBuild.test.mjs +++ b/src/tooling/panelBuild.test.mjs @@ -74,6 +74,7 @@ test('the dev server serves the panel build and nothing outside it', async () => const script = await get('/panel/assets/a.js'); assert.equal(script.headers['Content-Type'], 'text/javascript'); assert.equal((await get('/panel/missing.js')).status, 404); + assert.equal((await get('/panel/%ZZ')).status, 400); assert.deepEqual(await get('/panel/..%2Fsecret.txt'), { passed: true }); assert.deepEqual(await get('/other'), { passed: true }); } finally { diff --git a/src/tools/queries/panelRequest.js b/src/tools/queries/panelRequest.js index b4d280dce..22066c233 100644 --- a/src/tools/queries/panelRequest.js +++ b/src/tools/queries/panelRequest.js @@ -13,6 +13,9 @@ import { PANEL_REQUEST_TOOL } from '../globePanel.js'; export const PANEL_PART_BYTES = 512 * 1024; const HELD_MS = 2 * 60 * 1000; const HELD_LIMIT_BYTES = 256 * 1024 * 1024; +/** The largest response the panel may load, read up to this and no more. */ +export const PANEL_RESPONSE_LIMIT_BYTES = 64 * 1024 * 1024; +const REQUEST_TIMEOUT_MS = 60 * 1000; const METHODS = new Set(['GET', 'HEAD', 'POST']); // Provider Settings write the app's keys; only the app's own page may. const REFUSED_PATHS = [/^\/api\/setup(?:\/|$)/]; @@ -52,12 +55,51 @@ async function gzip(bytes) { return new Uint8Array(await new Response(stream).arrayBuffer()); } -function forget(now = Date.now()) { - for (const [id, entry] of held) { - if (entry.expires > now && heldBytes <= HELD_LIMIT_BYTES) continue; - held.delete(id); - heldBytes -= entry.bytes.length; +function release(id) { + heldBytes -= held.get(id).bytes.length; + held.delete(id); +} + +/** Drop held responses nobody continued in time. */ +function forgetExpired(now = Date.now()) { + for (const [id, entry] of held) if (entry.expires <= now) release(id); +} + +/** Hold a response for its later parts, first making room for it. */ +function hold(id, entry) { + forgetExpired(); + // Oldest first; a response being read is renewed, so it is the newest. + for (const oldest of held.keys()) { + if (heldBytes + entry.bytes.length <= HELD_LIMIT_BYTES) break; + release(oldest); } + held.set(id, entry); + heldBytes += entry.bytes.length; +} + +/** Read a response body, refusing one larger than the panel may load. */ +async function readLimited(answer) { + const reader = answer.body?.getReader(); + if (!reader) return new Uint8Array(0); + const chunks = []; + let total = 0; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + total += value.length; + if (total > PANEL_RESPONSE_LIMIT_BYTES) { + await reader.cancel(); + throw new ToolError('unavailable', 'The response is too large to load'); + } + chunks.push(value); + } + const bytes = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.length; + } + return bytes; } function part(response, bytes, offset) { @@ -70,17 +112,41 @@ function part(response, bytes, offset) { }; } -function checkedPath(path) { +/** + * The path to request, as the URL parser reads it against the app's + * address. Anything that leaves the app's server is refused, including + * paths parsers read as another host: two slashes, or a slash and a + * backslash. Refused routes are matched decoded too, as servers route. + */ +function checkedPath(path, baseUrl) { + if (typeof path !== 'string' || !path.startsWith('/')) + throw new ToolError('invalid_arguments', 'path must start with /'); + const base = new URL(baseUrl); + let url; + try { + url = new URL(path, base); + } catch { + throw new ToolError('invalid_arguments', 'path is not a valid path'); + } + if (url.origin !== base.origin) + throw new ToolError( + 'invalid_arguments', + "path must stay on the app's server", + ); + const { pathname } = url; + let decoded; + try { + decoded = decodeURIComponent(pathname); + } catch { + throw new ToolError('invalid_arguments', 'path is not a valid path'); + } if ( - typeof path !== 'string' || - !path.startsWith('/') || - path.startsWith('//') + REFUSED_PATHS.some( + (pattern) => pattern.test(pathname) || pattern.test(decoded), + ) ) - throw new ToolError('invalid_arguments', 'path must start with one /'); - const { pathname } = new URL(path, 'http://app.invalid'); - if (REFUSED_PATHS.some((pattern) => pattern.test(pathname))) throw new ToolError('invalid_arguments', `${pathname} is not available`); - return path; + return pathname + url.search; } export const panelRequest = defineTool({ @@ -113,9 +179,14 @@ export const panelRequest = defineTool({ requires: ['app'], ui: { visibility: ['app'] }, async run(args, { services, signal }) { - forget(); + forgetExpired(); if (args.id !== undefined) { const entry = held.get(args.id); + if (entry) { + // Still being read: renew it, and keep it newest. + held.delete(args.id); + held.set(args.id, { ...entry, expires: Date.now() + HELD_MS }); + } if (!entry) throw new ToolError( 'invalid_arguments', @@ -126,7 +197,7 @@ export const panelRequest = defineTool({ data: part(entry.response, entry.bytes, args.offset ?? 0), }; } - const path = checkedPath(args.path); + const path = checkedPath(args.path, services.app.baseUrl); const method = args.method ?? 'GET'; if (!METHODS.has(method)) throw new ToolError('invalid_arguments', `Unsupported method ${method}`); @@ -135,7 +206,9 @@ export const panelRequest = defineTool({ if (FORWARDED_REQUEST_HEADERS.includes(name.toLowerCase())) headers[name] = String(value); } + const deadline = AbortSignal.timeout(REQUEST_TIMEOUT_MS); let answer; + let bytes; try { answer = await services.app.fetch(path, { method, @@ -143,13 +216,16 @@ export const panelRequest = defineTool({ ...(args.body !== undefined && method === 'POST' ? { body: fromBase64(args.body) } : {}), - signal, + // A redirect could lead off the app's server; the panel gets it as is. + redirect: 'manual', + signal: signal ? AbortSignal.any([signal, deadline]) : deadline, }); + bytes = await readLimited(answer); } catch (error) { if (signal?.aborted) throw error; + if (error instanceof ToolError) throw error; throw new ToolError('unavailable', "The app's server did not answer"); } - let bytes = new Uint8Array(await answer.arrayBuffer()); const type = answer.headers.get('content-type') || ''; let encoding = 'identity'; if (bytes.length >= COMPRESS_MIN_BYTES && !INCOMPRESSIBLE.test(type)) { @@ -169,13 +245,12 @@ export const panelRequest = defineTool({ }; if (bytes.length > PANEL_PART_BYTES) { response.id = crypto.randomUUID(); - held.set(response.id, { + hold(response.id, { path, response, bytes, expires: Date.now() + HELD_MS, }); - heldBytes += bytes.length; } return { summary: `${answer.status} ${method} ${path}`, diff --git a/src/tools/queries/panelRequest.test.mjs b/src/tools/queries/panelRequest.test.mjs index b95613f8e..ac5177a68 100644 --- a/src/tools/queries/panelRequest.test.mjs +++ b/src/tools/queries/panelRequest.test.mjs @@ -1,7 +1,10 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { composeCatalog, coreTools } from '../index.js'; -import { PANEL_PART_BYTES } from './panelRequest.js'; +import { + PANEL_PART_BYTES, + PANEL_RESPONSE_LIMIT_BYTES, +} from './panelRequest.js'; const fromBase64 = (text) => Buffer.from(text, 'base64'); const gunzip = async (bytes) => @@ -93,7 +96,10 @@ test('a request body and method pass through; settings and other sites do not', for (const path of [ '/api/setup/status', '/api/setup', + '/api/%73etup/status', '//evil.example/x', + '/\\evil.example/secret', + '/\\/evil.example/secret', 'http://evil.example/', ]) await assert.rejects( @@ -107,3 +113,57 @@ test('a request body and method pass through; settings and other sites do not', ); assert.equal(requests.length, 1); }); + +test('the path is requested as the parser reads it, without following redirects', async () => { + const { catalog, requests } = catalogWith(() => new Response('ok')); + await catalog.call('panel_request', { path: '/panel/a/../b.js?x=1' }); + assert.equal(requests[0].path, '/panel/b.js?x=1'); + assert.equal(requests[0].redirect, 'manual'); + assert.ok(requests[0].signal instanceof AbortSignal); +}); + +test('a response larger than the panel may load is refused while reading', async () => { + const chunk = new Uint8Array(1024 * 1024); + let sent = 0; + const { catalog } = catalogWith( + () => + new Response( + new ReadableStream({ + pull(controller) { + sent += chunk.length; + controller.enqueue(chunk); + }, + }), + ), + ); + await assert.rejects( + catalog.call('panel_request', { path: '/huge' }), + (error) => error.code === 'unavailable' && /too large/.test(error.message), + ); + assert.ok(sent <= PANEL_RESPONSE_LIMIT_BYTES + 2 * chunk.length, `${sent}`); +}); + +test('large responses read in turn keep their own parts', async () => { + const bodies = [1, 2].map((fill) => + new Uint8Array(PANEL_PART_BYTES + 5).fill(fill), + ); + let index = 0; + const { catalog } = catalogWith( + () => + new Response(bodies[index++], { + headers: { 'content-type': 'image/png' }, + }), + ); + const first = (await catalog.call('panel_request', { path: '/a.png' })).data; + const second = (await catalog.call('panel_request', { path: '/b.png' })).data; + for (const [start, fill] of [ + [first, 1], + [second, 2], + ]) { + const { data } = await catalog.call('panel_request', { + id: start.id, + offset: start.nextOffset, + }); + assert.deepEqual([...fromBase64(data.body)], [1, 1, 1, 1, 1].fill(fill)); + } +});