Skip to content
Merged
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
25 changes: 14 additions & 11 deletions src/server/auth-cors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);
Expand All @@ -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<string, string> {
Expand Down
102 changes: 102 additions & 0 deletions tests/management-origin-tls.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): 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);
});
});
4 changes: 3 additions & 1 deletion tests/storage-cleanup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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 });
Expand Down
Loading