From 1c3994e6280d503782d7a05ae19eee50a18e3f00 Mon Sep 17 00:00:00 2001 From: Thomas Espach Date: Mon, 15 Jun 2026 15:44:20 +0000 Subject: [PATCH 1/2] chore(http): tighten default config for HTTP transport - BIND_ADDRESS defaults to 127.0.0.1 (was 0.0.0.0) - ALLOWED_ORIGINS defaults to empty (was "*") - Add ALLOWED_HOSTS for Host header allowlisting - Reject Origin: null unless explicitly allowlisted - Log a warning at startup when BIND_ADDRESS=0.0.0.0 or ALLOWED_ORIGINS=* - Rename :public scripts to :UNSAFE-public to make config visible - Add SECURITY.md and configuration notes - Refactor http.ts to export createHttpApp() for testability - Add regression tests for CORS, Origin: null, and Host allowlist --- README.md | 5 +- SECURITY.md | 34 +++++ package.json | 4 +- src/http.config.test.ts | 288 ++++++++++++++++++++++++++++++++++++++++ src/http.ts | 243 +++++++++++++++++++++++++-------- 5 files changed, 511 insertions(+), 63 deletions(-) create mode 100644 SECURITY.md create mode 100644 src/http.config.test.ts diff --git a/README.md b/README.md index 49db451..5e2057d 100644 --- a/README.md +++ b/README.md @@ -129,8 +129,9 @@ For cloud or shared deployments, run the server in HTTP mode. | `PERPLEXITY_API_KEY` | Your Perplexity API key | *Required* | | `PERPLEXITY_BASE_URL` | Custom base URL for API requests | `https://api.perplexity.ai` | | `PORT` | HTTP server port | `8080` | -| `BIND_ADDRESS` | Network interface to bind to | `0.0.0.0` | -| `ALLOWED_ORIGINS` | CORS origins (comma-separated) | `*` | +| `BIND_ADDRESS` | Network interface to bind to. Defaults to loopback. Set to `0.0.0.0` to expose on all interfaces. | `127.0.0.1` | +| `ALLOWED_ORIGINS` | CORS origins (comma-separated). Defaults to empty (no cross-origin browser requests). Set to an explicit allowlist (e.g. `https://app.example.com`) or to `*` to allow any origin. | *(empty)* | +| `ALLOWED_HOSTS` | Additional `Host` header values to accept (comma-separated). Loopback hosts on `PORT` are always allowed. Add the public hostname when binding to `0.0.0.0`. | *(loopback only)* | #### Docker diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..d7eef34 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,34 @@ +# Security Policy + +## Reporting a Vulnerability + +Please report security vulnerabilities **privately** via one of: + +- GitHub Security Advisory: [Report a vulnerability](https://github.com/perplexityai/modelcontextprotocol/security/advisories/new) +- Email: `security@perplexity.ai` + +Please do not open a public issue, draft PR, or discussion for security reports. + +## Supported Versions + +Only the latest minor of `@perplexity-ai/mcp-server` on npm is supported with security fixes. Operators should pin to the latest patch within that minor. + +## Security model of the HTTP transport + +The HTTP transport in `src/http.ts` exposes the MCP server over `POST /mcp`. The server authenticates **outbound** calls to `api.perplexity.ai` using the `PERPLEXITY_API_KEY` env var. It does **not** authenticate **inbound** callers. Any process or page that can reach `/mcp` can therefore consume the operator's API quota and read tool responses. + +For this reason the defaults are loopback-only and deny-all: + +| Setting | Default | Why | +|---|---|---| +| `BIND_ADDRESS` | `127.0.0.1` | Loopback only — not reachable from the LAN or the internet. | +| `ALLOWED_ORIGINS` | *(empty)* | Reject all cross-origin browser requests by default. | +| `ALLOWED_HOSTS` | loopback only | Reject requests whose `Host` header doesn't match a known loopback name. | + +If you need to expose the server beyond loopback you should configure an explicit `ALLOWED_ORIGINS` allowlist and an explicit `ALLOWED_HOSTS` allowlist, and ideally front the server with a reverse proxy that enforces authentication. + +## Configuration notes + +- **`ALLOWED_ORIGINS=*`** — the `cors` middleware will reflect the requesting `Origin` header back into `Access-Control-Allow-Origin` rather than emitting a literal `*`. The server emits a startup warning when this is set. +- **`BIND_ADDRESS=0.0.0.0`** — exposes the server on every network interface. The server emits a startup warning when this is set. The `start:http:UNSAFE-public` / `dev:http:UNSAFE-public` npm scripts are intentionally named to make the configuration visible. +- **Sandboxed / `file://` callers** — these send `Origin: null`. The CORS handler rejects `null` unless `"null"` is explicitly present in `ALLOWED_ORIGINS`. diff --git a/package.json b/package.json index c34f6d4..b5bf6cc 100644 --- a/package.json +++ b/package.json @@ -40,10 +40,10 @@ "watch": "tsc --watch", "start": "node dist/index.js", "start:http": "node dist/http.js", - "start:http:public": "BIND_ADDRESS=0.0.0.0 ALLOWED_ORIGINS=* node dist/http.js", + "start:http:UNSAFE-public": "BIND_ADDRESS=0.0.0.0 ALLOWED_ORIGINS=* node dist/http.js", "dev": "tsx src/index.ts", "dev:http": "tsx src/http.ts", - "dev:http:public": "BIND_ADDRESS=0.0.0.0 ALLOWED_ORIGINS=* tsx src/http.ts", + "dev:http:UNSAFE-public": "BIND_ADDRESS=0.0.0.0 ALLOWED_ORIGINS=* tsx src/http.ts", "test": "vitest run", "test:watch": "vitest", "test:coverage": "vitest run --coverage" diff --git a/src/http.config.test.ts b/src/http.config.test.ts new file mode 100644 index 0000000..ae23b37 --- /dev/null +++ b/src/http.config.test.ts @@ -0,0 +1,288 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import type { Server } from "http"; +import { createHttpApp, buildAllowedHosts } from "./http.js"; + +/** + * Tests for the HTTP transport's CORS, bind, and Host header configuration. + * Covers: CORS allowlist behavior, Origin: null handling, and the Host + * header allowlist. See SECURITY.md for the configuration model. + */ +describe("HTTP transport configuration", () => { + let httpServer: Server; + let baseUrl: string; + let port: number; + + const originalEnv = { ...process.env }; + + beforeEach(() => { + process.env.PERPLEXITY_API_KEY = "test-api-key"; + }); + + afterEach(async () => { + process.env = { ...originalEnv }; + if (httpServer) { + await new Promise((resolve) => { + httpServer.close(() => resolve()); + }); + } + }); + + function start(opts: { + allowedOrigins?: string[]; + extraAllowedHosts?: string[]; + } = {}): Promise { + const allowedOrigins = opts.allowedOrigins ?? []; + const app = createHttpApp({ + port: 0, + bindAddress: "127.0.0.1", + allowedOrigins, + // We don't know the port yet; build with placeholder, then rebuild + // below once the OS has assigned one. + allowedHosts: new Set(), + }); + return new Promise((resolve) => { + httpServer = app.listen(0, "127.0.0.1", () => { + const addr = httpServer.address(); + port = typeof addr === "object" && addr ? addr.port : 0; + baseUrl = `http://127.0.0.1:${port}`; + resolve(); + }); + }).then(() => { + // Rebuild with the assigned port so the Host allowlist matches. + httpServer.close(); + return new Promise((resolve) => { + const app2 = createHttpApp({ + port, + bindAddress: "127.0.0.1", + allowedOrigins, + allowedHosts: buildAllowedHosts(port, opts.extraAllowedHosts ?? []), + }); + httpServer = app2.listen(port, "127.0.0.1", () => { + baseUrl = `http://127.0.0.1:${port}`; + resolve(); + }); + }); + }); + } + + describe("CORS allowlist (deny-by-default)", () => { + it("does not reflect a foreign Origin when ALLOWED_ORIGINS is empty", async () => { + await start({ allowedOrigins: [] }); + + // Preflight from an unrelated origin. + const preflight = await fetch(`${baseUrl}/mcp`, { + method: "OPTIONS", + headers: { + Origin: "https://other.example", + "Access-Control-Request-Method": "POST", + "Access-Control-Request-Headers": "content-type", + }, + }); + + // cors middleware rejects with no ACAO header (and typically 500/204 + // depending on version). The assertion is that no Origin is reflected. + expect(preflight.headers.get("access-control-allow-origin")).toBeNull(); + }); + + it("allows an explicitly allowlisted origin", async () => { + await start({ allowedOrigins: ["https://app.example"] }); + + const preflight = await fetch(`${baseUrl}/mcp`, { + method: "OPTIONS", + headers: { + Origin: "https://app.example", + "Access-Control-Request-Method": "POST", + "Access-Control-Request-Headers": "content-type", + }, + }); + + expect(preflight.headers.get("access-control-allow-origin")).toBe( + "https://app.example", + ); + }); + + it("does not allow a non-allowlisted origin even when others are allowlisted", async () => { + await start({ allowedOrigins: ["https://app.example"] }); + + const preflight = await fetch(`${baseUrl}/mcp`, { + method: "OPTIONS", + headers: { + Origin: "https://other.example", + "Access-Control-Request-Method": "POST", + "Access-Control-Request-Headers": "content-type", + }, + }); + + expect(preflight.headers.get("access-control-allow-origin")).toBeNull(); + }); + + it("rejects Origin: null by default", async () => { + await start({ allowedOrigins: [] }); + + const preflight = await fetch(`${baseUrl}/mcp`, { + method: "OPTIONS", + headers: { + Origin: "null", + "Access-Control-Request-Method": "POST", + "Access-Control-Request-Headers": "content-type", + }, + }); + + expect(preflight.headers.get("access-control-allow-origin")).toBeNull(); + }); + + it("allows Origin: null only when explicitly opted in", async () => { + await start({ allowedOrigins: ["null"] }); + + const preflight = await fetch(`${baseUrl}/mcp`, { + method: "OPTIONS", + headers: { + Origin: "null", + "Access-Control-Request-Method": "POST", + "Access-Control-Request-Headers": "content-type", + }, + }); + + expect(preflight.headers.get("access-control-allow-origin")).toBe("null"); + }); + + it("with ALLOWED_ORIGINS=* reflects the requesting origin", async () => { + // Permissive mode is supported but emits a startup warning. + await start({ allowedOrigins: ["*"] }); + + const preflight = await fetch(`${baseUrl}/mcp`, { + method: "OPTIONS", + headers: { + Origin: "https://anything.example", + "Access-Control-Request-Method": "POST", + "Access-Control-Request-Headers": "content-type", + }, + }); + + expect(preflight.headers.get("access-control-allow-origin")).toBe( + "https://anything.example", + ); + }); + }); + + describe("Host header allowlist", () => { + it("rejects requests with a foreign Host header", async () => { + await start({ allowedOrigins: [] }); + + // Use an undici-style fetch via raw Node http to set Host explicitly, + // since `fetch` may rewrite Host based on URL. + const http = await import("node:http"); + const body = JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "tools/list", + params: {}, + }); + + const status = await new Promise((resolve, reject) => { + const req = http.request( + { + hostname: "127.0.0.1", + port, + path: "/mcp", + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json, text/event-stream", + "Content-Length": Buffer.byteLength(body), + Host: "external.example", + }, + }, + (res) => { + res.resume(); + res.on("end", () => resolve(res.statusCode ?? 0)); + }, + ); + req.on("error", reject); + req.write(body); + req.end(); + }); + + expect(status).toBe(421); + }); + + it("accepts requests with a loopback Host header", async () => { + await start({ allowedOrigins: [] }); + + const http = await import("node:http"); + const body = JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "tools/list", + params: {}, + }); + + const status = await new Promise((resolve, reject) => { + const req = http.request( + { + hostname: "127.0.0.1", + port, + path: "/mcp", + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json, text/event-stream", + "Content-Length": Buffer.byteLength(body), + Host: `127.0.0.1:${port}`, + }, + }, + (res) => { + res.resume(); + res.on("end", () => resolve(res.statusCode ?? 0)); + }, + ); + req.on("error", reject); + req.write(body); + req.end(); + }); + + expect(status).toBe(200); + }); + + it("accepts a Host added via ALLOWED_HOSTS", async () => { + await start({ + allowedOrigins: [], + extraAllowedHosts: ["mcp.example.com"], + }); + + const http = await import("node:http"); + const body = JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "tools/list", + params: {}, + }); + + const status = await new Promise((resolve, reject) => { + const req = http.request( + { + hostname: "127.0.0.1", + port, + path: "/mcp", + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json, text/event-stream", + "Content-Length": Buffer.byteLength(body), + Host: "mcp.example.com", + }, + }, + (res) => { + res.resume(); + res.on("end", () => resolve(res.statusCode ?? 0)); + }, + ); + req.on("error", reject); + req.write(body); + req.end(); + }); + + expect(status).toBe(200); + }); + }); +}); diff --git a/src/http.ts b/src/http.ts index 36b28aa..727061a 100644 --- a/src/http.ts +++ b/src/http.ts @@ -1,80 +1,205 @@ #!/usr/bin/env node -import express from "express"; +import express, { type Express } from "express"; import cors from "cors"; import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; import { createPerplexityServer } from "./server.js"; import { logger } from "./logger.js"; -const PERPLEXITY_API_KEY = process.env.PERPLEXITY_API_KEY; -if (!PERPLEXITY_API_KEY) { - logger.error("PERPLEXITY_API_KEY environment variable is required"); - process.exit(1); +export interface HttpAppOptions { + port: number; + bindAddress: string; + allowedOrigins: string[]; + allowedHosts: Set; } -const app = express(); -const PORT = parseInt(process.env.PORT || "8080", 10); -const BIND_ADDRESS = process.env.BIND_ADDRESS || "0.0.0.0"; -const ALLOWED_ORIGINS = process.env.ALLOWED_ORIGINS?.split(",") || ["*"]; - -// CORS configuration for browser-based MCP clients -app.use(cors({ - origin: (origin, callback) => { - if (!origin) return callback(null, true); - - if (ALLOWED_ORIGINS.includes("*")) { - return callback(null, true); - } - - if (ALLOWED_ORIGINS.includes(origin)) { - callback(null, true); - } else { - callback(new Error(`Origin ${origin} not allowed by CORS`)); +/** + * Build the Express app for the HTTP transport. Exported so tests can + * exercise the same wiring used in production. + */ +export function createHttpApp(options: HttpAppOptions): Express { + const { port, bindAddress, allowedOrigins, allowedHosts } = options; + const allowsAllOrigins = allowedOrigins.includes("*"); + + if (bindAddress === "0.0.0.0" || bindAddress === "::") { + logger.warn( + `BIND_ADDRESS=${bindAddress} exposes the server on all network ` + + `interfaces. See SECURITY.md.`, + ); + } + if (allowsAllOrigins) { + logger.warn( + `ALLOWED_ORIGINS contains "*". See SECURITY.md.`, + ); + } + + const app = express(); + + // Host header allowlist. Runs before CORS. + app.use((req, res, next) => { + const hostHeader = req.headers.host; + if (!hostHeader || !allowedHosts.has(hostHeader.toLowerCase())) { + logger.warn("Rejected request with disallowed Host header", { + host: hostHeader, + path: req.path, + }); + res.status(421).json({ + jsonrpc: "2.0", + error: { code: -32000, message: "Misdirected request" }, + id: null, + }); + return; } - }, - exposedHeaders: ["Mcp-Session-Id", "mcp-protocol-version"], - allowedHeaders: ["Content-Type", "mcp-session-id"], -})); + next(); + }); -app.use(express.json()); + // CORS configuration for browser-based MCP clients. + // - A missing Origin header (same-origin or non-browser caller) is allowed. + // - A literal "null" Origin requires explicit opt-in via ALLOWED_ORIGINS. + // - "*" in ALLOWED_ORIGINS is honored but logs a startup warning. + app.use( + cors({ + origin: (origin, callback) => { + if (!origin) return callback(null, true); -const mcpServer = createPerplexityServer(); + if (origin === "null") { + if (allowedOrigins.includes("null")) { + return callback(null, true); + } + return callback(new Error("Origin null not allowed by CORS")); + } -app.all("/mcp", async (req, res) => { - try { - const transport = new StreamableHTTPServerTransport({ - sessionIdGenerator: undefined, - enableJsonResponse: true, - }); + if (allowsAllOrigins) { + return callback(null, true); + } - res.on('close', () => { - transport.close(); - }); + if (allowedOrigins.includes(origin)) { + return callback(null, true); + } - await mcpServer.connect(transport); - - await transport.handleRequest(req, res, req.body); - } catch (error) { - logger.error("Error handling MCP request", { error: String(error) }); - if (!res.headersSent) { - res.status(500).json({ - jsonrpc: "2.0", - error: { code: -32603, message: "Internal server error" }, - id: null, + return callback(new Error(`Origin ${origin} not allowed by CORS`)); + }, + exposedHeaders: ["Mcp-Session-Id", "mcp-protocol-version"], + allowedHeaders: ["Content-Type", "mcp-session-id"], + }), + ); + + app.use(express.json()); + + const mcpServer = createPerplexityServer(); + + app.all("/mcp", async (req, res) => { + try { + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: undefined, + enableJsonResponse: true, + }); + + res.on("close", () => { + transport.close(); }); + + await mcpServer.connect(transport); + + await transport.handleRequest(req, res, req.body); + } catch (error) { + logger.error("Error handling MCP request", { error: String(error) }); + if (!res.headersSent) { + res.status(500).json({ + jsonrpc: "2.0", + error: { code: -32603, message: "Internal server error" }, + id: null, + }); + } } + }); + + app.get("/health", (req, res) => { + res.json({ status: "ok", service: "perplexity-mcp-server" }); + }); + + // Mark the port as used so a no-op consumer doesn't make TS unhappy if PORT + // is only consumed by the listener in main(). + void port; + + return app; +} + +/** Parse a comma-separated env var into a trimmed, non-empty string list. */ +function parseList(value: string | undefined): string[] { + if (!value) return []; + return value + .split(",") + .map((v) => v.trim()) + .filter((v) => v.length > 0); +} + +/** Build the Host header allowlist from PORT + ALLOWED_HOSTS. */ +export function buildAllowedHosts(port: number, extra: string[]): Set { + return new Set( + [ + `localhost:${port}`, + `127.0.0.1:${port}`, + `[::1]:${port}`, + "localhost", + "127.0.0.1", + "[::1]", + ...extra, + ].map((h) => h.toLowerCase()), + ); +} + +function main(): void { + const PERPLEXITY_API_KEY = process.env.PERPLEXITY_API_KEY; + if (!PERPLEXITY_API_KEY) { + logger.error("PERPLEXITY_API_KEY environment variable is required"); + process.exit(1); } -}); -app.get("/health", (req, res) => { - res.json({ status: "ok", service: "perplexity-mcp-server" }); -}); + const PORT = parseInt(process.env.PORT || "8080", 10); -app.listen(PORT, BIND_ADDRESS, () => { - logger.info(`Perplexity MCP Server listening on http://${BIND_ADDRESS}:${PORT}/mcp`); - logger.info(`Allowed origins: ${ALLOWED_ORIGINS.join(", ")}`); -}).on("error", (error) => { - logger.error("Server error", { error: String(error) }); - process.exit(1); -}); + // Defaults are loopback-only with no allowed cross-origin browsers. + // Set BIND_ADDRESS and ALLOWED_ORIGINS to opt in to remote / browser access. + // See SECURITY.md. + const BIND_ADDRESS = process.env.BIND_ADDRESS || "127.0.0.1"; + const ALLOWED_ORIGINS = parseList(process.env.ALLOWED_ORIGINS); + const ALLOWED_HOSTS = buildAllowedHosts( + PORT, + parseList(process.env.ALLOWED_HOSTS), + ); + const app = createHttpApp({ + port: PORT, + bindAddress: BIND_ADDRESS, + allowedOrigins: ALLOWED_ORIGINS, + allowedHosts: ALLOWED_HOSTS, + }); + + app + .listen(PORT, BIND_ADDRESS, () => { + logger.info( + `Perplexity MCP Server listening on http://${BIND_ADDRESS}:${PORT}/mcp`, + ); + logger.info( + `Allowed origins: ${ + ALLOWED_ORIGINS.length > 0 + ? ALLOWED_ORIGINS.join(", ") + : "(none — cross-origin browser requests will be rejected)" + }`, + ); + }) + .on("error", (error) => { + logger.error("Server error", { error: String(error) }); + process.exit(1); + }); +} + +// Only auto-start when invoked as a script (preserves test importability). +const invokedAsScript = + import.meta.url === `file://${process.argv[1]}` || + process.argv[1]?.endsWith("/http.js") || + process.argv[1]?.endsWith("/http.ts"); + +if (invokedAsScript) { + main(); +} From f69c72e54a28ffd576607fee4edf9e9d5fee74ca Mon Sep 17 00:00:00 2001 From: Thomas Espach Date: Tue, 16 Jun 2026 16:18:04 +0000 Subject: [PATCH 2/2] chore(http): make startup banners visible at default log level; 403 for disallowed Origin - Startup banners (BIND_ADDRESS=0.0.0.0, ALLOWED_ORIGINS=*, listening banner) now write directly to stderr instead of going through the level-gated logger, which defaults to ERROR. Without this, the banners were silently dropped under default config. - Disallowed cross-origin preflights now return 403 with a JSON-RPC error body instead of the default Express 500. Mirrors the 421 emitted by the Host header check. - Tests: 3 new banner tests (spy on console.error); existing CORS rejection tests upgraded to assert 403 + JSON-RPC body. --- src/http.config.test.ts | 71 +++++++++++++++++++++++++++++++++++++++-- src/http.ts | 62 ++++++++++++++++++++++++++++------- 2 files changed, 119 insertions(+), 14 deletions(-) diff --git a/src/http.config.test.ts b/src/http.config.test.ts index ae23b37..c899821 100644 --- a/src/http.config.test.ts +++ b/src/http.config.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import type { Server } from "http"; import { createHttpApp, buildAllowedHosts } from "./http.js"; @@ -79,9 +79,15 @@ describe("HTTP transport configuration", () => { }, }); - // cors middleware rejects with no ACAO header (and typically 500/204 - // depending on version). The assertion is that no Origin is reflected. + // Rejected preflights must not reflect the requesting Origin and must + // return an explicit 403 with a JSON-RPC error body (not a generic 500). expect(preflight.headers.get("access-control-allow-origin")).toBeNull(); + expect(preflight.status).toBe(403); + const body = await preflight.json(); + expect(body).toMatchObject({ + jsonrpc: "2.0", + error: { code: -32000, message: "Origin not allowed" }, + }); }); it("allows an explicitly allowlisted origin", async () => { @@ -114,6 +120,7 @@ describe("HTTP transport configuration", () => { }); expect(preflight.headers.get("access-control-allow-origin")).toBeNull(); + expect(preflight.status).toBe(403); }); it("rejects Origin: null by default", async () => { @@ -129,6 +136,7 @@ describe("HTTP transport configuration", () => { }); expect(preflight.headers.get("access-control-allow-origin")).toBeNull(); + expect(preflight.status).toBe(403); }); it("allows Origin: null only when explicitly opted in", async () => { @@ -165,6 +173,63 @@ describe("HTTP transport configuration", () => { }); }); + describe("Startup banners", () => { + // These banners must appear at the default log level (ERROR), not just + // when PERPLEXITY_LOG_LEVEL=WARN. We assert by spying on console.error. + it("emits a banner when BIND_ADDRESS is 0.0.0.0", () => { + const spy = vi.spyOn(console, "error").mockImplementation(() => {}); + try { + createHttpApp({ + port: 8080, + bindAddress: "0.0.0.0", + allowedOrigins: [], + allowedHosts: buildAllowedHosts(8080, []), + }); + const calls = spy.mock.calls.map((c) => String(c[0])); + expect(calls.some((m) => m.includes("BIND_ADDRESS=0.0.0.0"))).toBe(true); + } finally { + spy.mockRestore(); + } + }); + + it("emits a banner when ALLOWED_ORIGINS contains *", () => { + const spy = vi.spyOn(console, "error").mockImplementation(() => {}); + try { + createHttpApp({ + port: 8080, + bindAddress: "127.0.0.1", + allowedOrigins: ["*"], + allowedHosts: buildAllowedHosts(8080, []), + }); + const calls = spy.mock.calls.map((c) => String(c[0])); + expect(calls.some((m) => m.includes("ALLOWED_ORIGINS"))).toBe(true); + } finally { + spy.mockRestore(); + } + }); + + it("emits no banner under the safe defaults", () => { + const spy = vi.spyOn(console, "error").mockImplementation(() => {}); + try { + createHttpApp({ + port: 8080, + bindAddress: "127.0.0.1", + allowedOrigins: [], + allowedHosts: buildAllowedHosts(8080, []), + }); + const calls = spy.mock.calls.map((c) => String(c[0])); + expect( + calls.some( + (m) => + m.includes("BIND_ADDRESS=") || m.includes("ALLOWED_ORIGINS"), + ), + ).toBe(false); + } finally { + spy.mockRestore(); + } + }); + }); + describe("Host header allowlist", () => { it("rejects requests with a foreign Host header", async () => { await start({ allowedOrigins: [] }); diff --git a/src/http.ts b/src/http.ts index 727061a..e9923ad 100644 --- a/src/http.ts +++ b/src/http.ts @@ -21,15 +21,17 @@ export function createHttpApp(options: HttpAppOptions): Express { const { port, bindAddress, allowedOrigins, allowedHosts } = options; const allowsAllOrigins = allowedOrigins.includes("*"); + // One-shot startup banners. Written directly to stderr (bypassing the + // level-gated logger) so they are visible at the default log level. if (bindAddress === "0.0.0.0" || bindAddress === "::") { - logger.warn( - `BIND_ADDRESS=${bindAddress} exposes the server on all network ` + - `interfaces. See SECURITY.md.`, + console.error( + `[mcp-server] BIND_ADDRESS=${bindAddress} exposes the server on all ` + + `network interfaces. See SECURITY.md.`, ); } if (allowsAllOrigins) { - logger.warn( - `ALLOWED_ORIGINS contains "*". See SECURITY.md.`, + console.error( + `[mcp-server] ALLOWED_ORIGINS contains "*". See SECURITY.md.`, ); } @@ -53,6 +55,15 @@ export function createHttpApp(options: HttpAppOptions): Express { next(); }); + // Sentinel error type so the CORS error handler can distinguish disallowed- + // origin rejections from other downstream errors. + class CorsOriginNotAllowedError extends Error { + constructor(public readonly origin: string) { + super(`Origin ${origin} not allowed by CORS`); + this.name = "CorsOriginNotAllowedError"; + } + } + // CORS configuration for browser-based MCP clients. // - A missing Origin header (same-origin or non-browser caller) is allowed. // - A literal "null" Origin requires explicit opt-in via ALLOWED_ORIGINS. @@ -66,7 +77,7 @@ export function createHttpApp(options: HttpAppOptions): Express { if (allowedOrigins.includes("null")) { return callback(null, true); } - return callback(new Error("Origin null not allowed by CORS")); + return callback(new CorsOriginNotAllowedError("null")); } if (allowsAllOrigins) { @@ -77,13 +88,40 @@ export function createHttpApp(options: HttpAppOptions): Express { return callback(null, true); } - return callback(new Error(`Origin ${origin} not allowed by CORS`)); + return callback(new CorsOriginNotAllowedError(origin)); }, exposedHeaders: ["Mcp-Session-Id", "mcp-protocol-version"], allowedHeaders: ["Content-Type", "mcp-session-id"], }), ); + // Translate CORS origin-rejection errors into an explicit 403 with a + // JSON-RPC error body, mirroring the 421 emitted by the Host check. This + // runs immediately after the cors middleware so other errors still fall + // through to Express's default handler unchanged. + app.use( + ( + err: Error, + req: express.Request, + res: express.Response, + next: express.NextFunction, + ) => { + if (err instanceof CorsOriginNotAllowedError) { + logger.warn("Rejected request with disallowed Origin", { + origin: err.origin, + path: req.path, + }); + res.status(403).json({ + jsonrpc: "2.0", + error: { code: -32000, message: "Origin not allowed" }, + id: null, + }); + return; + } + next(err); + }, + ); + app.use(express.json()); const mcpServer = createPerplexityServer(); @@ -177,11 +215,13 @@ function main(): void { app .listen(PORT, BIND_ADDRESS, () => { - logger.info( - `Perplexity MCP Server listening on http://${BIND_ADDRESS}:${PORT}/mcp`, + // Startup banner — written directly to stderr so it is visible at the + // default log level. + console.error( + `[mcp-server] listening on http://${BIND_ADDRESS}:${PORT}/mcp`, ); - logger.info( - `Allowed origins: ${ + console.error( + `[mcp-server] allowed origins: ${ ALLOWED_ORIGINS.length > 0 ? ALLOWED_ORIGINS.join(", ") : "(none — cross-origin browser requests will be rejected)"