fix(mcp): accept only direct local requests at /mcp

The /mcp route now applies the same proxy and sharing checks as Provider
Settings: requests carrying forwarding headers, or arriving while launcher
sharing is on, are refused. The Host must name the port the request reached
and a browser Origin must match that host exactly. The proxy and sharing
checks move into shared helpers in keySetupCore; Provider Settings behaves
as before.

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 f7d3db5875
commit fec93aa817
5 files changed
+113 -32

No files matched your search

+28 -5
View File
@@ -2,10 +2,12 @@
* Serves the local MCP server at /mcp on the development and preview servers.
*
* This is local transport safety, not authentication: requests must come from
* this machine, name a loopback host (blocking DNS rebinding) and, when a
* browser sends an Origin, come from a loopback origin.
* this machine, carry no proxy forwarding headers, arrive while sharing is
* off, name a loopback host on the port they arrived at (blocking DNS
* rebinding) and, when a browser sends an Origin, come from that same host.
*/
import { hasProxySignals, isSharingEnabled } from '../../src/keySetupCore.mjs';
import { createMcpHttpHandler } from '../../src/tools/mcp/index.js';
import { createLocalMcpServer } from './server.js';
@@ -13,9 +15,23 @@ const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]']);
const LOOPBACK_ADDRESSES = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1']);
const MAX_BODY_BYTES = 1024 * 1024;
/** Whether a request may reach the local MCP server. */
export function isLocalMcpRequest({ remoteAddress, host, origin }) {
/**
* Whether a request may reach the local MCP server. `localPort` is the port
* the connection arrived at; `headers` are the request's, keyed by lower-case
* name; `env` is checked for launcher sharing.
*/
export function isLocalMcpRequest({
remoteAddress,
localPort,
host,
origin,
headers = {},
env = {},
}) {
if (!LOOPBACK_ADDRESSES.has(remoteAddress)) return false;
// A proxy or tunnel in front of the server can make a remote request look
// local, and sharing exposes the server beyond this machine.
if (hasProxySignals(headers) || isSharingEnabled(env)) return false;
let hostUrl;
try {
hostUrl = new URL(`http://${host}`);
@@ -23,12 +39,16 @@ export function isLocalMcpRequest({ remoteAddress, host, origin }) {
return false;
}
if (!LOOPBACK_HOSTS.has(hostUrl.hostname)) return false;
// The Host names the port the request reached, so tools are never pointed
// at another local service.
if (localPort != null && (hostUrl.port || '80') !== String(localPort))
return false;
if (origin == null) return true;
try {
const originUrl = new URL(origin);
return (
['http:', 'https:'].includes(originUrl.protocol) &&
LOOPBACK_HOSTS.has(originUrl.hostname)
originUrl.host === hostUrl.host
);
} catch {
return false;
@@ -49,8 +69,11 @@ export function localMcpPlugin({ createServer = createLocalMcpServer } = {}) {
if (
!isLocalMcpRequest({
remoteAddress: req.socket?.remoteAddress,
localPort: req.socket?.localPort,
host,
origin: req.headers.origin,
headers: req.headers,
env: process.env,
})
) {
res.writeHead(403, {