Skip to content

Commit 982822e

Browse files
authored
feat(vscode-ide-companion): harden ide-server with CORS and host validation (#8512)
1 parent 3d63e67 commit 982822e

2 files changed

Lines changed: 169 additions & 18 deletions

File tree

packages/vscode-ide-companion/src/ide-server.test.ts

Lines changed: 118 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import type * as vscode from 'vscode';
99
import * as fs from 'node:fs/promises';
1010
import type * as os from 'node:os';
1111
import * as path from 'node:path';
12+
import * as http from 'node:http';
1213
import { IDEServer } from './ide-server.js';
1314
import type { DiffManager } from './diff-manager.js';
1415

@@ -62,26 +63,26 @@ vi.mock('./open-files-manager', () => {
6263
return { OpenFilesManager };
6364
});
6465

66+
const getPortFromMock = (
67+
replaceMock: ReturnType<
68+
() => vscode.ExtensionContext['environmentVariableCollection']['replace']
69+
>,
70+
) => {
71+
const port = vi
72+
.mocked(replaceMock)
73+
.mock.calls.find((call) => call[0] === 'GEMINI_CLI_IDE_SERVER_PORT')?.[1];
74+
75+
if (port === undefined) {
76+
expect.fail('Port was not set');
77+
}
78+
return port;
79+
};
80+
6581
describe('IDEServer', () => {
6682
let ideServer: IDEServer;
6783
let mockContext: vscode.ExtensionContext;
6884
let mockLog: (message: string) => void;
6985

70-
const getPortFromMock = (
71-
replaceMock: ReturnType<
72-
() => vscode.ExtensionContext['environmentVariableCollection']['replace']
73-
>,
74-
) => {
75-
const port = vi
76-
.mocked(replaceMock)
77-
.mock.calls.find((call) => call[0] === 'GEMINI_CLI_IDE_SERVER_PORT')?.[1];
78-
79-
if (port === undefined) {
80-
expect.fail('Port was not set');
81-
}
82-
return port;
83-
};
84-
8586
beforeEach(() => {
8687
mockLog = vi.fn();
8788
ideServer = new IDEServer(mockLog, mocks.diffManager);
@@ -456,3 +457,105 @@ describe('IDEServer', () => {
456457
});
457458
});
458459
});
460+
461+
const request = (
462+
port: string,
463+
options: http.RequestOptions,
464+
body?: string,
465+
): Promise<http.IncomingMessage> =>
466+
new Promise((resolve, reject) => {
467+
const req = http.request(
468+
{
469+
hostname: '127.0.0.1',
470+
port,
471+
...options,
472+
},
473+
(res) => {
474+
res.resume(); // Consume response data to free up memory
475+
resolve(res);
476+
},
477+
);
478+
req.on('error', reject);
479+
if (body) {
480+
req.write(body);
481+
}
482+
req.end();
483+
});
484+
485+
describe('IDEServer HTTP endpoints', () => {
486+
let ideServer: IDEServer;
487+
let mockContext: vscode.ExtensionContext;
488+
let mockLog: (message: string) => void;
489+
let port: string;
490+
491+
beforeEach(async () => {
492+
mockLog = vi.fn();
493+
ideServer = new IDEServer(mockLog, mocks.diffManager);
494+
mockContext = {
495+
subscriptions: [],
496+
environmentVariableCollection: {
497+
replace: vi.fn(),
498+
clear: vi.fn(),
499+
},
500+
} as unknown as vscode.ExtensionContext;
501+
await ideServer.start(mockContext);
502+
const replaceMock = mockContext.environmentVariableCollection.replace;
503+
port = getPortFromMock(replaceMock);
504+
});
505+
506+
afterEach(async () => {
507+
await ideServer.stop();
508+
vi.restoreAllMocks();
509+
});
510+
511+
it('should deny requests with an origin header', async () => {
512+
const response = await request(
513+
port,
514+
{
515+
path: '/mcp',
516+
method: 'POST',
517+
headers: {
518+
Host: `localhost:${port}`,
519+
Origin: 'https://evil.com',
520+
'Content-Type': 'application/json',
521+
},
522+
},
523+
JSON.stringify({ jsonrpc: '2.0', method: 'initialize' }),
524+
);
525+
expect(response.statusCode).toBe(403);
526+
});
527+
528+
it('should deny requests with an invalid host header', async () => {
529+
const response = await request(
530+
port,
531+
{
532+
path: '/mcp',
533+
method: 'POST',
534+
headers: {
535+
Host: 'evil.com',
536+
'Content-Type': 'application/json',
537+
},
538+
},
539+
JSON.stringify({ jsonrpc: '2.0', method: 'initialize' }),
540+
);
541+
expect(response.statusCode).toBe(403);
542+
});
543+
544+
it('should allow requests with a valid host header', async () => {
545+
const response = await request(
546+
port,
547+
{
548+
path: '/mcp',
549+
method: 'POST',
550+
headers: {
551+
Host: `localhost:${port}`,
552+
'Content-Type': 'application/json',
553+
},
554+
},
555+
JSON.stringify({ jsonrpc: '2.0', method: 'initialize' }),
556+
);
557+
// We expect a 400 here because we are not sending a valid MCP request,
558+
// but it's not a host error, which is what we are testing.
559+
expect(response.statusCode).toBe(400);
560+
});
561+
});

packages/vscode-ide-companion/src/ide-server.ts

Lines changed: 51 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,12 @@ import {
1313
import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
1414
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
1515
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
16-
import express, { type Request, type Response } from 'express';
16+
import express, {
17+
type Request,
18+
type Response,
19+
type NextFunction,
20+
} from 'express';
21+
import cors from 'cors';
1722
import { randomUUID } from 'node:crypto';
1823
import { type Server as HTTPServer } from 'node:http';
1924
import * as path from 'node:path';
@@ -23,6 +28,13 @@ import type { z } from 'zod';
2328
import type { DiffManager } from './diff-manager.js';
2429
import { OpenFilesManager } from './open-files-manager.js';
2530

31+
class CORSError extends Error {
32+
constructor(message: string) {
33+
super(message);
34+
this.name = 'CORSError';
35+
}
36+
}
37+
2638
const MCP_SESSION_ID_HEADER = 'mcp-session-id';
2739
const IDE_SERVER_PORT_ENV_VAR = 'GEMINI_CLI_IDE_SERVER_PORT';
2840
const IDE_WORKSPACE_PATH_ENV_VAR = 'GEMINI_CLI_IDE_WORKSPACE_PATH';
@@ -131,6 +143,34 @@ export class IDEServer {
131143

132144
const app = express();
133145
app.use(express.json({ limit: '10mb' }));
146+
147+
app.use(
148+
cors({
149+
origin: (origin, callback) => {
150+
// Only allow non-browser requests with no origin.
151+
if (!origin) {
152+
return callback(null, true);
153+
}
154+
return callback(
155+
new CORSError('Request denied by CORS policy.'),
156+
false,
157+
);
158+
},
159+
}),
160+
);
161+
162+
app.use((req, res, next) => {
163+
const host = req.headers.host || '';
164+
const allowedHosts = [
165+
`localhost:${this.port}`,
166+
`127.0.0.1:${this.port}`,
167+
];
168+
if (!allowedHosts.includes(host)) {
169+
return res.status(403).json({ error: 'Invalid Host header' });
170+
}
171+
next();
172+
});
173+
134174
app.use((req, res, next) => {
135175
const authHeader = req.headers.authorization;
136176
if (authHeader) {
@@ -274,7 +314,15 @@ export class IDEServer {
274314

275315
app.get('/mcp', handleSessionRequest);
276316

277-
this.server = app.listen(0, async () => {
317+
app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
318+
if (err instanceof CORSError) {
319+
res.status(403).json({ error: 'Request denied by CORS policy.' });
320+
} else {
321+
next(err);
322+
}
323+
});
324+
325+
this.server = app.listen(0, '127.0.0.1', async () => {
278326
const address = (this.server as HTTPServer).address();
279327
if (address && typeof address !== 'string') {
280328
this.port = address.port;
@@ -286,7 +334,7 @@ export class IDEServer {
286334
os.tmpdir(),
287335
`gemini-ide-server-${process.ppid}.json`,
288336
);
289-
this.log(`IDE server listening on port ${this.port}`);
337+
this.log(`IDE server listening on http://127.0.0.1:${this.port}`);
290338

291339
if (this.authToken) {
292340
await writePortAndWorkspace({

0 commit comments

Comments
 (0)