diff --git a/package-lock.json b/package-lock.json index 0a52bbe..5a0eddd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "@types/express": "^4.17.21", "@types/jest": "^30.0.0", "@types/node": "^16.11.6", + "@types/supertest": "^6.0.3", "@typescript-eslint/eslint-plugin": "5.29.0", "@typescript-eslint/parser": "5.29.0", "builtin-modules": "3.3.0", @@ -25,6 +26,7 @@ "esbuild": "0.17.3", "jest": "^30.2.0", "obsidian": "latest", + "supertest": "^7.1.4", "ts-jest": "^29.4.5", "tslib": "2.4.0", "typescript": "4.7.4" @@ -1799,6 +1801,19 @@ "@tybys/wasm-util": "^0.10.0" } }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -1837,6 +1852,16 @@ "node": ">= 8" } }, + "node_modules/@paralleldrive/cuid2": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz", + "integrity": "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@noble/hashes": "^1.1.5" + } + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -2014,6 +2039,13 @@ "@types/node": "*" } }, + "node_modules/@types/cookiejar": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz", + "integrity": "sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/cors": { "version": "2.8.19", "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", @@ -2126,6 +2158,13 @@ "@types/node": "*" } }, + "node_modules/@types/methods": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz", + "integrity": "sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/mime": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", @@ -2204,6 +2243,30 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/superagent": { + "version": "8.1.9", + "resolved": "https://registry.npmjs.org/@types/superagent/-/superagent-8.1.9.tgz", + "integrity": "sha512-pTVjI73witn+9ILmoJdajHGW2jkSaOzhiFYF1Rd3EQ94kymLqB9PjD9ISg7WaALC7+dCHT0FGe9T2LktLq/3GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/cookiejar": "^2.1.5", + "@types/methods": "^1.1.4", + "@types/node": "*", + "form-data": "^4.0.0" + } + }, + "node_modules/@types/supertest": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@types/supertest/-/supertest-6.0.3.tgz", + "integrity": "sha512-8WzXq62EXFhJ7QsH3Ocb/iKQ/Ty9ZVWnVzoTKc9tyyFRRF3a74Tk2+TLFgaFFw364Ere+npzHKEJ6ga2LzIL7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/methods": "^1.1.4", + "@types/superagent": "^8.1.0" + } + }, "node_modules/@types/tern": { "version": "0.23.9", "resolved": "https://registry.npmjs.org/@types/tern/-/tern-0.23.9.tgz", @@ -2859,6 +2922,20 @@ "node": ">=8" } }, + "node_modules/asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", + "dev": true, + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/babel-jest": { "version": "30.2.0", "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.2.0.tgz", @@ -3374,6 +3451,29 @@ "dev": true, "license": "MIT" }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/component-emitter": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", + "integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -3424,6 +3524,13 @@ "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", "license": "MIT" }, + "node_modules/cookiejar": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz", + "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==", + "dev": true, + "license": "MIT" + }, "node_modules/cors": { "version": "2.8.5", "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", @@ -3588,6 +3695,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -3625,6 +3742,17 @@ "license": "MIT", "optional": true }, + "node_modules/dezalgo": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", + "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==", + "dev": true, + "license": "ISC", + "dependencies": { + "asap": "^2.0.0", + "wrappy": "1" + } + }, "node_modules/dir-glob": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", @@ -3804,6 +3932,22 @@ "node": ">= 0.4" } }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/es6-error": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz", @@ -4336,6 +4480,13 @@ "license": "MIT", "peer": true }, + "node_modules/fast-safe-stringify": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", + "dev": true, + "license": "MIT" + }, "node_modules/fastq": { "version": "1.19.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", @@ -4498,6 +4649,41 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/form-data": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", + "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/formidable": { + "version": "3.5.4", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz", + "integrity": "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@paralleldrive/cuid2": "^2.2.2", + "dezalgo": "^1.0.4", + "once": "^1.4.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "url": "https://ko-fi.com/tunnckoCore/commissions" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -4871,6 +5057,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/hasown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", @@ -7558,6 +7760,54 @@ "node": ">= 8.0" } }, + "node_modules/superagent": { + "version": "10.2.3", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-10.2.3.tgz", + "integrity": "sha512-y/hkYGeXAj7wUMjxRbB21g/l6aAEituGXM9Rwl4o20+SX3e8YOSV6BxFXl+dL3Uk0mjSL3kCbNkwURm8/gEDig==", + "dev": true, + "license": "MIT", + "dependencies": { + "component-emitter": "^1.3.1", + "cookiejar": "^2.1.4", + "debug": "^4.3.7", + "fast-safe-stringify": "^2.1.1", + "form-data": "^4.0.4", + "formidable": "^3.5.4", + "methods": "^1.1.2", + "mime": "2.6.0", + "qs": "^6.11.2" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/superagent/node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/supertest": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/supertest/-/supertest-7.1.4.tgz", + "integrity": "sha512-tjLPs7dVyqgItVFirHYqe2T+MfWc2VOBQ8QFKKbWTA3PU7liZR8zoSpAi/C1k1ilm9RsXIKYf197oap9wXGVYg==", + "dev": true, + "license": "MIT", + "dependencies": { + "methods": "^1.1.2", + "superagent": "^10.2.3" + }, + "engines": { + "node": ">=14.18.0" + } + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", diff --git a/package.json b/package.json index 830783c..12ef303 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "@types/express": "^4.17.21", "@types/jest": "^30.0.0", "@types/node": "^16.11.6", + "@types/supertest": "^6.0.3", "@typescript-eslint/eslint-plugin": "5.29.0", "@typescript-eslint/parser": "5.29.0", "builtin-modules": "3.3.0", @@ -37,6 +38,7 @@ "esbuild": "0.17.3", "jest": "^30.2.0", "obsidian": "latest", + "supertest": "^7.1.4", "ts-jest": "^29.4.5", "tslib": "2.4.0", "typescript": "4.7.4" diff --git a/src/server/middleware.ts b/src/server/middleware.ts index 7cae095..dda6c88 100644 --- a/src/server/middleware.ts +++ b/src/server/middleware.ts @@ -8,52 +8,51 @@ export function setupMiddleware(app: Express, settings: MCPServerSettings, creat // Parse JSON bodies app.use(express.json()); - // CORS configuration - if (settings.enableCORS) { - const corsOptions = { - origin: (origin: string | undefined, callback: (err: Error | null, allow?: boolean) => void) => { - // Allow requests with no origin (like mobile apps or curl requests) - if (!origin) return callback(null, true); - - if (settings.allowedOrigins.includes('*') || - settings.allowedOrigins.includes(origin)) { - callback(null, true); - } else { - callback(new Error('Not allowed by CORS')); - } - }, - credentials: true - }; - app.use(cors(corsOptions)); - } + // CORS configuration - Always enabled with fixed localhost-only policy + const corsOptions = { + origin: (origin: string | undefined, callback: (err: Error | null, allow?: boolean) => void) => { + // Allow requests with no origin (like CLI clients, curl, MCP SDKs) + if (!origin) { + return callback(null, true); + } - // Authentication middleware - if (settings.enableAuth) { - app.use((req: Request, res: Response, next: any) => { - // Defensive check: if auth is enabled but no API key is set, reject all requests - if (!settings.apiKey || settings.apiKey.trim() === '') { - return res.status(500).json(createErrorResponse(null, ErrorCodes.InternalError, 'Server misconfigured: Authentication enabled but no API key set')); + // Allow localhost and 127.0.0.1 on any port, both HTTP and HTTPS + const localhostRegex = /^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/; + if (localhostRegex.test(origin)) { + callback(null, true); + } else { + callback(new Error('Not allowed by CORS')); } - - const authHeader = req.headers.authorization; - const apiKey = authHeader?.replace('Bearer ', ''); - - if (apiKey !== settings.apiKey) { - return res.status(401).json(createErrorResponse(null, ErrorCodes.InvalidRequest, 'Unauthorized')); - } - next(); - }); - } + }, + credentials: true + }; + app.use(cors(corsOptions)); + + // Authentication middleware - Always enabled + app.use((req: Request, res: Response, next: any) => { + // Defensive check: if no API key is set, reject all requests + if (!settings.apiKey || settings.apiKey.trim() === '') { + return res.status(500).json(createErrorResponse(null, ErrorCodes.InternalError, 'Server misconfigured: No API key set')); + } + + const authHeader = req.headers.authorization; + const providedKey = authHeader?.replace('Bearer ', ''); + + if (providedKey !== settings.apiKey) { + return res.status(401).json(createErrorResponse(null, ErrorCodes.InvalidRequest, 'Unauthorized')); + } + next(); + }); // Origin validation for security (DNS rebinding protection) app.use((req: Request, res: Response, next: any) => { const host = req.headers.host; - + // Only allow localhost connections if (host && !host.startsWith('localhost') && !host.startsWith('127.0.0.1')) { return res.status(403).json(createErrorResponse(null, ErrorCodes.InvalidRequest, 'Only localhost connections allowed')); } - + next(); }); } diff --git a/src/settings.ts b/src/settings.ts index b514ab1..caa02ad 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -57,38 +57,6 @@ export class MCPServerSettingTab extends PluginSettingTab { } })); - // CORS setting - new Setting(containerEl) - .setName('Enable CORS') - .setDesc('Enable Cross-Origin Resource Sharing (requires restart)') - .addToggle(toggle => toggle - .setValue(this.plugin.settings.enableCORS) - .onChange(async (value) => { - this.plugin.settings.enableCORS = value; - await this.plugin.saveSettings(); - if (this.plugin.mcpServer?.isRunning()) { - new Notice('⚠️ Server restart required for CORS changes to take effect'); - } - })); - - // Allowed origins - new Setting(containerEl) - .setName('Allowed origins') - .setDesc('Comma-separated list of allowed origins (* for all, requires restart)') - .addText(text => text - .setPlaceholder('*') - .setValue(this.plugin.settings.allowedOrigins.join(', ')) - .onChange(async (value) => { - this.plugin.settings.allowedOrigins = value - .split(',') - .map(s => s.trim()) - .filter(s => s.length > 0); - await this.plugin.saveSettings(); - if (this.plugin.mcpServer?.isRunning()) { - new Notice('⚠️ Server restart required for origin changes to take effect'); - } - })); - // Authentication new Setting(containerEl) .setName('Enable authentication') diff --git a/tests/middleware.test.ts b/tests/middleware.test.ts new file mode 100644 index 0000000..51eaeea --- /dev/null +++ b/tests/middleware.test.ts @@ -0,0 +1,180 @@ +import express, { Express } from 'express'; +import request from 'supertest'; +import { setupMiddleware } from '../src/server/middleware'; +import { MCPServerSettings } from '../src/types/settings-types'; +import { ErrorCodes } from '../src/types/mcp-types'; + +describe('Middleware', () => { + let app: Express; + const mockCreateError = jest.fn((id, code, message) => ({ + jsonrpc: '2.0', + id, + error: { code, message } + })); + + const createTestSettings = (overrides?: Partial): MCPServerSettings => ({ + port: 3000, + apiKey: 'test-api-key-12345', + enableAuth: true, + ...overrides + }); + + beforeEach(() => { + app = express(); + mockCreateError.mockClear(); + }); + + describe('CORS', () => { + it('should allow localhost origin on any port', async () => { + setupMiddleware(app, createTestSettings(), mockCreateError); + app.get('/test', (req, res) => res.json({ ok: true })); + + const response = await request(app) + .get('/test') + .set('Origin', 'http://localhost:8080') + .set('Host', 'localhost:3000') + .set('Authorization', 'Bearer test-api-key-12345'); + + expect(response.headers['access-control-allow-origin']).toBe('http://localhost:8080'); + }); + + it('should allow 127.0.0.1 origin on any port', async () => { + setupMiddleware(app, createTestSettings(), mockCreateError); + app.get('/test', (req, res) => res.json({ ok: true })); + + const response = await request(app) + .get('/test') + .set('Origin', 'http://127.0.0.1:9000') + .set('Host', '127.0.0.1:3000') + .set('Authorization', 'Bearer test-api-key-12345'); + + expect(response.headers['access-control-allow-origin']).toBe('http://127.0.0.1:9000'); + }); + + it('should allow https localhost origins', async () => { + setupMiddleware(app, createTestSettings(), mockCreateError); + app.get('/test', (req, res) => res.json({ ok: true })); + + const response = await request(app) + .get('/test') + .set('Origin', 'https://localhost:443') + .set('Host', 'localhost:3000') + .set('Authorization', 'Bearer test-api-key-12345'); + + expect(response.headers['access-control-allow-origin']).toBe('https://localhost:443'); + }); + + it('should reject non-localhost origins', async () => { + setupMiddleware(app, createTestSettings(), mockCreateError); + app.get('/test', (req, res) => res.json({ ok: true })); + + const response = await request(app) + .get('/test') + .set('Origin', 'http://evil.com') + .set('Host', 'localhost:3000') + .set('Authorization', 'Bearer test-api-key-12345'); + + expect(response.status).toBe(500); // CORS error + }); + + it('should allow requests with no origin (CLI clients)', async () => { + setupMiddleware(app, createTestSettings(), mockCreateError); + app.get('/test', (req, res) => res.json({ ok: true })); + + const response = await request(app) + .get('/test') + .set('Host', 'localhost:3000') + .set('Authorization', 'Bearer test-api-key-12345'); + + expect(response.status).toBe(200); + }); + }); + + describe('Authentication', () => { + it('should require Bearer token when auth enabled', async () => { + setupMiddleware(app, createTestSettings({ enableAuth: true }), mockCreateError); + app.post('/mcp', (req, res) => res.json({ ok: true })); + + const response = await request(app) + .post('/mcp') + .set('Host', 'localhost:3000'); + + expect(response.status).toBe(401); + }); + + it('should accept valid Bearer token', async () => { + setupMiddleware(app, createTestSettings({ enableAuth: true, apiKey: 'secret123' }), mockCreateError); + app.post('/mcp', (req, res) => res.json({ ok: true })); + + const response = await request(app) + .post('/mcp') + .set('Host', 'localhost:3000') + .set('Authorization', 'Bearer secret123'); + + expect(response.status).toBe(200); + }); + + it('should reject invalid Bearer token', async () => { + setupMiddleware(app, createTestSettings({ enableAuth: true, apiKey: 'secret123' }), mockCreateError); + app.post('/mcp', (req, res) => res.json({ ok: true })); + + const response = await request(app) + .post('/mcp') + .set('Host', 'localhost:3000') + .set('Authorization', 'Bearer wrong-token'); + + expect(response.status).toBe(401); + }); + + it('should reject requests when API key is empty', async () => { + setupMiddleware(app, createTestSettings({ apiKey: '' }), mockCreateError); + app.post('/mcp', (req, res) => res.json({ ok: true })); + + const response = await request(app) + .post('/mcp') + .set('Host', 'localhost:3000') + .set('Authorization', 'Bearer any-token'); + + expect(response.status).toBe(500); + expect(response.body.error.message).toContain('No API key set'); + }); + }); + + describe('Host validation', () => { + it('should allow localhost host header', async () => { + setupMiddleware(app, createTestSettings(), mockCreateError); + app.get('/test', (req, res) => res.json({ ok: true })); + + const response = await request(app) + .get('/test') + .set('Host', 'localhost:3000') + .set('Authorization', 'Bearer test-api-key-12345'); + + expect(response.status).toBe(200); + }); + + it('should allow 127.0.0.1 host header', async () => { + setupMiddleware(app, createTestSettings(), mockCreateError); + app.get('/test', (req, res) => res.json({ ok: true })); + + const response = await request(app) + .get('/test') + .set('Host', '127.0.0.1:3000') + .set('Authorization', 'Bearer test-api-key-12345'); + + expect(response.status).toBe(200); + }); + + it('should reject non-localhost host header', async () => { + setupMiddleware(app, createTestSettings(), mockCreateError); + app.get('/test', (req, res) => res.json({ ok: true })); + + const response = await request(app) + .get('/test') + .set('Host', 'evil.com') + .set('Authorization', 'Bearer test-api-key-12345'); + + expect(response.status).toBe(403); + }); + }); +});