mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
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:
1 parent
b1a958242c
commit
3318390e3f
9 files changed
+209
-23
No files matched your search
@@ -55,12 +55,13 @@ export const PANEL_REQUEST_TOOL = 'panel_request';
|
||||
* the panel's script, `runtime`, which loads the app through the MCP server
|
||||
* and shows each view a tool returns.
|
||||
*/
|
||||
function panelHtml(runtime) {
|
||||
function panelHtml(runtime, panelKey) {
|
||||
const config = {
|
||||
appBaseUrl: PANEL_APP_BASE_URL,
|
||||
loadTimeoutMs: LOAD_TIMEOUT_MS,
|
||||
panelBase: PANEL_BASE,
|
||||
panelHeight: PANEL_HEIGHT_PX,
|
||||
panelKey,
|
||||
protocolVersion: MCP_APPS_PROTOCOL_VERSION,
|
||||
toolName: PANEL_REQUEST_TOOL,
|
||||
workerPreludePath: PANEL_WORKER_PRELUDE_PATH,
|
||||
@@ -103,18 +104,21 @@ function panelHtml(runtime) {
|
||||
* it (src/app/globePanelRuntime.js), as it is browser code. The security
|
||||
* policy lets the panel load map imagery, tiles and fonts from their
|
||||
* providers; everything from the app's own server arrives through the MCP
|
||||
* server instead.
|
||||
* server instead. `panelKey` is the key the panel's requests must carry
|
||||
* (see src/tools/queries/panelRequest.js), which this page holds.
|
||||
*/
|
||||
export function createGlobePanelResource({ runtime }) {
|
||||
export function createGlobePanelResource({ runtime, panelKey }) {
|
||||
if (typeof runtime !== 'function')
|
||||
throw new TypeError('The globe panel needs its runtime script');
|
||||
if (typeof panelKey !== 'string' || panelKey.length === 0)
|
||||
throw new TypeError('The globe panel needs its request key');
|
||||
return Object.freeze({
|
||||
uri: GLOBE_PANEL_URI,
|
||||
name: 'globe',
|
||||
title: "God's Eye View globe",
|
||||
description: "Live God's Eye View, showing the view a tool returns.",
|
||||
mimeType: MCP_APP_MIME_TYPE,
|
||||
text: panelHtml(runtime),
|
||||
text: panelHtml(runtime, panelKey),
|
||||
_meta: {
|
||||
ui: {
|
||||
csp: {
|
||||
|
||||
@@ -9,7 +9,10 @@ import { panelRuntime } from '../app/globePanelRuntime.js';
|
||||
import { composeCatalog, coreTools } from './index.js';
|
||||
|
||||
test('the globe panel is an MCP Apps resource that loads the app through its server', () => {
|
||||
const resource = createGlobePanelResource({ runtime: panelRuntime });
|
||||
const resource = createGlobePanelResource({
|
||||
runtime: panelRuntime,
|
||||
panelKey: 'test-key',
|
||||
});
|
||||
assert.equal(resource.uri, GLOBE_PANEL_URI);
|
||||
assert.equal(resource.mimeType, MCP_APP_MIME_TYPE);
|
||||
assert.equal(MCP_APP_MIME_TYPE, 'text/html;profile=mcp-app');
|
||||
@@ -24,6 +27,9 @@ test('the globe panel is an MCP Apps resource that loads the app through its ser
|
||||
assert.equal(csp.frameDomains, undefined);
|
||||
assert.equal(csp.baseUriDomains, undefined);
|
||||
assert.match(resource.text, /"toolName":"panel_request"/);
|
||||
// The page carries the key its requests need, and sends it with each one.
|
||||
assert.match(resource.text, /"panelKey":"test-key"/);
|
||||
assert.match(resource.text, /key: config\.panelKey/);
|
||||
assert.match(resource.text, /"panelBase":"\/panel\/"/);
|
||||
assert.match(resource.text, /'ui\/initialize'/);
|
||||
assert.match(resource.text, /'ui\/notifications\/tool-result'/);
|
||||
@@ -39,6 +45,14 @@ test('the globe panel is an MCP Apps resource that loads the app through its ser
|
||||
assert.doesNotThrow(() => new Function(script));
|
||||
});
|
||||
|
||||
test('the globe panel needs a request key', () => {
|
||||
for (const panelKey of [undefined, '', 42])
|
||||
assert.throws(
|
||||
() => createGlobePanelResource({ runtime: panelRuntime, panelKey }),
|
||||
TypeError,
|
||||
);
|
||||
});
|
||||
|
||||
test('show_in_gods_eye_view names the panel and shows a view another answer returned', async () => {
|
||||
const catalog = composeCatalog({
|
||||
tools: coreTools,
|
||||
|
||||
@@ -131,3 +131,43 @@ test('a failed tool call is logged with its reason', async () => {
|
||||
' tools/call show_in_gods_eye_view failed: bad area',
|
||||
]);
|
||||
});
|
||||
|
||||
test("only the key in the server's own panel page opens panel requests", async () => {
|
||||
const requested = [];
|
||||
const server = createLocalMcpServer({
|
||||
apiBase: 'http://127.0.0.1:5000',
|
||||
fetchImpl: async (url) => {
|
||||
requested.push(new URL(url).pathname);
|
||||
return new Response('ok');
|
||||
},
|
||||
});
|
||||
const call = (id, args) =>
|
||||
server.handle({
|
||||
jsonrpc: '2.0',
|
||||
id,
|
||||
method: 'tools/call',
|
||||
params: { name: 'panel_request', arguments: args },
|
||||
});
|
||||
const page = await server.handle({
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
method: 'resources/read',
|
||||
params: { uri: 'ui://gods-eye-view/globe' },
|
||||
});
|
||||
const [, key] = page.result.contents[0].text.match(/"panelKey":"([^"]+)"/);
|
||||
assert.ok(key.length >= 40);
|
||||
const refused = await call(2, { path: '/panel/index.html' });
|
||||
assert.equal(refused.result.isError, true);
|
||||
const opened = await call(3, { key, path: '/panel/index.html' });
|
||||
assert.equal(opened.result.isError, false);
|
||||
assert.deepEqual(requested, ['/panel/index.html']);
|
||||
// Every server makes its own key.
|
||||
const other = createLocalMcpServer({ apiBase: 'http://127.0.0.1:5000' });
|
||||
const otherPage = await other.handle({
|
||||
jsonrpc: '2.0',
|
||||
id: 4,
|
||||
method: 'resources/read',
|
||||
params: { uri: 'ui://gods-eye-view/globe' },
|
||||
});
|
||||
assert.doesNotMatch(otherPage.result.contents[0].text, new RegExp(key));
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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'],
|
||||
);
|
||||
});
|
||||
@@ -4,6 +4,8 @@
|
||||
* the sources' relative `/api/...` paths; in a browser the page's own fetch
|
||||
* does, and elsewhere a caller supplies a resolving fetch. `appUrl` is the
|
||||
* address the app is served from; `app.fetch` requests the app's own paths.
|
||||
* `panelKey`, when the panel is offered, is the key its page carries, which
|
||||
* the panel's requests must include.
|
||||
*/
|
||||
|
||||
import { createAlprTileSource } from '../layers/alpr/source.js';
|
||||
@@ -40,12 +42,12 @@ import {
|
||||
} from './places.js';
|
||||
|
||||
/** Construct every service Core's tools read. */
|
||||
export function createToolServices({ fetchImpl, appUrl }) {
|
||||
export function createToolServices({ fetchImpl, appUrl, panelKey }) {
|
||||
if (typeof fetchImpl !== 'function')
|
||||
throw new TypeError('A fetch implementation is required');
|
||||
const requests = createApplicationRequestServices({ fetchImpl });
|
||||
return {
|
||||
app: { baseUrl: appUrl, fetch: fetchImpl },
|
||||
app: { baseUrl: appUrl, fetch: fetchImpl, panelKey },
|
||||
earthquakes: createUsgsEarthquakeSource({ fetchImpl }),
|
||||
fires: createFirmsSource({ fetchImpl }),
|
||||
launches: createLaunchSource({ fetchImpl }),
|
||||
|
||||
Reference in new issue
Block a user