mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 03:14:00 +00:00
fix(mcp): refuse panel requests to routes reached through a dot suffix
The server routes a mounted path when a dot follows it, so /mcp.json reached the MCP endpoint while panel_request refused only /mcp and /mcp/...; the same held for the provider-settings, credential and model routes. The refusals now match a dot after the route as well. A regression test drives panel_request against a real Vite server. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
6cdd6c8af9
commit
81513f9aae
5 files changed
+109
-8
No files matched your search
+1
-1
@@ -34,7 +34,7 @@
|
|||||||
- Tighten the local MCP server and embed mode. The panel's `panel_request`
|
- Tighten the local MCP server and embed mode. The panel's `panel_request`
|
||||||
needs the key in its MCP server's panel page, and refuses Provider
|
needs the key in its MCP server's panel page, and refuses Provider
|
||||||
Settings, credential and model endpoints, `/mcp` and development server
|
Settings, credential and model endpoints, `/mcp` and development server
|
||||||
routes in any letter case or encoding; panels on one server share six
|
routes in any letter case, encoding or dot suffix; panels on one server share six
|
||||||
requests in flight, and further requests wait their turn. `/mcp` applies Provider Settings' proxy and sharing checks,
|
requests in flight, and further requests wait their turn. `/mcp` applies Provider Settings' proxy and sharing checks,
|
||||||
requires a Host on the port it reached and an Origin from that host, and
|
requires a Host on the port it reached and an Origin from that host, and
|
||||||
times out request bodies after 30 seconds; stdio honors
|
times out request bodies after 30 seconds; stdio honors
|
||||||
|
|||||||
+1
-1
@@ -136,7 +136,7 @@ map imagery, tiles and fonts load directly, from the providers the
|
|||||||
resource's `csp` lists. `panel_request` refuses Provider Settings
|
resource's `csp` lists. `panel_request` refuses Provider Settings
|
||||||
(`/api/setup`), credential and model endpoints (`/api/realtime`,
|
(`/api/setup`), credential and model endpoints (`/api/realtime`,
|
||||||
`/api/openai`), `/mcp` and the development server's internal routes, in any
|
`/api/openai`), `/mcp` and the development server's internal routes, in any
|
||||||
letter case or encoding.
|
letter case, encoding or dot suffix.
|
||||||
|
|
||||||
The panel loads the app's panel build, which `npm run build:panel` writes to
|
The panel loads the app's panel build, which `npm run build:panel` writes to
|
||||||
`dist/panel` and the servers serve at `/panel/`: one app script, one
|
`dist/panel` and the servers serve at `/panel/`: one app script, one
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { createServer, request as httpRequest } from 'node:http';
|
import { createServer, request as httpRequest } from 'node:http';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
import { isLocalMcpRequest, localMcpPlugin } from '../../server/mcp/plugin.js';
|
import { isLocalMcpRequest, localMcpPlugin } from '../../server/mcp/plugin.js';
|
||||||
|
|
||||||
test('only direct loopback connections to this server are local', () => {
|
test('only direct loopback connections to this server are local', () => {
|
||||||
@@ -236,3 +237,96 @@ test('a client that stops sending its request body is answered with a timeout',
|
|||||||
});
|
});
|
||||||
assert.equal(status, 408);
|
assert.equal(status, 408);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('panel requests cannot reach /mcp through a suffix the server routes to it', async (t) => {
|
||||||
|
const { createServer: createViteServer } = await import('vite');
|
||||||
|
const vite = await createViteServer({
|
||||||
|
root: fileURLToPath(new URL('../../', import.meta.url)),
|
||||||
|
configFile: false,
|
||||||
|
envFile: false,
|
||||||
|
publicDir: false,
|
||||||
|
logLevel: 'silent',
|
||||||
|
plugins: [localMcpPlugin()],
|
||||||
|
optimizeDeps: { noDiscovery: true, include: [] },
|
||||||
|
server: { host: '127.0.0.1', port: 0, hmr: false, watch: null },
|
||||||
|
});
|
||||||
|
await vite.listen();
|
||||||
|
t.after(() => vite.close());
|
||||||
|
const { port } = vite.httpServer.address();
|
||||||
|
const rpc = (path, message) =>
|
||||||
|
new Promise((resolve, reject) => {
|
||||||
|
const body = JSON.stringify(message);
|
||||||
|
const request = httpRequest(
|
||||||
|
{
|
||||||
|
host: '127.0.0.1',
|
||||||
|
port,
|
||||||
|
path,
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Host: `localhost:${port}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
(response) => {
|
||||||
|
let text = '';
|
||||||
|
response.on('data', (chunk) => (text += chunk));
|
||||||
|
response.on('end', () =>
|
||||||
|
resolve({ status: response.statusCode, text }),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
request.on('error', reject);
|
||||||
|
request.end(body);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The hazard is real: the server routes /mcp.json to the MCP endpoint.
|
||||||
|
const direct = await rpc('/mcp.json', {
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 1,
|
||||||
|
method: 'ping',
|
||||||
|
});
|
||||||
|
assert.equal(direct.status, 200);
|
||||||
|
assert.deepEqual(JSON.parse(direct.text), {
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 1,
|
||||||
|
result: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const page = JSON.parse(
|
||||||
|
(
|
||||||
|
await rpc('/mcp', {
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 2,
|
||||||
|
method: 'resources/read',
|
||||||
|
params: { uri: 'ui://gods-eye-view/globe' },
|
||||||
|
})
|
||||||
|
).text,
|
||||||
|
);
|
||||||
|
const [, key] = page.result.contents[0].text.match(/"panelKey":"([^"]+)"/);
|
||||||
|
const body = Buffer.from(
|
||||||
|
JSON.stringify({ jsonrpc: '2.0', id: 3, method: 'ping' }),
|
||||||
|
).toString('base64');
|
||||||
|
for (const path of ['/mcp', '/mcp.json', '/MCP.json', '/mcp.anything']) {
|
||||||
|
const answer = JSON.parse(
|
||||||
|
(
|
||||||
|
await rpc('/mcp', {
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 4,
|
||||||
|
method: 'tools/call',
|
||||||
|
params: {
|
||||||
|
name: 'panel_request',
|
||||||
|
arguments: {
|
||||||
|
key,
|
||||||
|
path,
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
).text,
|
||||||
|
);
|
||||||
|
assert.equal(answer.result.isError, true, path);
|
||||||
|
assert.match(answer.result.content[0].text, /is not available/, path);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -25,13 +25,14 @@ const HELD_LIMIT_BYTES = 256 * 1024 * 1024;
|
|||||||
export const PANEL_RESPONSE_LIMIT_BYTES = 64 * 1024 * 1024;
|
export const PANEL_RESPONSE_LIMIT_BYTES = 64 * 1024 * 1024;
|
||||||
const REQUEST_TIMEOUT_MS = 60 * 1000;
|
const REQUEST_TIMEOUT_MS = 60 * 1000;
|
||||||
const METHODS = new Set(['GET', 'HEAD', 'POST']);
|
const METHODS = new Set(['GET', 'HEAD', 'POST']);
|
||||||
// Routes the panel never loads, matched without regard to case as the
|
// Routes the panel never loads, matched as the server routes them: without
|
||||||
// server routes them: Provider Settings, which only the app's own page may
|
// regard to case, and with a mounted route also matching when a dot follows
|
||||||
// use; credential and model endpoints; the MCP server itself; and the
|
// it (`/mcp.json` reaches `/mcp`). Provider Settings, which only the app's own
|
||||||
// development server's internal routes.
|
// page may use; credential and model endpoints; the MCP server itself; and
|
||||||
|
// the development server's internal routes.
|
||||||
const REFUSED_PATHS = [
|
const REFUSED_PATHS = [
|
||||||
/^\/api\/(?:setup|realtime|openai)(?:\/|$)/i,
|
/^\/api\/(?:setup|realtime|openai)(?:[/.]|$)/i,
|
||||||
/^\/mcp(?:\/|$)/i,
|
/^\/mcp(?:[/.]|$)/i,
|
||||||
/^\/(?:@|__)/,
|
/^\/(?:@|__)/,
|
||||||
];
|
];
|
||||||
const FORWARDED_REQUEST_HEADERS = ['accept', 'content-type'];
|
const FORWARDED_REQUEST_HEADERS = ['accept', 'content-type'];
|
||||||
|
|||||||
@@ -234,6 +234,12 @@ test('routes the panel never loads are refused in any case or encoding', async (
|
|||||||
'/api/%4fpenai/hud-summary',
|
'/api/%4fpenai/hud-summary',
|
||||||
'/mcp',
|
'/mcp',
|
||||||
'/MCP',
|
'/MCP',
|
||||||
|
// A dot after a mounted route still reaches it.
|
||||||
|
'/mcp.json',
|
||||||
|
'/MCP.json',
|
||||||
|
'/api/setup.json',
|
||||||
|
'/api/realtime.token',
|
||||||
|
'/api/OpenAI.x/hud-summary',
|
||||||
'/@fs/etc/passwd',
|
'/@fs/etc/passwd',
|
||||||
'/@vite/client',
|
'/@vite/client',
|
||||||
'/%40fs/x',
|
'/%40fs/x',
|
||||||
|
|||||||
Reference in new issue
Block a user