Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
120 changes: 120 additions & 0 deletions src/gateway/__tests__/cors-host.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = {},
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: <value> 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);
});
});
78 changes: 69 additions & 9 deletions src/gateway/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
// ============================
Expand Down Expand Up @@ -169,19 +203,39 @@ export class TdaiGateway {
// ============================

private async handleRequest(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
// 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 {
Expand Down Expand Up @@ -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<void> {
assertSafeHost();
const gateway = new TdaiGateway();

// Graceful shutdown
Expand Down