fix(mcp): require the panel key for panel requests

panel_request now requires a key that each MCP server generates and places
in its panel page, which keeps the tool from clients that list it to the
model without loading the panel. The key is not access control: any MCP
client can read the panel page. Routes the panel never loads (provider
settings, credential and model endpoints, the MCP endpoint and development
server internals) are refused to every caller, regardless of letter case or
encoding.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Sameh KhamisandClaude Opus 5.5 committed 2026-10-02 19:47:37 -07:00
1 parent b1a958242c
commit 3318390e3f
9 files changed
+209 -23

No files matched your search

+39 -4
View File
@@ -2,8 +2,16 @@
* The God's Eye View panel's requests. A panel cannot reach the app's server
* itself: hosts serve panels from their own sites and may refuse other
* addresses, such as a server on the user's machine. The panel asks this
* tool instead, which only an app may call, and it requests the path from
* the app's server. Large responses come back in parts.
* tool instead, and it requests the path from the app's server. Large
* responses come back in parts.
*
* It is meant for the panel. `visibility: ['app']` asks hosts to keep it
* from the model, and every call must also carry the key in the panel's page
* (`services.app.panelKey`), so a client that lists the tool to the model
* without loading the panel cannot use it. The key is not access control:
* any MCP client can read the panel's page, key included. What protects
* sensitive routes is that those the panel never needs are refused to every
* caller.
*/
import { defineTool, ToolError } from '../catalog.js';
@@ -17,8 +25,15 @@ const HELD_LIMIT_BYTES = 256 * 1024 * 1024;
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(?:\/|$)/];
// Routes the panel never loads, matched without regard to case as the
// server routes them: Provider Settings, which only the app's own page may
// use; credential and model endpoints; the MCP server itself; and the
// development server's internal routes.
const REFUSED_PATHS = [
/^\/api\/(?:setup|realtime|openai)(?:\/|$)/i,
/^\/mcp(?:\/|$)/i,
/^\/(?:@|__)/,
];
const FORWARDED_REQUEST_HEADERS = ['accept', 'content-type'];
const DROPPED_RESPONSE_HEADERS = new Set([
'connection',
@@ -48,6 +63,17 @@ function fromBase64(text) {
return Uint8Array.from(atob(text), (char) => char.charCodeAt(0));
}
/** Compare a caller's key with the panel's, in time independent of where they differ. */
function isPanelKey(given, expected) {
if (typeof expected !== 'string' || expected.length === 0) return false;
if (typeof given !== 'string' || given.length !== expected.length)
return false;
let difference = 0;
for (let index = 0; index < expected.length; index += 1)
difference |= given.charCodeAt(index) ^ expected.charCodeAt(index);
return difference === 0;
}
async function gzip(bytes) {
const stream = new Blob([bytes])
.stream()
@@ -158,6 +184,10 @@ export const panelRequest = defineTool({
inputSchema: {
type: 'object',
properties: {
key: {
type: 'string',
description: "The panel's key, from its own page.",
},
path: {
type: 'string',
description: "A path on the app's server, starting with /.",
@@ -179,6 +209,11 @@ export const panelRequest = defineTool({
requires: ['app'],
ui: { visibility: ['app'] },
async run(args, { services, signal }) {
if (!isPanelKey(args.key, services.app.panelKey))
throw new ToolError(
'invalid_arguments',
"Only the God's Eye View panel may make this request",
);
forgetExpired();
if (args.id !== undefined) {
const entry = held.get(args.id);
+93 -9
View File
@@ -7,6 +7,7 @@ import {
} from './panelRequest.js';
const fromBase64 = (text) => Buffer.from(text, 'base64');
const KEY = 'panel-key-for-tests';
const gunzip = async (bytes) =>
new Uint8Array(
await new Response(
@@ -25,7 +26,11 @@ function catalogWith(handler) {
catalog: composeCatalog({
tools: coreTools,
services: {
app: { baseUrl: 'http://localhost:4173/', fetch: fetchImpl },
app: {
baseUrl: 'http://localhost:4173/',
fetch: fetchImpl,
panelKey: KEY,
},
},
}),
};
@@ -46,6 +51,7 @@ test('a small response returns whole, compressed when that helps', async () => {
}),
);
const { data } = await catalog.call('panel_request', {
key: KEY,
path: '/panel/assets/style.css',
headers: { Accept: 'text/css', Cookie: 'secret', 'X-Other': '1' },
});
@@ -66,12 +72,16 @@ test('a large response comes in parts that join to the original', async () => {
const { catalog } = catalogWith(
() => new Response(bytes, { headers: { 'content-type': 'image/png' } }),
);
let { data } = await catalog.call('panel_request', { path: '/a.png' });
let { data } = await catalog.call('panel_request', {
key: KEY,
path: '/a.png',
});
assert.equal(data.encoding, 'identity');
assert.equal(data.totalBytes, bytes.length);
const parts = [fromBase64(data.body)];
while (data.nextOffset !== undefined) {
({ data } = await catalog.call('panel_request', {
key: KEY,
id: data.id,
offset: data.nextOffset,
}));
@@ -86,7 +96,8 @@ test('a request body and method pass through; settings and other sites do not',
() => new Response('{}', { status: 201 }),
);
const { data } = await catalog.call('panel_request', {
path: '/api/openai/hud-summary',
key: KEY,
path: '/api/overpass',
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: Buffer.from('{"a":1}').toString('base64'),
@@ -103,12 +114,12 @@ test('a request body and method pass through; settings and other sites do not',
'http://evil.example/',
])
await assert.rejects(
catalog.call('panel_request', { path }),
catalog.call('panel_request', { key: KEY, path }),
(error) => error.code === 'invalid_arguments',
path,
);
await assert.rejects(
catalog.call('panel_request', { id: 'unknown' }),
catalog.call('panel_request', { key: KEY, id: 'unknown' }),
(error) => error.code === 'invalid_arguments',
);
assert.equal(requests.length, 1);
@@ -116,7 +127,11 @@ test('a request body and method pass through; settings and other sites do not',
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' });
await catalog.call('panel_request', {
key: KEY,
key: KEY,
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);
@@ -137,7 +152,7 @@ test('a response larger than the panel may load is refused while reading', async
),
);
await assert.rejects(
catalog.call('panel_request', { path: '/huge' }),
catalog.call('panel_request', { key: KEY, path: '/huge' }),
(error) => error.code === 'unavailable' && /too large/.test(error.message),
);
assert.ok(sent <= PANEL_RESPONSE_LIMIT_BYTES + 2 * chunk.length, `${sent}`);
@@ -154,16 +169,85 @@ test('large responses read in turn keep their own parts', async () => {
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;
const first = (
await catalog.call('panel_request', { key: KEY, path: '/a.png' })
).data;
const second = (
await catalog.call('panel_request', { key: KEY, path: '/b.png' })
).data;
for (const [start, fill] of [
[first, 1],
[second, 2],
]) {
const { data } = await catalog.call('panel_request', {
key: KEY,
id: start.id,
offset: start.nextOffset,
});
assert.deepEqual([...fromBase64(data.body)], [1, 1, 1, 1, 1].fill(fill));
}
});
test('a call without the panel key is refused before any request', async () => {
const { catalog, requests } = catalogWith(() => new Response('ok'));
for (const key of [undefined, '', 'wrong', `${KEY}x`, KEY.toUpperCase()])
await assert.rejects(
catalog.call('panel_request', {
...(key === undefined ? {} : { key }),
path: '/panel/index.html',
}),
(error) => error.code === 'invalid_arguments',
String(key),
);
await assert.rejects(
catalog.call('panel_request', { id: 'held-elsewhere', offset: 0 }),
(error) => error.code === 'invalid_arguments',
);
assert.equal(requests.length, 0);
});
test('without a panel key in its services the tool refuses every call', async () => {
const catalog = composeCatalog({
tools: coreTools,
services: {
app: {
baseUrl: 'http://localhost:4173/',
fetch: async () => new Response('ok'),
},
},
});
await assert.rejects(
catalog.call('panel_request', { key: '', path: '/panel/index.html' }),
(error) => error.code === 'invalid_arguments',
);
});
test('routes the panel never loads are refused in any case or encoding', async () => {
const { catalog, requests } = catalogWith(() => new Response('ok'));
for (const path of [
'/API/setup/status',
'/Api/Setup',
'/api/realtime/token',
'/API/REALTIME/token',
'/api/openai/hud-summary',
'/api/%4fpenai/hud-summary',
'/mcp',
'/MCP',
'/@fs/etc/passwd',
'/@vite/client',
'/%40fs/x',
'/__open-in-editor?file=x',
])
await assert.rejects(
catalog.call('panel_request', { key: KEY, path }),
(error) => error.code === 'invalid_arguments',
path,
);
// Similar names the panel does load stay available.
await catalog.call('panel_request', { key: KEY, path: '/api/setups-help' });
await catalog.call('panel_request', { key: KEY, path: '/mcpx.json' });
assert.deepEqual(
requests.map((request) => request.path),
['/api/setups-help', '/mcpx.json'],
);
});