mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
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:
1 parent
efdee8d255
commit
abca609708
5 files changed
+187
-38
No files matched your search
+8
-1
@@ -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 {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user