diff --git a/package-lock.json b/package-lock.json index 0c8d715..554f23b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,9 @@ "dependencies": { "@modelcontextprotocol/sdk": "^1.12.3", "axios": "^1.15.0", + "cors": "^2.8.5", "dotenv": "^16.5.0", + "express": "^5.2.1", "node-html-markdown": "^1.3.0", "tsx": "^4.21.0" }, @@ -25,6 +27,7 @@ "@semantic-release/git": "^10.0.1", "@semantic-release/github": "^12.0.6", "@semantic-release/npm": "^13.1.5", + "@types/cors": "^2.8.17", "@types/express": "^5.0.1", "@types/jest": "^30.0.0", "@types/node": "^24.0.0", @@ -5105,6 +5108,16 @@ "@types/node": "*" } }, + "node_modules/@types/cors": { + "version": "2.8.19", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", + "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/esrecurse": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", diff --git a/package.json b/package.json index 8720cb4..2a9a7ea 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,9 @@ "dependencies": { "@modelcontextprotocol/sdk": "^1.12.3", "axios": "^1.15.0", + "cors": "^2.8.5", "dotenv": "^16.5.0", + "express": "^5.2.1", "node-html-markdown": "^1.3.0", "tsx": "^4.21.0" }, @@ -40,6 +42,7 @@ "@semantic-release/git": "^10.0.1", "@semantic-release/github": "^12.0.6", "@semantic-release/npm": "^13.1.5", + "@types/cors": "^2.8.17", "@types/express": "^5.0.1", "@types/jest": "^30.0.0", "@types/node": "^24.0.0", diff --git a/src/server.ts b/src/server.ts index 1f650e2..95f261a 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,11 +1,14 @@ import 'dotenv/config'; +import cors from 'cors'; import express from 'express'; import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; +import { corsOptions } from './server/cors'; import { ScraperAPIHttpServer } from './server/sapi-http-server'; import { resolveToolsets } from './utils'; const app = express(); +app.use(cors(corsOptions)); app.use(express.json()); app.get('/mcp', (_req, res) => { diff --git a/src/server/__tests__/cors.test.ts b/src/server/__tests__/cors.test.ts new file mode 100644 index 0000000..5943fb5 --- /dev/null +++ b/src/server/__tests__/cors.test.ts @@ -0,0 +1,83 @@ +import cors from 'cors'; +import express from 'express'; +import { corsOptions, isAllowedOrigin } from '../cors'; + +describe('CORS origin allowlist', () => { + it('allows requests with no origin', () => { + expect(isAllowedOrigin(undefined)).toBe(true); + }); + + it('allows claude.ai and subdomains', () => { + expect(isAllowedOrigin('https://claude.ai')).toBe(true); + expect(isAllowedOrigin('https://www.claude.ai')).toBe(true); + }); + + it('allows anthropic.com and subdomains', () => { + expect(isAllowedOrigin('https://anthropic.com')).toBe(true); + expect(isAllowedOrigin('https://api.anthropic.com')).toBe(true); + }); + + it('allows local development origins', () => { + expect(isAllowedOrigin('http://localhost:3000')).toBe(true); + expect(isAllowedOrigin('http://127.0.0.1:6274')).toBe(true); + }); + + it('rejects unknown origins', () => { + expect(isAllowedOrigin('https://evil.com')).toBe(false); + expect(isAllowedOrigin('https://claude.ai.evil.com')).toBe(false); + }); +}); + +describe('CORS middleware behavior', () => { + const withTestServer = async ( + run: (port: number) => Promise + ): Promise => { + const app = express(); + app.use(cors(corsOptions)); + app.get('/mcp', (_req, res) => { + res.status(200).send('ok'); + }); + + const server = app.listen(0); + const port = (server.address() as { port: number }).port; + + try { + await run(port); + } finally { + await new Promise((resolve, reject) => { + server.close((error) => { + if (error) { + reject(error); + return; + } + + resolve(); + }); + }); + } + }; + + it('does not return 500 for disallowed origins', async () => { + await withTestServer(async (port) => { + const response = await fetch(`http://127.0.0.1:${port}/mcp`, { + headers: { Origin: 'https://evil.com' }, + }); + + expect(response.status).toBe(200); + expect(response.headers.get('access-control-allow-origin')).toBeNull(); + }); + }); + + it('returns CORS headers for allowed origins', async () => { + await withTestServer(async (port) => { + const response = await fetch(`http://127.0.0.1:${port}/mcp`, { + headers: { Origin: 'https://claude.ai' }, + }); + + expect(response.status).toBe(200); + expect(response.headers.get('access-control-allow-origin')).toBe( + 'https://claude.ai' + ); + }); + }); +}); diff --git a/src/server/cors.ts b/src/server/cors.ts new file mode 100644 index 0000000..11a1e06 --- /dev/null +++ b/src/server/cors.ts @@ -0,0 +1,31 @@ +import cors from 'cors'; + +const ALLOWED_ORIGIN_PATTERNS = [ + /^https:\/\/([a-z0-9-]+\.)*claude\.ai$/, + /^https:\/\/([a-z0-9-]+\.)*anthropic\.com$/, + /^http:\/\/localhost(:\d+)?$/, + /^http:\/\/127\.0\.0\.1(:\d+)?$/, +]; + +export const isAllowedOrigin = (origin: string | undefined): boolean => { + if (!origin) { + return true; + } + + return ALLOWED_ORIGIN_PATTERNS.some((pattern) => pattern.test(origin)); +}; + +export const corsOptions: cors.CorsOptions = { + origin: (origin, callback) => { + if (isAllowedOrigin(origin)) { + callback(null, true); + return; + } + + callback(null, false); + }, + methods: ['GET', 'POST', 'OPTIONS'], + allowedHeaders: ['Content-Type', 'Authorization', 'Mcp-Session-Id'], + exposedHeaders: ['Mcp-Session-Id'], + maxAge: 86400, +}; diff --git a/src/utils/__tests__/progress.test.ts b/src/utils/__tests__/progress.test.ts index 9125fa0..4af1e66 100644 --- a/src/utils/__tests__/progress.test.ts +++ b/src/utils/__tests__/progress.test.ts @@ -87,7 +87,9 @@ describe('ProgressNotifier', () => { }, }); - if (timeout) clearTimeout(timeout); + if (timeout) { + clearTimeout(timeout); + } jest.useRealTimers(); });