From 1a64585d8e4f93a3af1dd2f6892d1df4db98af50 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Fri, 31 Jul 2026 00:18:53 +0200 Subject: [PATCH 1/4] fix: address issue #760 Fixes #760 --- src/server/auth-cors.ts | 46 ++++++++++++++++------ tests/management-origin-tls.test.ts | 60 +++++++++++++++++++++++++++++ 2 files changed, 95 insertions(+), 11 deletions(-) create mode 100644 tests/management-origin-tls.test.ts diff --git a/src/server/auth-cors.ts b/src/server/auth-cors.ts index 5b4d48c21..4f99ba3c8 100644 --- a/src/server/auth-cors.ts +++ b/src/server/auth-cors.ts @@ -70,16 +70,6 @@ export function isSameOriginAsRequest(req: Request, origin: string): boolean { } export function isAllowedRequestOrigin(req: Request, config: OcxConfig): boolean { - function isExtraAllowedOrigin(origin: string, cfg: OcxConfig): boolean { - if (!cfg.corsAllowOrigins?.length) return false; - return cfg.corsAllowOrigins.some(allowed => { - try { - return new URL(allowed).origin === new URL(origin).origin; - } catch { - return allowed === origin; - } - }); - } const origin = req.headers.get("Origin"); if (!isApiAuthRequired(config)) { if (!isLoopbackRequestHost(req.headers.get("Host"))) return false; @@ -88,6 +78,33 @@ export function isAllowedRequestOrigin(req: Request, config: OcxConfig): boolean return !origin || isLoopbackOriginValue(origin) || isSameOriginAsRequest(req, origin) || isExtraAllowedOrigin(origin, config); } +function isExtraAllowedOrigin(origin: string, cfg: OcxConfig): boolean { + if (!cfg.corsAllowOrigins?.length) return false; + return cfg.corsAllowOrigins.some(allowed => { + try { + return new URL(allowed).origin === new URL(origin).origin; + } catch { + return allowed === origin; + } + }); +} + +/** True when Origin and the process-derived origin share a host across http/https (TLS terminator). */ +function sameManagementHost(origin: string, requestOrigin: string): boolean { + try { + const left = new URL(origin); + const right = new URL(requestOrigin); + if (left.protocol !== "http:" && left.protocol !== "https:") return false; + if (right.protocol !== "http:" && right.protocol !== "https:") return false; + if (left.hostname.toLowerCase() !== right.hostname.toLowerCase()) return false; + // Same scheme with unequal origins means a port (or rare URL) mismatch — keep fail-closed. + // Cross-scheme only: browser https Origin vs process http Host behind a TLS terminator. + return left.protocol !== right.protocol; + } catch { + return false; + } +} + export function managementRequestOrigin(req: Request, config: OcxConfig): string | null { const host = req.headers.get("Host"); const parsedHost = parseHttpHost(host); @@ -106,7 +123,14 @@ export function isAllowedManagementOrigin(req: Request, config: OcxConfig): bool const requestOrigin = managementRequestOrigin(req, config); if (!requestOrigin) return false; const origin = req.headers.get("Origin"); - return !origin || origin === requestOrigin; + if (!origin) return true; + if (origin === requestOrigin) return true; + // Explicit operator allowlist (documented reverse-proxy deployments). + if (isExtraAllowedOrigin(origin, config)) return true; + // TLS-terminating reverse proxy: browser sends https://host while the process sees http://host. + // Hostname match is enough; credentials still required by requireManagementAuth. + if (sameManagementHost(origin, requestOrigin)) return true; + return false; } export function browserSecurityHeaders(): Record { diff --git a/tests/management-origin-tls.test.ts b/tests/management-origin-tls.test.ts new file mode 100644 index 000000000..073cddcf5 --- /dev/null +++ b/tests/management-origin-tls.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, test } from "bun:test"; +import { isAllowedManagementOrigin } from "../src/server/auth-cors"; +import type { OcxConfig } from "../src/types"; + +function config(partial: Partial = {}): OcxConfig { + return { + port: 10100, + hostname: "0.0.0.0", + providers: {}, + ...partial, + } as OcxConfig; +} + +describe("management origin behind TLS termination (#760)", () => { + test("allows https Origin when the process sees http with the same Host", () => { + const req = new Request("http://127.0.0.1:10100/api/v2", { + method: "PUT", + headers: { + Host: "proxy.example.com", + Origin: "https://proxy.example.com", + }, + }); + expect(isAllowedManagementOrigin(req, config())).toBe(true); + }); + + test("still rejects a different Origin host", () => { + const req = new Request("http://127.0.0.1:10100/api/v2", { + method: "PUT", + headers: { + Host: "proxy.example.com", + Origin: "https://evil.example.com", + }, + }); + expect(isAllowedManagementOrigin(req, config())).toBe(false); + }); + + test("still rejects same-scheme cross-port Origins", () => { + const req = new Request("http://127.0.0.1:10100/api/config", { + method: "GET", + headers: { + Host: "127.0.0.1:10100", + Origin: "http://127.0.0.1:65534", + }, + }); + expect(isAllowedManagementOrigin(req, config({ hostname: "127.0.0.1" }))).toBe(false); + }); + + test("honours corsAllowOrigins for an explicit external origin", () => { + const req = new Request("http://127.0.0.1:10100/api/v2", { + method: "PUT", + headers: { + Host: "internal.example.com", + Origin: "https://dashboard.example.com", + }, + }); + expect(isAllowedManagementOrigin(req, config({ + corsAllowOrigins: ["https://dashboard.example.com"], + }))).toBe(true); + }); +}); From efec5fd7837aa89f1b0b7be99ee201241ea56d87 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Fri, 31 Jul 2026 02:20:35 +0200 Subject: [PATCH 2/4] fix(security): tighten TLS skew origin port matching Only allow https-to-http terminator skew for matching public ports (or the 443/80 default pair) and reject unrelated cross-port origins. --- src/server/auth-cors.ts | 13 ++++++++++++- tests/management-origin-tls.test.ts | 11 +++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/src/server/auth-cors.ts b/src/server/auth-cors.ts index 4f99ba3c8..67eb61bb2 100644 --- a/src/server/auth-cors.ts +++ b/src/server/auth-cors.ts @@ -89,6 +89,11 @@ function isExtraAllowedOrigin(origin: string, cfg: OcxConfig): boolean { }); } +function normalizedPort(url: URL): string { + if (url.port) return url.port; + return url.protocol === "https:" ? "443" : "80"; +} + /** True when Origin and the process-derived origin share a host across http/https (TLS terminator). */ function sameManagementHost(origin: string, requestOrigin: string): boolean { try { @@ -98,8 +103,14 @@ function sameManagementHost(origin: string, requestOrigin: string): boolean { if (right.protocol !== "http:" && right.protocol !== "https:") return false; if (left.hostname.toLowerCase() !== right.hostname.toLowerCase()) return false; // Same scheme with unequal origins means a port (or rare URL) mismatch — keep fail-closed. + if (left.protocol === right.protocol) return false; // Cross-scheme only: browser https Origin vs process http Host behind a TLS terminator. - return left.protocol !== right.protocol; + if (left.protocol !== "https:" || right.protocol !== "http:") return false; + const leftPort = normalizedPort(left); + const rightPort = normalizedPort(right); + // Public https:443 in front of an internal http listener on :80 is the common terminator shape. + if (leftPort === rightPort) return true; + return leftPort === "443" && rightPort === "80"; } catch { return false; } diff --git a/tests/management-origin-tls.test.ts b/tests/management-origin-tls.test.ts index 073cddcf5..2a0d1cc0e 100644 --- a/tests/management-origin-tls.test.ts +++ b/tests/management-origin-tls.test.ts @@ -45,6 +45,17 @@ describe("management origin behind TLS termination (#760)", () => { expect(isAllowedManagementOrigin(req, config({ hostname: "127.0.0.1" }))).toBe(false); }); + test("rejects cross-scheme Origins on a different public port", () => { + const req = new Request("http://proxy.example.com/api/config", { + method: "GET", + headers: { + Host: "proxy.example.com", + Origin: "https://proxy.example.com:4443", + }, + }); + expect(isAllowedManagementOrigin(req, config({ hostname: "0.0.0.0" }))).toBe(false); + }); + test("honours corsAllowOrigins for an explicit external origin", () => { const req = new Request("http://127.0.0.1:10100/api/v2", { method: "PUT", From bb1dcead9a5ad7760a27605f911afa19cb0bda8a Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Fri, 31 Jul 2026 07:00:23 +0200 Subject: [PATCH 3/4] fix(security): admit TLS Origin skew only via corsAllowOrigins Drop automatic cross-scheme Host matching so management origin widens only to origins the operator listed, matching the reported reverse-proxy setup without a default change for other deployments. --- src/server/auth-cors.ts | 38 +++----------------------- tests/management-origin-tls.test.ts | 41 +++++++++++++++++++++++++---- 2 files changed, 39 insertions(+), 40 deletions(-) diff --git a/src/server/auth-cors.ts b/src/server/auth-cors.ts index 67eb61bb2..497e23b41 100644 --- a/src/server/auth-cors.ts +++ b/src/server/auth-cors.ts @@ -89,33 +89,6 @@ function isExtraAllowedOrigin(origin: string, cfg: OcxConfig): boolean { }); } -function normalizedPort(url: URL): string { - if (url.port) return url.port; - return url.protocol === "https:" ? "443" : "80"; -} - -/** True when Origin and the process-derived origin share a host across http/https (TLS terminator). */ -function sameManagementHost(origin: string, requestOrigin: string): boolean { - try { - const left = new URL(origin); - const right = new URL(requestOrigin); - if (left.protocol !== "http:" && left.protocol !== "https:") return false; - if (right.protocol !== "http:" && right.protocol !== "https:") return false; - if (left.hostname.toLowerCase() !== right.hostname.toLowerCase()) return false; - // Same scheme with unequal origins means a port (or rare URL) mismatch — keep fail-closed. - if (left.protocol === right.protocol) return false; - // Cross-scheme only: browser https Origin vs process http Host behind a TLS terminator. - if (left.protocol !== "https:" || right.protocol !== "http:") return false; - const leftPort = normalizedPort(left); - const rightPort = normalizedPort(right); - // Public https:443 in front of an internal http listener on :80 is the common terminator shape. - if (leftPort === rightPort) return true; - return leftPort === "443" && rightPort === "80"; - } catch { - return false; - } -} - export function managementRequestOrigin(req: Request, config: OcxConfig): string | null { const host = req.headers.get("Host"); const parsedHost = parseHttpHost(host); @@ -134,14 +107,9 @@ export function isAllowedManagementOrigin(req: Request, config: OcxConfig): bool const requestOrigin = managementRequestOrigin(req, config); if (!requestOrigin) return false; const origin = req.headers.get("Origin"); - if (!origin) return true; - if (origin === requestOrigin) return true; - // Explicit operator allowlist (documented reverse-proxy deployments). - if (isExtraAllowedOrigin(origin, config)) return true; - // TLS-terminating reverse proxy: browser sends https://host while the process sees http://host. - // Hostname match is enough; credentials still required by requireManagementAuth. - if (sameManagementHost(origin, requestOrigin)) return true; - return false; + // Exact match against the process-derived origin, or an operator-listed corsAllowOrigins + // entry (covers TLS-terminator https://… when the process observes http://…). + return !origin || origin === requestOrigin || isExtraAllowedOrigin(origin, config); } export function browserSecurityHeaders(): Record { diff --git a/tests/management-origin-tls.test.ts b/tests/management-origin-tls.test.ts index 2a0d1cc0e..ede00e3ef 100644 --- a/tests/management-origin-tls.test.ts +++ b/tests/management-origin-tls.test.ts @@ -12,7 +12,7 @@ function config(partial: Partial = {}): OcxConfig { } describe("management origin behind TLS termination (#760)", () => { - test("allows https Origin when the process sees http with the same Host", () => { + test("does not auto-admit https Origin when Host is http without corsAllowOrigins", () => { const req = new Request("http://127.0.0.1:10100/api/v2", { method: "PUT", headers: { @@ -20,7 +20,33 @@ describe("management origin behind TLS termination (#760)", () => { Origin: "https://proxy.example.com", }, }); - expect(isAllowedManagementOrigin(req, config())).toBe(true); + expect(isAllowedManagementOrigin(req, config())).toBe(false); + }); + + test("admits https Origin listed in corsAllowOrigins on a remote bind", () => { + const req = new Request("http://127.0.0.1:10100/api/v2", { + method: "PUT", + headers: { + Host: "proxy.example.com", + Origin: "https://proxy.example.com", + }, + }); + expect(isAllowedManagementOrigin(req, config({ + corsAllowOrigins: ["https://proxy.example.com"], + }))).toBe(true); + }); + + test("admits OPTIONS preflight for an allowlisted https Origin", () => { + const req = new Request("http://127.0.0.1:10100/api/v2", { + method: "OPTIONS", + headers: { + Host: "proxy.example.com", + Origin: "https://proxy.example.com", + }, + }); + expect(isAllowedManagementOrigin(req, config({ + corsAllowOrigins: ["https://proxy.example.com"], + }))).toBe(true); }); test("still rejects a different Origin host", () => { @@ -31,7 +57,9 @@ describe("management origin behind TLS termination (#760)", () => { Origin: "https://evil.example.com", }, }); - expect(isAllowedManagementOrigin(req, config())).toBe(false); + expect(isAllowedManagementOrigin(req, config({ + corsAllowOrigins: ["https://proxy.example.com"], + }))).toBe(false); }); test("still rejects same-scheme cross-port Origins", () => { @@ -45,7 +73,7 @@ describe("management origin behind TLS termination (#760)", () => { expect(isAllowedManagementOrigin(req, config({ hostname: "127.0.0.1" }))).toBe(false); }); - test("rejects cross-scheme Origins on a different public port", () => { + test("rejects allowlisted host on a different public port", () => { const req = new Request("http://proxy.example.com/api/config", { method: "GET", headers: { @@ -53,7 +81,10 @@ describe("management origin behind TLS termination (#760)", () => { Origin: "https://proxy.example.com:4443", }, }); - expect(isAllowedManagementOrigin(req, config({ hostname: "0.0.0.0" }))).toBe(false); + expect(isAllowedManagementOrigin(req, config({ + hostname: "0.0.0.0", + corsAllowOrigins: ["https://proxy.example.com"], + }))).toBe(false); }); test("honours corsAllowOrigins for an explicit external origin", () => { From 208cbe0f91975bc9315ae4640034068d766944df Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Fri, 31 Jul 2026 12:35:24 +0200 Subject: [PATCH 4/4] test(storage): budget spawn-edge cleanup under Windows CI load The permanent spawn/dynamic-tool cleanup assertion hit Bun's default 5s timeout on windows-latest for PR #779; give it STORE_BUDGET_MS like the other SQLite cleanup paths. --- tests/storage-cleanup.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/storage-cleanup.test.ts b/tests/storage-cleanup.test.ts index eecdf5e19..f91bba392 100644 --- a/tests/storage-cleanup.test.ts +++ b/tests/storage-cleanup.test.ts @@ -458,6 +458,8 @@ describe("executeArchivedCleanup", () => { expect(existsSync(join(home, ".trash", "77", "rollout-old.jsonl"))).toBe(false); }); + // Windows CI: permanent cleanup + spawn/dynamic-tool SQLite work can exceed Bun's + // default 5s under runner load (timed out at 5.5s on PR #779). test("deletes spawn edges with parent_thread_id/child_thread_id and cascades dynamic tools", () => { home = buildHome({ withSpawnEdges: true, withDynamicTools: true }); // Delete both sides of the edge together so referenced_history does not fire. @@ -467,7 +469,7 @@ describe("executeArchivedCleanup", () => { expect(db.query("SELECT COUNT(*) AS n FROM thread_spawn_edges").get() as { n: number }).toEqual({ n: 0 }); expect(db.query("SELECT COUNT(*) AS n FROM thread_dynamic_tools").get() as { n: number }).toEqual({ n: 0 }); db.close(); - }); + }, { timeout: STORE_BUDGET_MS }); test("rejects candidates still referenced by a live spawn edge", () => { home = buildHome({ withSpawnEdges: true });