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); }