From 284acc0416e5648320963bfa67180c80927d03a6 Mon Sep 17 00:00:00 2001 From: Jorge E Ortiz Garcia Date: Wed, 29 Jul 2026 12:50:37 -0700 Subject: [PATCH] Secure MCP SSE server against unauthenticated network access The MCP SSE server bound to all interfaces with no authentication, origin check, or CORS policy, allowing any local process, LAN host, or malicious webpage (via DNS rebinding) to enumerate tools and query the EDK2 workspace. - Bind the HTTP server to 127.0.0.1 instead of the unspecified address. - Reject requests whose Host/Origin headers are not loopback, which defeats DNS rebinding. - Require an 'Authorization: Bearer ' header on /sse and /messages, using a 32-byte random token persisted in VS Code SecretStorage and compared with timingSafeEqual. - Offer a 'Copy Access Token' action when the server starts. - Auto configuration now writes .vscode/mcp.json with the loopback URL and an Authorization header sourced from a password input, so the token is not stored on disk. --- src/mcp/mcpServer.ts | 152 +++++++++++++++++++++++++++++++--- src/newVersionPage/2.0.0.md | 1 + src/settings/settingsPanel.ts | 60 ++++++++++---- 3 files changed, 184 insertions(+), 29 deletions(-) diff --git a/src/mcp/mcpServer.ts b/src/mcp/mcpServer.ts index 8d2c7a4..fbe965f 100644 --- a/src/mcp/mcpServer.ts +++ b/src/mcp/mcpServer.ts @@ -1,9 +1,10 @@ import * as http from 'http'; +import * as crypto from 'crypto'; import * as vscode from 'vscode'; import path = require('path'); import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js'; -import { gDebugLog, gEdkWorkspaces, gPathFind, gWorkspacePath } from '../extension'; +import { gDebugLog, gEdkWorkspaces, gExtensionContext, gPathFind, gWorkspacePath } from '../extension'; import { openTextDocument } from '../utils'; import { getParserForDocument } from '../edkParser/parserFactory'; import { Edk2SymbolType } from '../symbols/symbolsType'; @@ -11,8 +12,106 @@ import { z } from 'zod'; let httpServer: http.Server | undefined; let mcpServer: McpServer | undefined; +let serverToken: string | undefined; const transports: Record = {}; +/** The server is only ever reachable through the loopback interface. */ +const BIND_ADDRESS = '127.0.0.1'; + +/** Host names that are accepted in the `Host` and `Origin` headers. */ +const ALLOWED_HOST_NAMES = new Set(['localhost', '127.0.0.1', '::1']); + +/** + * Extracts the host name (without port and without IPv6 brackets) from a + * `Host` header value. + */ +function parseHostName(hostHeader: string | undefined): string | undefined { + if (!hostHeader) { + return undefined; + } + const value = hostHeader.trim(); + // IPv6 literal, e.g. "[::1]:3100" + if (value.startsWith('[')) { + const end = value.indexOf(']'); + return end === -1 ? undefined : value.slice(1, end).toLowerCase(); + } + return value.split(':')[0].toLowerCase(); +} + +/** + * Rejects requests that do not target the loopback interface by name. + * This is what defeats DNS rebinding attacks: the browser keeps sending the + * attacker controlled host name (e.g. "evil.com") in the `Host`/`Origin` + * headers even after the DNS record points at 127.0.0.1. + */ +function isAllowedOriginAndHost(req: http.IncomingMessage): boolean { + const hostName = parseHostName(req.headers.host); + if (!hostName || !ALLOWED_HOST_NAMES.has(hostName)) { + return false; + } + + const origin = req.headers.origin; + if (origin !== undefined && origin !== 'null') { + try { + const originHost = new URL(origin).hostname.replace(/^\[|\]$/g, '').toLowerCase(); + if (!ALLOWED_HOST_NAMES.has(originHost)) { + return false; + } + } catch { + return false; + } + } + + return true; +} + +/** Constant time comparison of the presented bearer token. */ +function isAuthorized(req: http.IncomingMessage): boolean { + if (!serverToken) { + return false; + } + const header = req.headers.authorization; + if (!header) { + return false; + } + const match = /^Bearer\s+(.+)$/i.exec(header.trim()); + if (!match) { + return false; + } + const presented = Buffer.from(match[1], 'utf8'); + const expected = Buffer.from(serverToken, 'utf8'); + if (presented.length !== expected.length) { + return false; + } + return crypto.timingSafeEqual(presented, expected); +} + +/** Returns the bearer token required by MCP clients, if the server is running. */ +export function getMcpServerToken(): string | undefined { + return serverToken; +} + +const TOKEN_SECRET_KEY = 'edk2code.mcpServerToken'; + +/** + * Returns the persisted access token, creating one on first use. + * The token is kept in VS Code SecretStorage so that MCP clients do not need + * to be reconfigured every time the server restarts. + */ +export async function getOrCreateMcpToken(): Promise { + const secrets = gExtensionContext?.secrets; + if (!secrets) { + // No storage available: fall back to an ephemeral token. + return serverToken ?? crypto.randomBytes(32).toString('hex'); + } + let token = await secrets.get(TOKEN_SECRET_KEY); + if (!token) { + token = crypto.randomBytes(32).toString('hex'); + await secrets.store(TOKEN_SECRET_KEY, token); + } + return token; +} + function createMcpServer(): McpServer { const server = new McpServer( { name: 'edk2code', version: '1.0.0' }, @@ -306,9 +405,34 @@ export async function startMcpServer(port: number): Promise { } mcpServer = createMcpServer(); + serverToken = await getOrCreateMcpToken(); httpServer = http.createServer(async (req, res) => { - const url = new URL(req.url ?? '', `http://localhost:${port}`); + const url = new URL(req.url ?? '', `http://${BIND_ADDRESS}:${port}`); + + // Reject cross-origin / rebound-DNS requests before doing any work. + if (!isAllowedOriginAndHost(req)) { + gDebugLog.warning( + `MCP SSE: rejected request with host "${req.headers.host}" origin "${req.headers.origin}"` + ); + res.writeHead(403); + res.end('Forbidden'); + return; + } + + // Health check (no token required, loopback only). + if (req.method === 'GET' && url.pathname === '/health') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ status: 'ok' })); + return; + } + + if (!isAuthorized(req)) { + gDebugLog.warning('MCP SSE: rejected unauthenticated request'); + res.writeHead(401, { 'WWW-Authenticate': 'Bearer' }); + res.end('Unauthorized'); + return; + } // SSE stream endpoint if (req.method === 'GET' && url.pathname === '/sse') { @@ -344,29 +468,30 @@ export async function startMcpServer(port: number): Promise { return; } - // Health check - if (req.method === 'GET' && url.pathname === '/health') { - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ status: 'ok' })); - return; - } - res.writeHead(404); res.end('Not Found'); }); return new Promise((resolve, reject) => { - httpServer!.listen(port, () => { - gDebugLog.info(`MCP SSE server listening on http://localhost:${port}/sse`); + httpServer!.listen(port, BIND_ADDRESS, () => { + gDebugLog.info(`MCP SSE server listening on http://${BIND_ADDRESS}:${port}/sse (loopback only)`); vscode.window.showInformationMessage( - `EDK2 MCP SSE server started on http://localhost:${port}/sse` - ); + `EDK2 MCP SSE server started on http://${BIND_ADDRESS}:${port}/sse. ` + + 'Clients must send an "Authorization: Bearer " header.', + 'Copy Access Token' + ).then(async (selection) => { + if (selection === 'Copy Access Token' && serverToken) { + await vscode.env.clipboard.writeText(serverToken); + vscode.window.showInformationMessage('EDK2 MCP access token copied to clipboard'); + } + }); resolve(); }); httpServer!.on('error', (err) => { gDebugLog.error(`MCP SSE server error: ${err}`); vscode.window.showErrorMessage(`Failed to start MCP server: ${err.message}`); httpServer = undefined; + serverToken = undefined; reject(err); }); }); @@ -384,6 +509,7 @@ export function stopMcpServer(): void { httpServer.close(); httpServer = undefined; mcpServer = undefined; + serverToken = undefined; gDebugLog.info('MCP SSE server stopped'); vscode.window.showInformationMessage('EDK2 MCP SSE server stopped'); } diff --git a/src/newVersionPage/2.0.0.md b/src/newVersionPage/2.0.0.md index 28d784f..a9fa77d 100644 --- a/src/newVersionPage/2.0.0.md +++ b/src/newVersionPage/2.0.0.md @@ -59,6 +59,7 @@ EDK2Code now exposes an **MCP (Model Context Protocol) SSE server** that lets AI - Start with **`EDK2: Start MCP SSE Server`** and stop with **`EDK2: Stop MCP SSE Server`**. - The server port is configurable via the `edk2code.mcpServerPort` setting (default `3100`). +- The server listens on the loopback interface (`127.0.0.1`) only, rejects requests whose `Host`/`Origin` headers are not loopback, and requires an `Authorization: Bearer ` header. Use **Auto configure** in the settings UI to generate `.vscode/mcp.json`; the access token is copied to your clipboard and VS Code prompts for it. ## Settings UI diff --git a/src/settings/settingsPanel.ts b/src/settings/settingsPanel.ts index c942c7f..fa959b8 100644 --- a/src/settings/settingsPanel.ts +++ b/src/settings/settingsPanel.ts @@ -7,7 +7,7 @@ import { Disposable, Webview, WebviewPanel, window, Uri, ViewColumn } from "vsco import { gExtensionContext } from '../extension'; import { ConfigAgent, WorkspaceConfig, WorkspaceConfigErrors } from '../configuration'; import { askReloadFiles } from '../ui/messages'; -import { isMcpServerRunning } from '../mcp/mcpServer'; +import { getOrCreateMcpToken, isMcpServerRunning } from '../mcp/mcpServer'; function deepCopy(obj: any) { @@ -264,38 +264,60 @@ export class SettingsPanel { const vscodePath = path.join(workspaceFolders[0].uri.fsPath, '.vscode'); const mcpConfigPath = path.join(vscodePath, 'mcp.json'); const port = vscode.workspace.getConfiguration('edk2code').get('mcpServerPort', 3100); - const expectedUrl = `http://localhost:${port}/sse`; + // The server only listens on the loopback interface. + const expectedUrl = `http://127.0.0.1:${port}/sse`; + const tokenInputId = 'edk2code-mcp-token'; + // The token is never written to disk: it is requested from the user and + // kept by VS Code, so that mcp.json can be safely committed. + const tokenInput = { + id: tokenInputId, + type: 'promptString', + description: 'EDK2Code MCP access token', + password: true + }; + const serverEntryValue = { + type: 'sse', + url: expectedUrl, + headers: { Authorization: `Bearer \${input:${tokenInputId}}` } + }; + + const mergeInputs = (inputs: any): any[] => { + const list = Array.isArray(inputs) ? inputs : []; + const index = list.findIndex((i) => i && i.id === tokenInputId); + if (index === -1) { + list.push(tokenInput); + } else { + list[index] = tokenInput; + } + return list; + }; if (fs.existsSync(mcpConfigPath)) { try { const existing = JSON.parse(fs.readFileSync(mcpConfigPath, 'utf-8')); - const serverEntry = existing?.servers?.edk2code; - if (serverEntry && serverEntry.url === expectedUrl) { - void this._panel.webview.postMessage({ command: 'mcpConfigResult', message: 'edk2code MCP already configured correctly.' }); - return; - } // Add or update the edk2code entry if (!existing.servers) { existing.servers = {}; } - existing.servers.edk2code = { type: "sse", url: expectedUrl }; + existing.inputs = mergeInputs(existing.inputs); + existing.servers.edk2code = serverEntryValue; fs.writeFileSync(mcpConfigPath, JSON.stringify(existing, null, 4), 'utf-8'); - void this._panel.webview.postMessage({ command: 'mcpConfigResult', message: 'Updated edk2code entry in .vscode/mcp.json' }); + await this.copyMcpTokenToClipboard(); + void this._panel.webview.postMessage({ command: 'mcpConfigResult', message: 'Updated edk2code entry in .vscode/mcp.json. Access token copied to clipboard, paste it when VS Code asks for it.' }); } catch { // File exists but is not valid JSON, overwrite - const mcpConfig = { servers: { edk2code: { type: "sse", url: expectedUrl } } }; + const mcpConfig = { inputs: [tokenInput], servers: { edk2code: serverEntryValue } }; fs.writeFileSync(mcpConfigPath, JSON.stringify(mcpConfig, null, 4), 'utf-8'); - void this._panel.webview.postMessage({ command: 'mcpConfigResult', message: 'Replaced invalid .vscode/mcp.json' }); + await this.copyMcpTokenToClipboard(); + void this._panel.webview.postMessage({ command: 'mcpConfigResult', message: 'Replaced invalid .vscode/mcp.json. Access token copied to clipboard, paste it when VS Code asks for it.' }); } return; } const mcpConfig = { + inputs: [tokenInput], servers: { - "edk2code": { - "type": "sse", - "url": expectedUrl - } + "edk2code": serverEntryValue } }; @@ -303,7 +325,13 @@ export class SettingsPanel { fs.mkdirSync(vscodePath, { recursive: true }); } fs.writeFileSync(mcpConfigPath, JSON.stringify(mcpConfig, null, 4), 'utf-8'); - void this._panel.webview.postMessage({ command: 'mcpConfigResult', message: 'Created .vscode/mcp.json' }); + await this.copyMcpTokenToClipboard(); + void this._panel.webview.postMessage({ command: 'mcpConfigResult', message: 'Created .vscode/mcp.json. Access token copied to clipboard, paste it when VS Code asks for it.' }); + } + + private async copyMcpTokenToClipboard(): Promise { + const token = await getOrCreateMcpToken(); + await vscode.env.clipboard.writeText(token); }