From 9e2911d098d9d4fda61101ac0f515522d9d6ee30 Mon Sep 17 00:00:00 2001 From: 2830500285 <156506452+2830500285@users.noreply.github.com> Date: Wed, 20 May 2026 10:02:15 +0800 Subject: [PATCH] fix: restrict dev MCP server to loopback hosts --- .../src/server/mcp/get-mcp-middleware.test.ts | 40 ++++++++++++ .../next/src/server/mcp/get-mcp-middleware.ts | 62 +++++++++++++++++++ 2 files changed, 102 insertions(+) create mode 100644 packages/next/src/server/mcp/get-mcp-middleware.test.ts diff --git a/packages/next/src/server/mcp/get-mcp-middleware.test.ts b/packages/next/src/server/mcp/get-mcp-middleware.test.ts new file mode 100644 index 000000000000..b372e046e651 --- /dev/null +++ b/packages/next/src/server/mcp/get-mcp-middleware.test.ts @@ -0,0 +1,40 @@ +/** + * @jest-environment node + */ + +import { getMcpAllowedHosts } from './get-mcp-middleware' + +describe('getMcpAllowedHosts', () => { + it('allows loopback hosts on the dev server port', () => { + expect(getMcpAllowedHosts('http://localhost:3100', undefined)).toEqual([ + 'localhost', + '127.0.0.1', + '[::1]', + 'localhost:3100', + '127.0.0.1:3100', + '[::1]:3100', + ]) + }) + + it('does not allow network interfaces when the dev server binds to all hosts', () => { + expect( + getMcpAllowedHosts('http://0.0.0.0:3100', '192.168.0.10:3100') + ).toEqual([ + 'localhost', + '127.0.0.1', + '[::1]', + 'localhost:3100', + '127.0.0.1:3100', + '[::1]:3100', + ]) + }) + + it('allows the current request host only when it is loopback', () => { + expect(getMcpAllowedHosts(undefined, '127.0.0.1:4000')).toContain( + '127.0.0.1:4000' + ) + expect(getMcpAllowedHosts(undefined, '192.168.0.10:4000')).not.toContain( + '192.168.0.10:4000' + ) + }) +}) diff --git a/packages/next/src/server/mcp/get-mcp-middleware.ts b/packages/next/src/server/mcp/get-mcp-middleware.ts index 962b0d45d1c8..8b64a8fa1df2 100644 --- a/packages/next/src/server/mcp/get-mcp-middleware.ts +++ b/packages/next/src/server/mcp/get-mcp-middleware.ts @@ -3,9 +3,65 @@ import { getOrCreateMcpServer, type McpServerOptions, } from './get-or-create-mcp-server' +import * as Log from '../../build/output/log' import { parseBody } from '../api-utils/node/parse-body' import { StreamableHTTPServerTransport } from 'next/dist/compiled/@modelcontextprotocol/sdk/server/streamableHttp' +const LOOPBACK_MCP_HOSTS = ['localhost', '127.0.0.1', '[::1]'] + +function isLoopbackHostname(hostname: string): boolean { + const normalizedHostname = hostname.toLowerCase() + + return ( + normalizedHostname === 'localhost' || + normalizedHostname === '127.0.0.1' || + normalizedHostname === '::1' || + normalizedHostname === '[::1]' + ) +} + +function getHostnameFromHostHeader(hostHeader: string | undefined) { + if (!hostHeader) return undefined + + try { + return new URL(`http://${hostHeader}`).hostname + } catch { + return undefined + } +} + +export function getMcpAllowedHosts( + devServerUrl: string | undefined, + requestHost: string | undefined +): string[] { + const allowedHosts = new Set(LOOPBACK_MCP_HOSTS) + let port: string | undefined + + if (devServerUrl) { + try { + const parsedUrl = new URL(devServerUrl) + port = parsedUrl.port + + if (isLoopbackHostname(parsedUrl.hostname)) { + allowedHosts.add(parsedUrl.host) + } + } catch {} + } + + if (port) { + for (const host of LOOPBACK_MCP_HOSTS) { + allowedHosts.add(`${host}:${port}`) + } + } + + const requestHostname = getHostnameFromHostHeader(requestHost) + if (requestHostname && isLoopbackHostname(requestHostname) && requestHost) { + allowedHosts.add(requestHost) + } + + return Array.from(allowedHosts) +} + export function getMcpMiddleware(options: McpServerOptions) { return async function ( req: IncomingMessage, @@ -17,8 +73,13 @@ export function getMcpMiddleware(options: McpServerOptions) { return next() } const mcpServer = getOrCreateMcpServer(options) + const requestHost = Array.isArray(req.headers.host) + ? req.headers.host[0] + : req.headers.host const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, + enableDnsRebindingProtection: true, + allowedHosts: getMcpAllowedHosts(options.getDevServerUrl(), requestHost), }) try { res.on('close', () => { @@ -28,6 +89,7 @@ export function getMcpMiddleware(options: McpServerOptions) { const parsedBody = await parseBody(req, 1024 * 1024) // 1MB limit await transport.handleRequest(req, res, parsedBody) } catch (error) { + Log.error('Failed to handle Next.js MCP request', error) if (!res.headersSent) { res.statusCode = 500 res.setHeader('Content-Type', 'application/json; charset=utf-8')