diff --git a/CHANGELOG.md b/CHANGELOG.md index 05ba5497e..469e4d3ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,7 +34,7 @@ - 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 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, requires a Host on the port it reached and an Origin from that host, and times out request bodies after 30 seconds; stdio honors diff --git a/docs/TOOLS.md b/docs/TOOLS.md index dcea4be1b..401058d91 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -136,7 +136,7 @@ map imagery, tiles and fonts load directly, from the providers the resource's `csp` lists. `panel_request` refuses Provider Settings (`/api/setup`), credential and model endpoints (`/api/realtime`, `/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 `dist/panel` and the servers serve at `/panel/`: one app script, one diff --git a/src/tools/localRoute.test.mjs b/src/tools/localRoute.test.mjs index d02b3d014..224660ffc 100644 --- a/src/tools/localRoute.test.mjs +++ b/src/tools/localRoute.test.mjs @@ -1,6 +1,7 @@ import assert from 'node:assert/strict'; import { createServer, request as httpRequest } from 'node:http'; import test from 'node:test'; +import { fileURLToPath } from 'node:url'; import { isLocalMcpRequest, localMcpPlugin } from '../../server/mcp/plugin.js'; 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); }); + +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); + } +}); diff --git a/src/tools/queries/panelRequest.js b/src/tools/queries/panelRequest.js index 8606d7809..3864c9889 100644 --- a/src/tools/queries/panelRequest.js +++ b/src/tools/queries/panelRequest.js @@ -25,13 +25,14 @@ 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']); -// 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. +// Routes the panel never loads, matched as the server routes them: without +// regard to case, and with a mounted route also matching when a dot follows +// it (`/mcp.json` reaches `/mcp`). 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, + /^\/api\/(?:setup|realtime|openai)(?:[/.]|$)/i, + /^\/mcp(?:[/.]|$)/i, /^\/(?:@|__)/, ]; const FORWARDED_REQUEST_HEADERS = ['accept', 'content-type']; diff --git a/src/tools/queries/panelRequest.test.mjs b/src/tools/queries/panelRequest.test.mjs index 05bce98f9..bdd94bcec 100644 --- a/src/tools/queries/panelRequest.test.mjs +++ b/src/tools/queries/panelRequest.test.mjs @@ -234,6 +234,12 @@ test('routes the panel never loads are refused in any case or encoding', async ( '/api/%4fpenai/hud-summary', '/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', '/@vite/client', '/%40fs/x',