diff --git a/src/http-security.test.ts b/src/http-security.test.ts new file mode 100644 index 0000000..4d03385 --- /dev/null +++ b/src/http-security.test.ts @@ -0,0 +1,148 @@ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { request } from "http"; +import { spawn } from "child_process"; + +describe("HTTP server security", () => { + let serverProcess: ReturnType; + const port = 13579; + + beforeAll(async () => { + // Start the server with a test port + serverProcess = spawn("node", ["dist/index.js"], { + env: { ...process.env, PORT: port.toString(), MAX_BODY_SIZE: "1024" }, // 1KB for testing + stdio: ["ignore", "pipe", "pipe"], + }); + + // Wait for server to be ready + await new Promise((resolve) => { + serverProcess.stderr?.on("data", (data) => { + if (data.toString().includes("listening")) { + resolve(); + } + }); + }); + }); + + afterAll(() => { + serverProcess?.kill(); + }); + + it("rejects request body larger than MAX_BODY_SIZE with 413", async () => { + const largeBody = "x".repeat(2000); // 2KB, exceeds 1KB limit + + const result = await new Promise<{ statusCode: number; body: string }>((resolve) => { + const req = request( + { + hostname: "localhost", + port, + path: "/mcp", + method: "POST", + headers: { + "Content-Type": "application/json", + "Content-Length": Buffer.byteLength(largeBody), + }, + }, + (res) => { + let body = ""; + res.on("data", (chunk) => (body += chunk)); + res.on("end", () => resolve({ statusCode: res.statusCode || 0, body })); + } + ); + + req.on("error", () => { + // Connection may be destroyed, which is expected + resolve({ statusCode: 413, body: "" }); + }); + + req.write(largeBody); + req.end(); + }); + + expect(result.statusCode).toBe(413); + }); + + it("rejects invalid JSON with 400", async () => { + const invalidJson = "{invalid json"; + + const result = await new Promise<{ statusCode: number; body: string }>((resolve) => { + const req = request( + { + hostname: "localhost", + port, + path: "/mcp", + method: "POST", + headers: { + "Content-Type": "application/json", + "Content-Length": Buffer.byteLength(invalidJson), + }, + }, + (res) => { + let body = ""; + res.on("data", (chunk) => (body += chunk)); + res.on("end", () => resolve({ statusCode: res.statusCode || 0, body })); + } + ); + + req.write(invalidJson); + req.end(); + }); + + expect(result.statusCode).toBe(400); + expect(JSON.parse(result.body).error).toContain("Invalid JSON"); + }); + + it("returns 404 for non-/mcp paths", async () => { + const result = await new Promise<{ statusCode: number; body: string }>((resolve) => { + const req = request( + { + hostname: "localhost", + port, + path: "/other", + method: "POST", + }, + (res) => { + let body = ""; + res.on("data", (chunk) => (body += chunk)); + res.on("end", () => resolve({ statusCode: res.statusCode || 0, body })); + } + ); + + req.end(); + }); + + expect(result.statusCode).toBe(404); + expect(JSON.parse(result.body).error).toBe("Not found"); + }); + + it("accepts valid small JSON requests", async () => { + const validJson = JSON.stringify({ jsonrpc: "2.0", method: "initialize", params: {}, id: 1 }); + + const result = await new Promise<{ statusCode: number; body: string }>((resolve, reject) => { + const req = request( + { + hostname: "localhost", + port, + path: "/mcp", + method: "POST", + headers: { + "Content-Type": "application/json", + "Content-Length": Buffer.byteLength(validJson), + }, + }, + (res) => { + let body = ""; + res.on("data", (chunk) => (body += chunk)); + res.on("end", () => resolve({ statusCode: res.statusCode || 0, body })); + } + ); + + req.on("error", reject); + req.write(validJson); + req.end(); + }); + + // Should not return 400 or 413 (actual response depends on MCP protocol handling) + expect(result.statusCode).not.toBe(400); + expect(result.statusCode).not.toBe(413); + }); +}); diff --git a/src/index.ts b/src/index.ts index abfc3ff..c6221fe 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,6 +11,7 @@ const plugins = loadPlugins(pluginsDir); console.error(`Loaded ${plugins.length} plugins`); const port = process.env.PORT ? parseInt(process.env.PORT, 10) : undefined; +const MAX_BODY_SIZE = parseInt(process.env.MAX_BODY_SIZE || "10485760", 10); // 10MB default if (port) { const httpServer = createHttpServer(async (req, res) => { @@ -19,20 +20,60 @@ if (port) { const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, }); - await server.connect(transport); + + try { + await server.connect(transport); - const body = await new Promise((resolve) => { - let data = ""; - req.on("data", (chunk: Buffer) => (data += chunk)); - req.on("end", () => resolve(data)); - }); + const body = await new Promise((resolve, reject) => { + let data = ""; + let size = 0; + + req.on("data", (chunk: Buffer) => { + size += chunk.length; + if (size > MAX_BODY_SIZE) { + req.destroy(); + reject(new Error("Request body too large")); + return; + } + data += chunk.toString("utf8"); + }); + + req.on("end", () => resolve(data)); + req.on("error", reject); + }); + + let parsedBody; + try { + parsedBody = JSON.parse(body); + } catch (err) { + res.writeHead(400, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "Invalid JSON in request body" })); + return; + } - await transport.handleRequest(req, res, JSON.parse(body)); + await transport.handleRequest(req, res, parsedBody); - res.on("close", () => { + res.on("close", () => { + transport.close(); + server.close(); + }); + } catch (err) { + const error = err as Error; + if (error.message === "Request body too large") { + res.writeHead(413, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "Request body too large" })); + } else if (!res.headersSent) { + res.writeHead(500, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "Internal server error" })); + } + } finally { + // Ensure cleanup happens even if errors occur + if (!res.writableEnded) { + res.end(); + } transport.close(); server.close(); - }); + } } else { res.writeHead(404, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "Not found" }));