Keep panel requests on the app's server and bounded

panel_request resolved paths with a check for a leading //, but URL
parsing also reads /\host as another host, so it could request other
servers. It now resolves the path against the app's address, refuses
any other origin, matches refused routes decoded as well, and does not
follow redirects. Responses are capped at 64 MiB while reading, with a
deadline; held responses make room on arrival and expire by time only,
so a response being read keeps its parts.

The panel keeps converted images only for its build files, so live
camera frames no longer accumulate, and failed build loads are retried.
The dev server answers a malformed /panel/ path with 400 instead of
crashing.
This commit is contained in:
Sameh Khamis committed 2026-10-02 18:29:42 -07:00
1 parent efdee8d255
commit abca609708
5 files changed
+187 -38

No files matched your search

+61 -1
View File
@@ -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));
}
});