From 680e1c3cbe4852c7c6d4508c646b7b6b4a4a8ed5 Mon Sep 17 00:00:00 2001 From: aung-i Date: Wed, 13 May 2026 17:57:38 +0000 Subject: [PATCH] fix(server): skip Host/CORS validation for wildcard bind addresses When Kanban binds to 0.0.0.0 or ::, browsers send the external IP (e.g. 192.168.1.3:3484) in the Host header, which is never in the allowlist. This causes all requests to be rejected with "Host not allowed." even though the user explicitly chose to expose the server. Skip Host header and CORS origin validation when bound to a wildcard address. Security is maintained by passcode auth, which is still required for remote access (isKanbanRemoteHost returns true for wildcard addresses). Fixes #426, fixes #482 --- src/core/runtime-endpoint.ts | 11 +++ src/server/middleware.ts | 26 ++++-- test/runtime/runtime-endpoint.test.ts | 23 ++++++ test/runtime/server/middleware.test.ts | 110 ++++++++++++++++++++++++- 4 files changed, 163 insertions(+), 7 deletions(-) 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" }); + }); });