diff --git a/src/gateway/__tests__/cors-host.test.ts b/src/gateway/__tests__/cors-host.test.ts new file mode 100644 index 0000000..8eb643c --- /dev/null +++ b/src/gateway/__tests__/cors-host.test.ts @@ -0,0 +1,120 @@ +import { describe, it, expect, beforeAll, afterAll, vi } from "vitest"; +import http from "node:http"; +import { TdaiGateway } from "../server.js"; + +async function request( + port: number, + path: string, + headers: Record = {}, + method = "GET", +): Promise<{ status: number; headers: http.IncomingHttpHeaders; body: string }> { + return new Promise((resolve, reject) => { + const req = http.request( + { host: "127.0.0.1", port, path, method, headers }, + (res) => { + const chunks: Buffer[] = []; + res.on("data", (c) => chunks.push(c)); + res.on("end", () => + resolve({ + status: res.statusCode ?? 0, + headers: res.headers, + body: Buffer.concat(chunks).toString("utf-8"), + }), + ); + }, + ); + req.on("error", reject); + req.end(); + }); +} + +describe("Gateway CORS is opt-in (TDAI_GATEWAY_CORS_ORIGIN)", () => { + let gateway: TdaiGateway; + const PORT = 18431; + + beforeAll(async () => { + gateway = new TdaiGateway({ + server: { port: PORT, host: "127.0.0.1" }, + } as never); + await gateway.start(); + }); + + afterAll(async () => { + await gateway.stop(); + }); + + it("does NOT emit Access-Control-Allow-Origin by default", async () => { + const res = await request(PORT, "/health"); + expect(res.headers["access-control-allow-origin"]).toBeUndefined(); + expect(res.headers["access-control-allow-headers"]).toBeUndefined(); + expect(res.headers["access-control-allow-methods"]).toBeUndefined(); + }); + + it("does NOT respond 204 to OPTIONS preflight when CORS is disabled", async () => { + const res = await request(PORT, "/recall", {}, "OPTIONS"); + // With CORS disabled, OPTIONS falls through to normal routing + // (404 because OPTIONS /recall is not a defined route), NOT a 204 + // preflight ack. This prevents OPTIONS from being a permanent + // unauthenticated probe of the daemon's existence. + expect(res.status).not.toBe(204); + }); + + it("emits Access-Control-Allow-Origin: when TDAI_GATEWAY_CORS_ORIGIN is set", async () => { + vi.stubEnv("TDAI_GATEWAY_CORS_ORIGIN", "https://example.com"); + const res = await request(PORT, "/health"); + expect(res.headers["access-control-allow-origin"]).toBe("https://example.com"); + }); + + it("returns 204 for OPTIONS preflight when CORS is enabled", async () => { + vi.stubEnv("TDAI_GATEWAY_CORS_ORIGIN", "https://example.com"); + const res = await request(PORT, "/recall", {}, "OPTIONS"); + expect(res.status).toBe(204); + expect(res.headers["access-control-allow-origin"]).toBe("https://example.com"); + }); +}); + +describe("Gateway Host header allowlist (defence against DNS rebinding)", () => { + let gateway: TdaiGateway; + const PORT = 18432; + + beforeAll(async () => { + gateway = new TdaiGateway({ + server: { port: PORT, host: "127.0.0.1" }, + } as never); + await gateway.start(); + }); + + afterAll(async () => { + await gateway.stop(); + }); + + it.each([ + "127.0.0.1", + "127.0.0.1:8421", + "localhost", + "localhost:8421", + "[::1]", + "[::1]:8421", + ])("accepts loopback Host: %s", async (hostHeader) => { + const res = await request(PORT, "/health", { Host: hostHeader }); + expect(res.status, `Host=${hostHeader}`).toBe(200); + }); + + it.each([ + "evil.com", + "evil.com:8421", + "10.0.0.1", + "example.com", + "127.0.0.1.evil.com", + "localhost.evil.com", + ])("rejects non-loopback Host: %s with 403", async (hostHeader) => { + const res = await request(PORT, "/health", { Host: hostHeader }); + expect(res.status, `Host=${hostHeader}`).toBe(403); + }); + + it("skips Host check when TDAI_GATEWAY_ALLOW_REMOTE=1", async () => { + vi.stubEnv("TDAI_GATEWAY_ALLOW_REMOTE", "1"); + const res = await request(PORT, "/health", { Host: "evil.com" }); + expect(res.status).toBe(200); + }); +}); diff --git a/src/gateway/server.ts b/src/gateway/server.ts index bd7d0a0..4ad3e42 100644 --- a/src/gateway/server.ts +++ b/src/gateway/server.ts @@ -46,6 +46,40 @@ import type { SeedProgress } from "../core/seed/types.js"; const TAG = "[tdai-gateway]"; const VERSION = "0.1.0"; +const LOOPBACK_HOSTS = new Set(["127.0.0.1", "localhost", "::1", "::ffff:127.0.0.1"]); + +function isLoopbackHostHeader(hostHeader: string | undefined): boolean { + if (!hostHeader) return false; + let host = hostHeader.trim().toLowerCase(); + if (host.startsWith("[")) { + const closeBracket = host.indexOf("]"); + if (closeBracket === -1) return false; + host = host.slice(1, closeBracket); + } else { + const colonIdx = host.indexOf(":"); + if (colonIdx !== -1) host = host.slice(0, colonIdx); + } + return LOOPBACK_HOSTS.has(host); +} + +/** + * Refuse to bind a non-loopback `TDAI_GATEWAY_HOST` unless + * `TDAI_GATEWAY_ALLOW_REMOTE=1` is explicitly set. Exits with code 2 on + * violation. Called from main() (auto-start when running this file directly) + * to prevent the daemon from accidentally binding to a LAN/public interface. + */ +function assertSafeHost(): void { + const host = process.env.TDAI_GATEWAY_HOST?.trim(); + if (!host) return; + if (LOOPBACK_HOSTS.has(host)) return; + if (process.env.TDAI_GATEWAY_ALLOW_REMOTE === "1") return; + process.stderr.write( + `tdai-gateway: refusing to bind TDAI_GATEWAY_HOST=${host} (non-loopback). ` + + `Set TDAI_GATEWAY_ALLOW_REMOTE=1 to opt in.\n`, + ); + process.exit(2); +} + // ============================ // Console logger (for standalone gateway — no OpenClaw logger available) // ============================ @@ -169,19 +203,39 @@ export class TdaiGateway { // ============================ private async handleRequest(req: http.IncomingMessage, res: http.ServerResponse): Promise { + // Host header allowlist: defence against DNS rebinding. An attacker with + // a domain `evil.com` that has a short TTL can DNS-rebind a victim's + // browser to resolve `evil.com -> 127.0.0.1`, then issue fetch() to the + // local daemon. Require Host to be a loopback name/IP unless + // TDAI_GATEWAY_ALLOW_REMOTE=1 opted in at startup. + if ( + process.env.TDAI_GATEWAY_ALLOW_REMOTE !== "1" && + !isLoopbackHostHeader(req.headers.host) + ) { + sendError(res, 403, "Forbidden: Host header not in loopback allowlist"); + return; + } + const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`); const method = req.method?.toUpperCase() ?? "GET"; const pathname = url.pathname; - // CORS headers (for development) - res.setHeader("Access-Control-Allow-Origin", "*"); - res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS"); - res.setHeader("Access-Control-Allow-Headers", "Content-Type"); - - if (method === "OPTIONS") { - res.writeHead(204); - res.end(); - return; + // CORS is opt-in: only emit Access-Control-Allow-* headers (and ack + // OPTIONS preflight) when TDAI_GATEWAY_CORS_ORIGIN is explicitly set. + // The daemon binds loopback by default and has no legitimate cross-origin + // browser use case; hardcoding `*` previously let any browser page (with + // DNS rebinding) talk to the daemon. + const corsOrigin = process.env.TDAI_GATEWAY_CORS_ORIGIN?.trim(); + if (corsOrigin) { + res.setHeader("Access-Control-Allow-Origin", corsOrigin); + res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS"); + res.setHeader("Access-Control-Allow-Headers", "Content-Type"); + + if (method === "OPTIONS") { + res.writeHead(204); + res.end(); + return; + } } try { @@ -442,8 +496,14 @@ export class TdaiGateway { /** * Start the gateway from the command line. * Usage: node --import tsx src/gateway/server.ts + * + * Auto-start path applies `assertSafeHost()` first so that a stray + * `TDAI_GATEWAY_HOST=0.0.0.0` in the environment does NOT silently expose + * the daemon to the LAN; binding non-loopback requires explicit + * `TDAI_GATEWAY_ALLOW_REMOTE=1`. */ async function main(): Promise { + assertSafeHost(); const gateway = new TdaiGateway(); // Graceful shutdown