Skip to content
Draft
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
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
98 changes: 86 additions & 12 deletions gui/tests/api-auth-memory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
});
originalPrompt = window.prompt;
resetApiAuthFetchForTests(async () => {
return window.prompt("OpenCodex admin token (OPENCODEX_ADMIN_AUTH_TOKEN)")?.trim() || null;

Check failure on line 23 in gui/tests/api-auth-memory.test.ts

View workflow job for this annotation

GitHub Actions / gates

TypeError: window.prompt is not a function. (In 'window.prompt("OpenCodex admin token (OPENCODEX_ADMIN_AUTH_TOKEN)")', 'window.prompt' is undefined)

at <anonymous> (/home/runner/work/opencodex/opencodex/gui/tests/api-auth-memory.test.ts:23:19) at <anonymous> (/home/runner/work/opencodex/opencodex/gui/src/api.ts:270:30)
});
sessionStorage.clear();
});
Expand Down Expand Up @@ -352,11 +352,12 @@
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 +366,23 @@
}
}

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 +400,10 @@
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 +436,10 @@
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 +454,69 @@
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 @@ -284,27 +284,34 @@ const commandRunners: Record<string, CommandRunner> = {
return 0;
},
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