diff --git a/src/core/runtime-endpoint.ts b/src/core/runtime-endpoint.ts index dff687b91c..a5af6f58f8 100644 --- a/src/core/runtime-endpoint.ts +++ b/src/core/runtime-endpoint.ts @@ -92,10 +92,21 @@ export function isKanbanRuntimeHttps(): boolean { } const LOCALHOST_HOSTS = new Set(["127.0.0.1", "::1", "localhost"]); +const WILDCARD_HOSTS = new Set(["0.0.0.0", "::"]); + +/** + * Returns true when the runtime host is a wildcard bind address (0.0.0.0 or ::), + * meaning the server listens on all network interfaces. + */ +export function isKanbanWildcardHost(): boolean { + return WILDCARD_HOSTS.has(runtimeHost); +} /** * Returns true when Kanban is bound to a non-localhost host, meaning it is * accessible to other machines on the network and passcode auth is required. + * Wildcard addresses (0.0.0.0, ::) are also considered remote since they + * expose the server to all network interfaces. */ export function isKanbanRemoteHost(): boolean { return !LOCALHOST_HOSTS.has(runtimeHost); diff --git a/src/server/middleware.ts b/src/server/middleware.ts index acbb134ad2..9ad5ed34ee 100644 --- a/src/server/middleware.ts +++ b/src/server/middleware.ts @@ -5,6 +5,7 @@ import { getKanbanRuntimeOrigin, getKanbanRuntimePort, isKanbanRemoteHost, + isKanbanWildcardHost, } from "../core/runtime-endpoint"; export type CorsDecision = @@ -28,16 +29,16 @@ export function evaluateCors(input: CorsGateInput): CorsDecision { return { kind: "allow", origin: null }; } - const isDevServer = isDev && (origin === "http://localhost:4173" || origin === "http://127.0.0.1:4173"); - - if (origin !== input.allowedOrigin && !isDevServer) { - return { kind: "reject", origin }; + if (!isKanbanWildcardHost()) { + const isDevServer = isDev && (origin === "http://localhost:4173" || origin === "http://127.0.0.1:4173"); + if (origin !== input.allowedOrigin && !isDevServer) { + return { kind: "reject", origin }; + } } if (isPreflight) { return { kind: "preflight", origin }; } - return { kind: "allow", origin }; } @@ -49,6 +50,13 @@ export interface HostGateInput { export type HostDecision = { kind: "allow" } | { kind: "reject"; host: string | null }; export function evaluateHost(input: HostGateInput): HostDecision { + // When bound to a wildcard address (0.0.0.0 or ::), the user explicitly + // chose to expose the server. Skip Host header validation — security is + // handled by passcode auth, not by Host restrictions. + if (isKanbanWildcardHost()) { + return { kind: "allow" }; + } + if (!input.hostHeader) { return { kind: "reject", host: null }; } @@ -68,13 +76,21 @@ export function getAllowedHostHeaders(): ReadonlySet { allowed.add(`${host}:${port}`); }; + if (isKanbanWildcardHost()) { + // Host validation is skipped for wildcard binds (see evaluateHost), + // so the allowlist is irrelevant. Return an empty set. + return allowed; + } + if (isKanbanRemoteHost()) { + // When bound to a specific remote host, only that host is allowed. addHostPort(boundHost); return allowed; } addHostPort("localhost"); addHostPort("127.0.0.1"); + if (isDev) { // Vite's default dev server host:port allowed.add("localhost:4173"); diff --git a/test/runtime/runtime-endpoint.test.ts b/test/runtime/runtime-endpoint.test.ts index 42b9cfcd13..bb527ae715 100644 --- a/test/runtime/runtime-endpoint.test.ts +++ b/test/runtime/runtime-endpoint.test.ts @@ -9,6 +9,7 @@ import { getKanbanRuntimePort, getRuntimeFetch, isKanbanRuntimeHttps, + isKanbanWildcardHost, parseRuntimePort, setKanbanRuntimeHost, setKanbanRuntimePort, @@ -105,3 +106,25 @@ describe("runtime-endpoint", () => { expect(await getRuntimeFetch()).not.toBe(globalThis.fetch); }); }); + +describe("isKanbanWildcardHost", () => { + it("returns true for 0.0.0.0", () => { + setKanbanRuntimeHost("0.0.0.0"); + expect(isKanbanWildcardHost()).toBe(true); + }); + + it("returns true for ::", () => { + setKanbanRuntimeHost("::"); + expect(isKanbanWildcardHost()).toBe(true); + }); + + it("returns false for 127.0.0.1", () => { + setKanbanRuntimeHost("127.0.0.1"); + expect(isKanbanWildcardHost()).toBe(false); + }); + + it("returns false for 192.168.1.100", () => { + setKanbanRuntimeHost("192.168.1.100"); + expect(isKanbanWildcardHost()).toBe(false); + }); +}); diff --git a/test/runtime/server/middleware.test.ts b/test/runtime/server/middleware.test.ts index 173875fe6c..14ad3d9eae 100644 --- a/test/runtime/server/middleware.test.ts +++ b/test/runtime/server/middleware.test.ts @@ -1,11 +1,25 @@ import type { IncomingMessage } from "node:http"; import { PassThrough } from "node:stream"; -import { describe, expect, it } from "vitest"; -import { evaluateCors, evaluateHost, handleSocketUpgrade } from "../../../src/server/middleware"; +import { afterEach, describe, expect, it } from "vitest"; +import { + getKanbanRuntimeHost, + getKanbanRuntimePort, + setKanbanRuntimeHost, + setKanbanRuntimePort, +} from "../../../src/core/runtime-endpoint"; +import { evaluateCors, evaluateHost, getAllowedHostHeaders, handleSocketUpgrade } from "../../../src/server/middleware"; const ALLOWED_ORIGIN = "http://127.0.0.1:3484"; const ALLOWED_HOSTS = new Set(["localhost:3484", "127.0.0.1:3484"]); +const originalRuntimePort = getKanbanRuntimePort(); +const originalRuntimeHost = getKanbanRuntimeHost(); + +afterEach(() => { + setKanbanRuntimePort(originalRuntimePort); + setKanbanRuntimeHost(originalRuntimeHost); +}); + function makeFakeRequest(headers: Partial, method = "GET"): IncomingMessage { return { method, headers } as IncomingMessage; } @@ -127,6 +141,27 @@ describe("evaluateHost", () => { host: "localhost:9999", }); }); + + it("when wildcard-bound (0.0.0.0), allows any Host header", () => { + setKanbanRuntimeHost("0.0.0.0"); + setKanbanRuntimePort(3484); + const decision = evaluateHost({ hostHeader: "attacker.com:3484", allowedHosts: new Set() }); + expect(decision).toEqual({ kind: "allow" }); + }); + + it("when wildcard-bound (::), allows any Host header", () => { + setKanbanRuntimeHost("::"); + setKanbanRuntimePort(3484); + const decision = evaluateHost({ hostHeader: "evil.host:3484", allowedHosts: new Set() }); + expect(decision).toEqual({ kind: "allow" }); + }); + + it("when wildcard-bound, allows Host header with an external IP", () => { + setKanbanRuntimeHost("0.0.0.0"); + setKanbanRuntimePort(3484); + const decision = evaluateHost({ hostHeader: "192.168.1.3:3484", allowedHosts: new Set() }); + expect(decision).toEqual({ kind: "allow" }); + }); }); describe("handleSocketUpgrade", () => { @@ -166,4 +201,75 @@ describe("handleSocketUpgrade", () => { expect(result).toEqual({ end: true }); expect(socket.destroyed).toBe(true); }); + + it("when wildcard-bound, passes through upgrades from any host and origin", () => { + setKanbanRuntimeHost("0.0.0.0"); + setKanbanRuntimePort(3484); + const socket = new PassThrough(); + const request = makeFakeRequest({ host: "192.168.1.3:3484", origin: "http://192.168.1.3:3484" }); + const result = handleSocketUpgrade(request, socket); + expect(result).toEqual({ end: false }); + expect(socket.destroyed).toBe(false); + }); +}); + +describe("getAllowedHostHeaders", () => { + it("includes localhost entries for default (localhost) binding", () => { + setKanbanRuntimeHost("127.0.0.1"); + setKanbanRuntimePort(3484); + const allowed = getAllowedHostHeaders(); + expect(allowed.has("localhost:3484")).toBe(true); + expect(allowed.has("127.0.0.1:3484")).toBe(true); + }); + + it("includes only the remote host for remote binding", () => { + setKanbanRuntimeHost("192.168.1.100"); + setKanbanRuntimePort(3484); + const allowed = getAllowedHostHeaders(); + expect(allowed.has("192.168.1.100:3484")).toBe(true); + expect(allowed.has("localhost:3484")).toBe(false); + expect(allowed.has("127.0.0.1:3484")).toBe(false); + }); + + it("returns empty set for wildcard binding", () => { + setKanbanRuntimeHost("0.0.0.0"); + setKanbanRuntimePort(3484); + const allowed = getAllowedHostHeaders(); + expect(allowed.size).toBe(0); + }); +}); + +describe("evaluateCors (wildcard bound)", () => { + it("when wildcard-bound, allows any origin", () => { + setKanbanRuntimeHost("0.0.0.0"); + setKanbanRuntimePort(3484); + const decision = evaluateCors({ + method: "GET", + originHeader: "http://evil.example.com:3484", + allowedOrigin: "http://0.0.0.0:3484", + }); + expect(decision).toEqual({ kind: "allow", origin: "http://evil.example.com:3484" }); + }); + + it("when wildcard-bound, allows preflight from any origin", () => { + setKanbanRuntimeHost("0.0.0.0"); + setKanbanRuntimePort(3484); + const decision = evaluateCors({ + method: "OPTIONS", + originHeader: "http://evil.example.com:3484", + allowedOrigin: "http://0.0.0.0:3484", + }); + expect(decision).toEqual({ kind: "preflight", origin: "http://evil.example.com:3484" }); + }); + + it("when wildcard-bound, allows origin from an external host", () => { + setKanbanRuntimeHost("0.0.0.0"); + setKanbanRuntimePort(3484); + const decision = evaluateCors({ + method: "GET", + originHeader: "http://192.168.1.3:3484", + allowedOrigin: "http://0.0.0.0:3484", + }); + expect(decision).toEqual({ kind: "allow", origin: "http://192.168.1.3:3484" }); + }); });