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
11 changes: 11 additions & 0 deletions src/core/runtime-endpoint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
26 changes: 21 additions & 5 deletions src/server/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
getKanbanRuntimeOrigin,
getKanbanRuntimePort,
isKanbanRemoteHost,
isKanbanWildcardHost,
} from "../core/runtime-endpoint";

export type CorsDecision =
Expand All @@ -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 };
}
Comment on lines +32 to 43

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 CORS credential reflection allows any cross-origin credentialed request for wildcard binds

When isKanbanWildcardHost() is true and the request has an Origin header, the code falls through to applyAllowedOriginHeaders, which sets both Access-Control-Allow-Origin: <echoed-origin> and Access-Control-Allow-Credentials: true for every origin. This completely removes the DNS-rebinding protection layer: a malicious page can rebind its domain to the server's IP, then make credentialed cross-origin requests to the kanban API. Passcode auth is the sole remaining defense.

This is intentional and documented, and matches how Vite/Webpack dev servers behave — but it is worth confirming that the intended threat model accepts this trade-off for users running with --host 0.0.0.0 (e.g., a shared office network vs. a controlled home LAN).

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/server/middleware.ts
Line: 32-43

Comment:
**CORS credential reflection allows any cross-origin credentialed request for wildcard binds**

When `isKanbanWildcardHost()` is true and the request has an `Origin` header, the code falls through to `applyAllowedOriginHeaders`, which sets both `Access-Control-Allow-Origin: <echoed-origin>` and `Access-Control-Allow-Credentials: true` for every origin. This completely removes the DNS-rebinding protection layer: a malicious page can rebind its domain to the server's IP, then make credentialed cross-origin requests to the kanban API. Passcode auth is the sole remaining defense.

This is intentional and documented, and matches how Vite/Webpack dev servers behave — but it is worth confirming that the intended threat model accepts this trade-off for users running with `--host 0.0.0.0` (e.g., a shared office network vs. a controlled home LAN).

How can I resolve this? If you propose a fix, please make it concise.


Expand All @@ -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 };
}
Expand All @@ -68,13 +76,21 @@ export function getAllowedHostHeaders(): ReadonlySet<string> {
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");
Expand Down
23 changes: 23 additions & 0 deletions test/runtime/runtime-endpoint.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
getKanbanRuntimePort,
getRuntimeFetch,
isKanbanRuntimeHttps,
isKanbanWildcardHost,
parseRuntimePort,
setKanbanRuntimeHost,
setKanbanRuntimePort,
Expand Down Expand Up @@ -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);
});
});
110 changes: 108 additions & 2 deletions test/runtime/server/middleware.test.ts
Original file line number Diff line number Diff line change
@@ -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<IncomingMessage["headers"]>, method = "GET"): IncomingMessage {
return { method, headers } as IncomingMessage;
}
Expand Down Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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" });
});
});