/** * ADR-124 §3 Architecture — Streamable HTTP transport security tests. * * Tests the Origin-validation middleware and bearer-token auth gate. * No live MCP server needed for the guard logic — buildHttpApp is tested * with a minimal stub McpServer that never actually processes JSON-RPC. * * Covered: * 1. isOriginAllowed() unit tests — the pure function driving the gate * 2. POST /mcp with cross-origin Origin → 403 * 3. POST /mcp with allowed Origin → passes Origin gate (non-403) * 4. POST /mcp with no Origin header → passes Origin gate (non-403) * 5. Bearer token required, wrong token → 401 * 6. Bearer token required, correct token + wildcard origin → passes (non-401) */ import * as http from "node:http"; import { isOriginAllowed, buildHttpApp } from "../src/http-transport.js"; import { Server as McpServer } from "@modelcontextprotocol/sdk/server/index.js"; // ── helpers ──────────────────────────────────────────────────────────────── function makeMockMcpServer(): McpServer { return new McpServer( { name: "test-rvagent", version: "0.0.0" }, { capabilities: { tools: {} } } ); } async function post( port: number, path: string, headers: Record, body: string ): Promise<{ status: number; body: string }> { return new Promise((resolve, reject) => { const req = http.request( { hostname: "127.0.0.1", port, method: "POST", path, headers: { "Content-Type": "application/json", ...headers }, }, (res) => { let data = ""; res.on("data", (chunk: Buffer) => { data += chunk.toString(); }); res.on("end", () => resolve({ status: res.statusCode ?? 0, body: data })); } ); req.on("error", reject); req.write(body); req.end(); }); } async function startServer( opts: Parameters[1], basePort: number ): Promise<{ port: number; close: () => Promise }> { const port = basePort + Math.floor(Math.random() * 100); // Factory, not instance: each Streamable-HTTP session gets its own MCP // Server (ADR-264 F7/O3). const { httpServer } = buildHttpApp(() => makeMockMcpServer(), opts); await new Promise((resolve, reject) => { httpServer.once("error", reject); httpServer.listen(port, "127.0.0.1", () => resolve()); }); const close = () => new Promise((res, rej) => httpServer.close((e) => (e ? rej(e) : res())) ); return { port, close }; } const MCP_BODY = JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list" }); // ── 1. isOriginAllowed unit tests ────────────────────────────────────────── describe("isOriginAllowed()", () => { const allow = ["http://localhost", "http://127.0.0.1"]; it("allows undefined origin (non-browser request, no Origin header)", () => { expect(isOriginAllowed(undefined, allow)).toBe(true); }); it("allows an origin in the allowlist", () => { expect(isOriginAllowed("http://localhost", allow)).toBe(true); expect(isOriginAllowed("http://127.0.0.1", allow)).toBe(true); }); it("rejects an origin NOT in the allowlist", () => { expect(isOriginAllowed("https://evil.example.com", allow)).toBe(false); }); it("allows anything when allowedOrigins includes '*'", () => { expect(isOriginAllowed("https://evil.example.com", ["*"])).toBe(true); }); // ADR-264 F7: real browser origins carry ports — localhost must match on // hostname, any port, even with an empty allowlist. it("allows localhost origins on any port", () => { expect(isOriginAllowed("http://localhost:5173", [])).toBe(true); expect(isOriginAllowed("http://127.0.0.1:8080", [])).toBe(true); expect(isOriginAllowed("https://localhost:3001", [])).toBe(true); }); it("rejects non-local origins even with a localhost-looking prefix", () => { expect(isOriginAllowed("http://localhost.evil.example.com", [])).toBe(false); expect(isOriginAllowed("https://evil.example.com:443", [])).toBe(false); }); // ADR-264 F7 hardening: an EXPLICIT allowlist means exact matching only. The // any-port-localhost convenience applies solely to the empty-allowlist case, // so an operator who pins an allowlist actually gets it. it("with an explicit allowlist, rejects a localhost origin on an unlisted port", () => { expect(isOriginAllowed("http://localhost:5173", allow)).toBe(false); expect(isOriginAllowed("http://127.0.0.1:8080", allow)).toBe(false); }); it("with an explicit allowlist, still accepts an exactly-listed localhost origin", () => { expect(isOriginAllowed("http://localhost", allow)).toBe(true); expect(isOriginAllowed("http://127.0.0.1", allow)).toBe(true); }); it("is case-sensitive for non-local allowlist entries per RFC 6454", () => { expect(isOriginAllowed("HTTPS://Partner.Example.com", ["https://partner.example.com"])).toBe(false); }); }); // ── 2-4. Origin-validation integration tests ─────────────────────────────── describe("HTTP transport Origin-validation middleware", () => { let port: number; let close: () => Promise; beforeAll(async () => { const srv = await startServer( { allowedOrigins: ["http://localhost", "http://127.0.0.1"] }, 49200 ); port = srv.port; close = srv.close; }); afterAll(async () => { await close(); }); it("rejects cross-origin POST /mcp with 403", async () => { const r = await post(port, "/mcp", { Origin: "https://evil.example.com" }, MCP_BODY); expect(r.status).toBe(403); const body = JSON.parse(r.body) as Record; expect(body["error"]).toMatch(/cross-origin/i); }); it("passes Origin gate for http://localhost — status is not 403", async () => { const r = await post(port, "/mcp", { Origin: "http://localhost" }, MCP_BODY); expect(r.status).not.toBe(403); }); it("passes Origin gate with no Origin header — status is not 403", async () => { const r = await post(port, "/mcp", {}, MCP_BODY); expect(r.status).not.toBe(403); }); }); // ── 5-6. Bearer-token auth integration tests ────────────────────────────── describe("HTTP transport bearer-token auth gate", () => { const SECRET = "test-secret-token-xyz"; let port: number; let close: () => Promise; beforeAll(async () => { const srv = await startServer({ allowedOrigins: ["*"], bearerToken: SECRET }, 49400); port = srv.port; close = srv.close; }); afterAll(async () => { await close(); }); it("rejects missing Authorization header with 401", async () => { const r = await post(port, "/mcp", {}, MCP_BODY); expect(r.status).toBe(401); }); it("rejects wrong bearer token with 401", async () => { const r = await post(port, "/mcp", { Authorization: "Bearer wrong" }, MCP_BODY); expect(r.status).toBe(401); }); it("passes auth gate with correct bearer token — status is not 401", async () => { const r = await post(port, "/mcp", { Authorization: `Bearer ${SECRET}` }, MCP_BODY); expect(r.status).not.toBe(401); }); }); // ── 7. ADR-264 F7/O3 hardening: body cap + per-session routing ───────────── describe("HTTP transport session + body-cap hardening (ADR-264 F7)", () => { let port: number; let close: () => Promise; beforeAll(async () => { const srv = await startServer({ allowedOrigins: ["*"], maxBodyBytes: 64 * 1024 }, 49600); port = srv.port; close = srv.close; }); afterAll(async () => { await close(); }); it("rejects oversized request bodies with 413", async () => { const huge = JSON.stringify({ jsonrpc: "2.0", id: 1, method: "x", params: { pad: "y".repeat(128 * 1024) } }); const r = await post(port, "/mcp", {}, huge); expect(r.status).toBe(413); }); it("rejects a non-initialize POST without a session id with 400 (never a shared transport)", async () => { const r = await post(port, "/mcp", {}, MCP_BODY); // tools/list, no mcp-session-id expect(r.status).toBe(400); const body = JSON.parse(r.body) as Record; expect(body["error"]).toMatch(/initialize/i); }); it("rejects a POST with an unknown session id with 404", async () => { const r = await post(port, "/mcp", { "mcp-session-id": "no-such-session" }, MCP_BODY); expect(r.status).toBe(404); }); it("creates a fresh session (and MCP server) per initialize request", async () => { const init = JSON.stringify({ jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: "2024-11-05", capabilities: {}, clientInfo: { name: "test-client", version: "0.0.0" }, }, }); const r = await post(port, "/mcp", { Accept: "application/json, text/event-stream" }, init); expect([200, 406]).not.toContain(0); // sanity expect(r.status).toBe(200); }); }); // ── 8. ADR-264 F7: session-map bounds (cap + idle TTL sweep) ─────────────── describe("HTTP transport session bounds (ADR-264 F7)", () => { const initBody = (id: number): string => JSON.stringify({ jsonrpc: "2.0", id, method: "initialize", params: { protocolVersion: "2024-11-05", capabilities: {}, clientInfo: { name: "test-client", version: "0.0.0" }, }, }); // Build directly (not via startServer) so we can inspect the sessions map. async function startWithApp( opts: Parameters[1], basePort: number ): Promise<{ port: number; sessions: ReturnType["sessions"]; close: () => Promise; }> { const { httpServer, sessions } = buildHttpApp(() => makeMockMcpServer(), opts); const port = basePort + Math.floor(Math.random() * 100); await new Promise((resolve, reject) => { httpServer.once("error", reject); httpServer.listen(port, "127.0.0.1", () => resolve()); }); const close = () => new Promise((res, rej) => httpServer.close((e) => (e ? rej(e) : res()))); return { port, sessions, close }; } const ACCEPT = { Accept: "application/json, text/event-stream" }; it("never exceeds maxSessions — evicts the oldest-idle session at capacity", async () => { const srv = await startWithApp({ allowedOrigins: ["*"], maxSessions: 2 }, 49800); try { for (let i = 0; i < 5; i++) { await post(srv.port, "/mcp", ACCEPT, initBody(i)); } expect(srv.sessions.size).toBeLessThanOrEqual(2); } finally { await srv.close(); } }); it("sweeps sessions idle beyond sessionIdleMs", async () => { const srv = await startWithApp( { allowedOrigins: ["*"], sessionIdleMs: 20, sweepIntervalMs: 10 }, 49900 ); try { await post(srv.port, "/mcp", ACCEPT, initBody(1)); expect(srv.sessions.size).toBe(1); await new Promise((r) => setTimeout(r, 150)); expect(srv.sessions.size).toBe(0); } finally { await srv.close(); } }); });