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..c899821 --- /dev/null +++ b/src/http.config.test.ts @@ -0,0 +1,353 @@ +import { describe, it, expect, beforeEach, afterEach, vi } 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", + }, + }); + + // 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 () => { + 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(); + expect(preflight.status).toBe(403); + }); + + 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(); + expect(preflight.status).toBe(403); + }); + + 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("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: [] }); + + // 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..e9923ad 100644 --- a/src/http.ts +++ b/src/http.ts @@ -1,80 +1,245 @@ #!/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); +/** + * 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("*"); + + // 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 === "::") { + console.error( + `[mcp-server] BIND_ADDRESS=${bindAddress} exposes the server on all ` + + `network interfaces. See SECURITY.md.`, + ); + } + if (allowsAllOrigins) { + console.error( + `[mcp-server] 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; } - - if (ALLOWED_ORIGINS.includes(origin)) { - callback(null, true); - } else { - callback(new Error(`Origin ${origin} not allowed by CORS`)); + 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"; } - }, - exposedHeaders: ["Mcp-Session-Id", "mcp-protocol-version"], - allowedHeaders: ["Content-Type", "mcp-session-id"], -})); + } -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 CorsOriginNotAllowedError("null")); + } -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 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(); + + 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, () => { + // 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`, + ); + console.error( + `[mcp-server] 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(); +}