diff --git a/src/server/auth-cors.ts b/src/server/auth-cors.ts index 5b4d48c21..497e23b41 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,17 @@ 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; + } + }); +} + export function managementRequestOrigin(req: Request, config: OcxConfig): string | null { const host = req.headers.get("Host"); const parsedHost = parseHttpHost(host); @@ -106,7 +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"); - return !origin || origin === requestOrigin; + // 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 new file mode 100644 index 000000000..ede00e3ef --- /dev/null +++ b/tests/management-origin-tls.test.ts @@ -0,0 +1,102 @@ +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("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: { + Host: "proxy.example.com", + Origin: "https://proxy.example.com", + }, + }); + 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", () => { + 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({ + corsAllowOrigins: ["https://proxy.example.com"], + }))).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("rejects allowlisted host 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", + corsAllowOrigins: ["https://proxy.example.com"], + }))).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); + }); +}); 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 });