Skip to content
61 changes: 49 additions & 12 deletions gui/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,12 @@ function needsApiAuth(input: RequestInfo | URL): boolean {
try {
const raw = input instanceof Request ? input.url : String(input);
const url = new URL(raw, window.location.href);
// Absolute cross-origin URLs must never get the local API token or 401 prompt.
if (url.origin !== window.location.origin) return false;
const admittedOrigin = memoryToken?.startsWith("ocx_session_")
? memorySessionServerOrigin
: window.location.origin;
// A session is destination-bound. Third-party origins get neither credentials
// nor the local admin-token prompt.
if (!admittedOrigin || url.origin !== admittedOrigin) return false;
return url.pathname.startsWith("/api/");
} catch {
return false;
Expand All @@ -67,7 +71,8 @@ const LEGACY_TOKEN_KEY = "opencodex-api-token";
/** In-memory only — never write tokens to web storage (XSS can read sessionStorage/localStorage). */
let memoryToken: string | null = null;
let memoryCsrfToken: string | null = null;
let memorySessionOrigin: string | null = null;
let memorySessionBrowserOrigin: string | null = null;
let memorySessionServerOrigin: string | null = null;

function readToken(): string | null {
return memoryToken;
Expand All @@ -80,7 +85,8 @@ function storeToken(token: string): void {
function clearToken(): void {
memoryToken = null;
memoryCsrfToken = null;
memorySessionOrigin = null;
memorySessionBrowserOrigin = null;
memorySessionServerOrigin = null;
}

function takeMetaContent(name: string): string | null {
Expand All @@ -93,8 +99,9 @@ function takeMetaContent(name: string): string | null {
function loadInjectedSession(): void {
const token = takeMetaContent("opencodex-session-token");
const csrfToken = takeMetaContent("opencodex-session-csrf");
const origin = takeMetaContent("opencodex-session-origin");
storeSession(token, csrfToken, origin);
const browserOrigin = takeMetaContent("opencodex-session-origin");
const serverOrigin = takeMetaContent("opencodex-session-server-origin");
storeSession(token, csrfToken, browserOrigin, serverOrigin, window.location.origin);
}

/** Clear memory only when it still holds `expected` (avoid wiping a newer concurrent store). */
Expand All @@ -103,11 +110,26 @@ function clearTokenIfCurrent(expected: string | null): void {
}

/** Validate and store a server-minted GUI session; rejects anything bound to another origin. */
function storeSession(token: string | null, csrfToken: string | null, origin: string | null): boolean {
if (!token?.startsWith("ocx_session_") || !csrfToken || origin !== window.location.origin) return false;
function storeSession(
token: string | null,
csrfToken: string | null,
browserOrigin: string | null,
serverOrigin: string | null,
expectedServerOrigin: string,
): boolean {
if (
!token?.startsWith("ocx_session_")
|| !csrfToken
|| browserOrigin !== window.location.origin
|| serverOrigin !== expectedServerOrigin
) {
clearToken();
return false;
}
memoryToken = token;
memoryCsrfToken = csrfToken;
memorySessionOrigin = origin;
memorySessionBrowserOrigin = browserOrigin;
memorySessionServerOrigin = serverOrigin;
return true;
}

Expand Down Expand Up @@ -150,10 +172,20 @@ async function reBootstrapSessionToken(): Promise<RebootstrapResult> {
return response.status >= 400 && response.status < 500 ? { kind: "unavailable" } : { kind: "failed" };
}
const html = await response.text();
let responseOrigin: string;
try {
if (!response.url) throw new TypeError("bootstrap response URL is missing");
responseOrigin = new URL(response.url).origin;
} catch {
clearToken();
return { kind: "unavailable" };
}
const stored = storeSession(
metaContentFromHtml(html, "opencodex-session-token"),
metaContentFromHtml(html, "opencodex-session-csrf"),
metaContentFromHtml(html, "opencodex-session-origin"),
metaContentFromHtml(html, "opencodex-session-server-origin"),
responseOrigin,
);
const token = readToken();
if (stored && token) return { kind: "minted", token };
Expand Down Expand Up @@ -188,8 +220,12 @@ function clearLegacySessionToken(): void {
function withToken(input: RequestInfo | URL, init: RequestInit | undefined, token: string): [RequestInfo | URL, RequestInit | undefined] {
const headers = new Headers(init?.headers ?? (input instanceof Request ? input.headers : undefined));
headers.set("X-OpenCodex-API-Key", token);
if (memorySessionOrigin && memoryCsrfToken && token.startsWith("ocx_session_")) {
headers.set("X-OpenCodex-GUI-Origin", memorySessionOrigin);
if (memorySessionBrowserOrigin && memorySessionServerOrigin && memoryCsrfToken && token.startsWith("ocx_session_")) {
const raw = input instanceof Request ? input.url : String(input);
let destinationOrigin: string | null = null;
try { destinationOrigin = new URL(raw, window.location.href).origin; } catch { /* leave null */ }
if (destinationOrigin !== memorySessionServerOrigin) return [input, init];
headers.set("X-OpenCodex-GUI-Origin", memorySessionBrowserOrigin);
const method = (init?.method ?? (input instanceof Request ? input.method : "GET")).toUpperCase();
if (method !== "GET" && method !== "HEAD") {
headers.set("X-OpenCodex-CSRF-Token", memoryCsrfToken);
Expand Down Expand Up @@ -305,7 +341,8 @@ export function resetApiAuthFetchForTests(adminTokenPrompt: AdminTokenPrompt = p
installed = false;
memoryToken = null;
memoryCsrfToken = null;
memorySessionOrigin = null;
memorySessionBrowserOrigin = null;
memorySessionServerOrigin = null;
resolutionInFlight = null;
rawFetch = null;
promptCancelled = false;
Expand Down
13 changes: 9 additions & 4 deletions gui/tests/api-auth-deadline.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ function sessionDocumentHtml(token: string, csrf: string, origin: string): strin
`<meta name="opencodex-session-token" content="${token}">`,
`<meta name="opencodex-session-csrf" content="${csrf}">`,
`<meta name="opencodex-session-origin" content="${origin}">`,
`<meta name="opencodex-session-server-origin" content="${origin}">`,
"</head><body></body></html>",
].join("");
}
Expand All @@ -67,10 +68,14 @@ function hangUntilAborted(signal?: AbortSignal | null): Promise<Response> {
});
}

const MINTED = () => new Response(sessionDocumentHtml("ocx_session_fresh", "fresh-csrf", "http://localhost"), {
status: 200,
headers: { "Content-Type": "text/html" },
});
const MINTED = () => {
const response = new Response(sessionDocumentHtml("ocx_session_fresh", "fresh-csrf", "http://localhost"), {
status: 200,
headers: { "Content-Type": "text/html" },
});
Object.defineProperty(response, "url", { configurable: true, value: "http://localhost/opencodex-session" });
return response;
};
test("hung bootstrap fails the wave within the deadline and a later wave re-bootstraps to success", async () => {
setRebootstrapTimeoutForTests(50);
let bootstrapCalls = 0;
Expand Down
106 changes: 94 additions & 12 deletions gui/tests/api-auth-memory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,14 @@ beforeEach(() => {
fetch: { configurable: true, value: testWindow.fetch.bind(testWindow) },
});
originalPrompt = window.prompt;
// happy-dom does not implement `prompt`, so the admin-token fallback below throws a
// TypeError instead of returning null the moment a test actually reaches it. Most tests
// never do; the ones that clear a rejected session do, and they failed on a missing
// function rather than on the behavior they assert. A null-returning stub is the honest
// stand-in for "the operator dismissed the prompt".
if (typeof window.prompt !== "function") {
Object.defineProperty(testWindow, "prompt", { configurable: true, writable: true, value: () => null });
}
resetApiAuthFetchForTests(async () => {
return window.prompt("OpenCodex admin token (OPENCODEX_ADMIN_AUTH_TOKEN)")?.trim() || null;
});
Expand Down Expand Up @@ -352,11 +360,12 @@ test("data-plane requests never receive the management token or prompt", async (
expect(promptCalls).toBe(beforeCrossPrompts);
});

function injectSessionMeta(token: string, csrf: string, origin: string): void {
function injectSessionMeta(token: string, csrf: string, browserOrigin: string, serverOrigin = browserOrigin): void {
for (const [name, content] of [
["opencodex-session-token", token],
["opencodex-session-csrf", csrf],
["opencodex-session-origin", origin],
["opencodex-session-origin", browserOrigin],
["opencodex-session-server-origin", serverOrigin],
] as const) {
const meta = document.createElement("meta");
meta.setAttribute("name", name);
Expand All @@ -365,16 +374,23 @@ function injectSessionMeta(token: string, csrf: string, origin: string): void {
}
}

function sessionDocumentHtml(token: string, csrf: string, origin: string): string {
function sessionDocumentHtml(token: string, csrf: string, browserOrigin: string, serverOrigin = browserOrigin): string {
return [
"<!doctype html><html><head>",
`<meta name="opencodex-session-token" content="${token}">`,
`<meta name="opencodex-session-csrf" content="${csrf}">`,
`<meta name="opencodex-session-origin" content="${origin}">`,
`<meta name="opencodex-session-origin" content="${browserOrigin}">`,
`<meta name="opencodex-session-server-origin" content="${serverOrigin}">`,
"</head><body></body></html>",
].join("");
}

function htmlResponseAt(html: string, url: string): Response {
const response = new Response(html, { status: 200, headers: { "Content-Type": "text/html" } });
Object.defineProperty(response, "url", { configurable: true, value: url });
return response;
}

test("expired session silently re-bootstraps from the served document without prompting", async () => {
// Regression for the post-security-hardening UX bug: loopback sessions expire after the
// 5-minute TTL (or die on proxy restart), and the dashboard used to demand an admin token
Expand All @@ -392,10 +408,10 @@ test("expired session silently re-bootstraps from the served document without pr
const headers = new Headers(init?.headers ?? (input instanceof Request ? input.headers : undefined));
if (url.pathname === "/opencodex-session") {
bootstrapFetches += 1;
return new Response(sessionDocumentHtml("ocx_session_fresh", "fresh-csrf", "http://localhost"), {
status: 200,
headers: { "Content-Type": "text/html" },
});
return htmlResponseAt(
sessionDocumentHtml("ocx_session_fresh", "fresh-csrf", "http://localhost"),
"http://localhost/opencodex-session",
);
}
seenApiKeys.push(headers.get("X-OpenCodex-API-Key"));
seenGuiOrigins.push(headers.get("X-OpenCodex-GUI-Origin"));
Expand Down Expand Up @@ -428,10 +444,10 @@ test("a session minted for another origin is rejected and the prompt fallback st
const url = new URL(raw, "http://localhost/");
const headers = new Headers(init?.headers);
if (url.pathname === "/opencodex-session") {
return new Response(sessionDocumentHtml("ocx_session_foreign", "foreign-csrf", "http://192.0.2.10:10100"), {
status: 200,
headers: { "Content-Type": "text/html" },
});
return htmlResponseAt(
sessionDocumentHtml("ocx_session_foreign", "foreign-csrf", "http://192.0.2.10:10100"),
"http://localhost/opencodex-session",
);
}
if (headers.get("X-OpenCodex-API-Key") === "manual-admin-token") return new Response("{}", { status: 200 });
return new Response("unauthorized", { status: 401 });
Expand All @@ -446,3 +462,69 @@ test("a session minted for another origin is rejected and the prompt fallback st
expect(res.status).toBe(200);
expect(promptCalls).toBe(1);
});

test("a renewed two-origin session attaches only to its bound server and carries browser origin plus CSRF", async () => {
injectSessionMeta("ocx_session_stale", "stale-csrf", "http://localhost");
const seen = new Map<string, Headers[]>();
let localApiCalls = 0;
const record = (origin: string, headers: Headers) => {
const entries = seen.get(origin) ?? [];
entries.push(headers);
seen.set(origin, entries);
};
const mockFetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
const url = new URL(input instanceof Request ? input.url : String(input), "http://localhost/");
const headers = new Headers(init?.headers ?? (input instanceof Request ? input.headers : undefined));
if (url.pathname === "/opencodex-session") {
return htmlResponseAt(
sessionDocumentHtml("ocx_session_remote", "remote-csrf", "http://localhost", "https://hub.example.test"),
"https://hub.example.test/opencodex-session",
);
}
record(url.origin, headers);
if (url.origin === "http://localhost") {
localApiCalls += 1;
return new Response("{}", { status: localApiCalls === 1 ? 401 : 200 });
}
return new Response("{}", { status: 200 });
}) as typeof fetch;
await installMockAuthFetch(mockFetch);

expect((await fetch("/api/config")).status).toBe(200);
expect((await fetch("https://hub.example.test/api/config", { method: "POST" })).status).toBe(200);
expect((await fetch("https://evil.example.test/api/config")).status).toBe(200);

const hubHeaders = seen.get("https://hub.example.test")?.[0];
expect(hubHeaders?.get("X-OpenCodex-API-Key")).toBe("ocx_session_remote");
expect(hubHeaders?.get("X-OpenCodex-GUI-Origin")).toBe("http://localhost");
expect(hubHeaders?.get("X-OpenCodex-CSRF-Token")).toBe("remote-csrf");
const evilHeaders = seen.get("https://evil.example.test")?.[0];
expect(evilHeaders?.get("X-OpenCodex-API-Key")).toBeNull();
expect(evilHeaders?.get("X-OpenCodex-GUI-Origin")).toBeNull();
});

test("a mismatched bootstrap response/server origin clears every in-memory session field", async () => {
injectSessionMeta("ocx_session_stale", "stale-csrf", "http://localhost");
const seenKeys: Array<string | null> = [];
let apiCalls = 0;
const mockFetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
const url = new URL(input instanceof Request ? input.url : String(input), "http://localhost/");
if (url.pathname === "/opencodex-session") {
return htmlResponseAt(
sessionDocumentHtml("ocx_session_rejected", "new-csrf", "http://localhost", "https://evil.example.test"),
"https://hub.example.test/opencodex-session",
);
}
const headers = new Headers(init?.headers ?? (input instanceof Request ? input.headers : undefined));
seenKeys.push(headers.get("X-OpenCodex-API-Key"));
apiCalls += 1;
return new Response("unauthorized", { status: 401 });
}) as typeof fetch;
await installMockAuthFetch(mockFetch);

expect((await fetch("/api/config")).status).toBe(401);
expect(apiCalls).toBe(1);
expect((await fetch("https://hub.example.test/api/config")).status).toBe(401);
expect(seenKeys).toEqual(["ocx_session_stale", null]);
expect(sessionStorage.getItem(LEGACY_TOKEN_KEY)).toBeNull();
});
49 changes: 28 additions & 21 deletions src/cli/dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -451,27 +451,34 @@ const commandRunners: Record<string, CommandRunner> = {
return ok ? 0 : 1;
},
gui: async deps => {
const config = deps.loadConfig();
// Identity-checked liveness (not the pid file + a fixed sleep): finds a fallback-port
// proxy and waits until the spawned one actually answers before opening the browser.
let live = await deps.findLiveProxy();
if (!live) {
console.log("Proxy not running. Starting...");
deps.spawnDetached(deps.startArgv((config.port ?? 10100) > 0 ? (config.port ?? 10100) : undefined));
live = await deps.waitForProxy();
if (!live) {
console.error("❌ Proxy did not become healthy after starting. Not opening the GUI.");
return 1;
}
}
// Open the host the proxy actually binds — `localhost` only answers for
// loopback/wildcard binds, not a concrete LAN/IPv6 hostname.
const guiHost = deps.probeHostname(live?.hostname ?? config.hostname);
const guiUrl = `http://${guiHost === "127.0.0.1" ? "localhost" : guiHost}:${live?.port ?? config.port}`;
console.log(`Opening ${guiUrl}`);
const { openUrl } = await import("../lib/open-url");
openUrl(guiUrl);
return 0;
const { runGuiCommand } = await import("./gui");
return runGuiCommand(deps.args.slice(1), {
loadConfig: deps.loadConfig,
findLiveProxy: deps.findLiveProxy,
openDefaultGui: async () => {
const config = deps.loadConfig();
// Identity-checked liveness (not the pid file + a fixed sleep): finds a fallback-port
// proxy and waits until the spawned one actually answers before opening the browser.
let live = await deps.findLiveProxy();
if (!live) {
console.log("Proxy not running. Starting...");
deps.spawnDetached(deps.startArgv((config.port ?? 10100) > 0 ? (config.port ?? 10100) : undefined));
live = await deps.waitForProxy();
if (!live) {
console.error("❌ Proxy did not become healthy after starting. Not opening the GUI.");
return 1;
}
}
// Open the host the proxy actually binds — `localhost` only answers for
// loopback/wildcard binds, not a concrete LAN/IPv6 hostname.
const guiHost = deps.probeHostname(live?.hostname ?? config.hostname);
const guiUrl = `http://${guiHost === "127.0.0.1" ? "localhost" : guiHost}:${live?.port ?? config.port}`;
console.log(`Opening ${guiUrl}`);
const { openUrl } = await import("../lib/open-url");
openUrl(guiUrl);
return 0;
},
});
},
service: async deps => {
process.exitCode = 0;
Expand Down
Loading
Loading