From 73811310eabeaf32c7dca1d2c4584f4bb5ffca30 Mon Sep 17 00:00:00 2001 From: x3M3x Date: Sat, 22 Aug 2026 23:16:09 +0400 Subject: [PATCH 1/5] feat(server): authenticated remote dashboard listener with cookie sessions A dedicated dashboardListener binds a second socket (typically a tailnet address) that serves only the web app, /opencodex-session and /api/*, and refuses the entire /v1 data plane, /healthz and /readyz before any handler runs. The main proxy listener is untouched, so Codex keeps its existing provider and auth mode. POST /api/auth/session exchanges the admin token for a 12h HttpOnly SameSite=Strict cookie; GET reports the session CSRF material so a page refresh re-arms in-memory headers without re-prompting. Cookie sessions authenticate all management routes: reads bind to the Host-derived origin, mutations additionally require the per-session CSRF token and Origin. The dashboard web app arms these headers from the cookie probe and mints the cookie after a manual admin-token sign-in. --- .../src/content/docs/guides/web-dashboard.md | 51 ++++ .../docs/reference/configuration/server.md | 36 +++ gui/src/api.ts | 99 ++++++- gui/tests/api-auth-cookie-session.test.ts | 219 ++++++++++++++++ gui/tests/api-auth-deadline.test.ts | 2 + gui/tests/api-auth-memory.test.ts | 14 +- src/cli/index.ts | 13 +- src/config.ts | 53 +++- src/server/auth-cors.ts | 13 +- src/server/index.ts | 106 +++++++- src/server/management-api.ts | 5 +- src/server/management-auth.ts | 184 ++++++++++--- src/server/ports.ts | 17 +- src/types/config.ts | 23 ++ tests/cli-headless-parity.test.ts | 4 + tests/dashboard-listener-admission.test.ts | 98 +++++++ tests/dashboard-listener-integration.test.ts | 246 ++++++++++++++++++ 17 files changed, 1123 insertions(+), 60 deletions(-) create mode 100644 gui/tests/api-auth-cookie-session.test.ts create mode 100644 tests/dashboard-listener-admission.test.ts create mode 100644 tests/dashboard-listener-integration.test.ts diff --git a/docs-site/src/content/docs/guides/web-dashboard.md b/docs-site/src/content/docs/guides/web-dashboard.md index 6c2ca81e053..fa0c2acc539 100644 --- a/docs-site/src/content/docs/guides/web-dashboard.md +++ b/docs-site/src/content/docs/guides/web-dashboard.md @@ -34,6 +34,57 @@ password manager can offer to save and autofill it. The dashboard itself still k in memory and does not write it to `localStorage` or `sessionStorage`; whether it is saved is entirely the browser or password manager's decision. +## Remote dashboard access (Tailscale) + +opencodex can serve the dashboard to another device — a phone, a second computer — without opening +the proxy itself to the network. The opt-in `dashboardListener` config key opens a second listener +that serves only the dashboard: the web app and the `/api/*` management API. The loopback-only +`/opencodex-session` bootstrap is not part of this remote surface. + +```json +{ + "dashboardListener": { + "enabled": true, + "port": 10101, + "hostname": "100.88.9.100" + } +} +``` + +`hostname` is required and must be a specific non-blank bind address; the typical choice is the +machine's Tailscale IP, making the dashboard reachable at `http://100.88.9.100:10101` from anywhere +on your tailnet. The key is absent by default, and `{ "enabled": false }` is also accepted. The +`port` must differ from the proxy port and from `unauthenticatedLoopbackListener`'s port — a +collision is rejected when the config is written. + +The main proxy listener is completely unchanged: it stays on `127.0.0.1`, and Codex and every other +client keep pointing at the original proxy port. Nothing about provider or API-key mode changes. + +Every data-plane route is refused on this listener: `/v1/*` (Responses, Chat Completions, models, +Messages, Realtime/live), data-plane WebSocket upgrades, and `/readyz` all return `404`. `GET /healthz` is served because the dashboard itself polls it; it returns the standard health payload — service name, version, uptime, pid, port, and restart/provider-reload capability flags — operational metadata only, with no credentials or provider data. + +### Sign-in on the remote dashboard + +Management calls on this listener require the existing admin token (`OPENCODEX_ADMIN_AUTH_TOKEN` or +the generated `~/.opencodex/admin-api-token` file) — the same credential as the local dashboard. +After sign-in the dashboard exchanges the token for a session automatically. `POST /api/auth/session` +with the token in `X-OpenCodex-API-Key` mints a 12-hour GUI session and sets the HttpOnly +`opencodex_gui_session` cookie (`Path=/`; `SameSite=Strict`), while `GET /api/auth/session` returns +the current session's `csrfToken`, `origin`, and `expiresAt` when the cookie is valid. The cookie is +host-scoped and unreadable by page JavaScript, and mutations additionally require the per-session +CSRF token. The phone therefore asks for the admin token once per 12 hours instead of on every +refresh. + +Loopback dashboard behavior is unchanged: the local dashboard keeps auto-minting its 5-minute +sessions into the served page. + +:::caution[Keep it on the tailnet] +The bind hostname should be a private or tailnet address; Tailscale is the recommended setup. The +session cookie travels over plain HTTP to that address, which is acceptable inside a +WireGuard-encrypted tailnet but should not be exposed on an untrusted network. The cookie carries no +`Secure` flag because the tailnet URL is `http://`. +::: + ## What you can do | Area | What it does | diff --git a/docs-site/src/content/docs/reference/configuration/server.md b/docs-site/src/content/docs/reference/configuration/server.md index 939ed837897..df4a130b73f 100644 --- a/docs-site/src/content/docs/reference/configuration/server.md +++ b/docs-site/src/content/docs/reference/configuration/server.md @@ -12,6 +12,7 @@ runs helper features around provider requests. | --- | --- | --- | --- | | `port` | `number` | `10100` | Proxy listen port. | | `hostname?` | `string` | `"127.0.0.1"` | Bind address. Non-loopback binds require `OPENCODEX_API_AUTH_TOKEN`. | +| `dashboardListener?` | `{ enabled?: boolean; port: number; hostname: string }` | off | Opt-in second listener serving only the dashboard and `/api/*` on a private/tailnet address. The main proxy listener is unchanged. See [Remote dashboard access](/guides/web-dashboard/#remote-dashboard-access-tailscale). | | `proxy?` | `string` | — | Outbound HTTP(S) proxy URL or `${ENV_VAR}`. Applied to `HTTP_PROXY` / `HTTPS_PROXY` only when those variables are unset; loopback remains in `NO_PROXY`. | | `noProxy?` | `string \| string[]` | — | Hosts that bypass `proxy`, merged with inherited `NO_PROXY` and loopback entries. A string may use comma-separated `NO_PROXY` syntax or `${ENV_VAR}`. | | `emptyCompletionRetry?` | `boolean` | `false` | Opt in to one identical Responses retry when a turn has no text or tool call, including a stream that ends before a terminal event. The retry may be billable. `OCX_EMPTY_COMPLETION_RETRY=0` disables it without changing config; combo and routed-compaction turns remain excluded. | @@ -125,6 +126,41 @@ a page you visit can make your browser connect to `127.0.0.1`. The listener ther same `Host` and `Origin` checks as an ordinary loopback bind. Off by default. ::: +### Remote dashboard listener + +`dashboardListener` opens a second listener that serves only the dashboard: the web app and the +`/api/*` management API. The loopback-only `/opencodex-session` bootstrap is not part of this +remote surface. The main proxy listener — and +its `hostname` and `port` — are completely unchanged; Codex and every other client keep using the +original `127.0.0.1` address exactly as before. + +```json +{ + "dashboardListener": { + "enabled": true, + "port": 10101, + "hostname": "100.88.9.100" + } +} +``` + +`hostname` is required when the listener is enabled and must be a specific non-blank bind address, +typically the machine's Tailscale IP (`100.x.y.z`). The key is absent by default; an +`{ "enabled": false }` shape is also accepted. `port` must differ from the proxy `port` and from +`unauthenticatedLoopbackListener`'s port; a collision is a write-time validation error. + +Every data-plane route is refused on this listener: `/v1/*` (Responses, Chat Completions, models, +Messages, Realtime/live), data-plane WebSocket upgrades, and `/readyz` all return `404`. `GET /healthz` is served because the dashboard itself polls it; it returns the standard health payload — service name, version, uptime, pid, port, and restart/provider-reload capability flags — operational metadata only, with no credentials or provider data. +Management calls still require the existing admin token (`OPENCODEX_ADMIN_AUTH_TOKEN` or the +generated `~/.opencodex/admin-api-token` file) — the same credential the local dashboard uses. The +bind address should be a private or tailnet address; see +[Remote dashboard access (Tailscale)](/guides/web-dashboard/#remote-dashboard-access-tailscale). + +Session endpoints exist on the proxy listener and the dashboard listener: `POST /api/auth/session` +with the admin token in `X-OpenCodex-API-Key` mints a 12-hour GUI session cookie, and +`GET /api/auth/session` returns the current session's `csrfToken`, `origin`, and `expiresAt`. +The unauthenticated loopback listener returns `404` for all of `/api/*`. + ### SSH port forwarding Remote use does not require a remote bind. Keep loopback and forward it: diff --git a/gui/src/api.ts b/gui/src/api.ts index 658beac1ff1..f915851d920 100644 --- a/gui/src/api.ts +++ b/gui/src/api.ts @@ -25,6 +25,8 @@ let requestAdminToken: AdminTokenPrompt = promptForAdminToken; const SESSION_REBOOTSTRAP_PATH = "/opencodex-session"; /** Safe authenticated read used to validate a raw admin token before closing the sign-in form. */ const ADMIN_TOKEN_VALIDATION_PATH = "/api/settings"; +/** Cookie-session endpoint: GET probes the HttpOnly cookie, POST mints one from an admin token. */ +const AUTH_SESSION_PATH = "/api/auth/session"; /** * The silent re-bootstrap must fail fast: every /api/* request queues behind the @@ -69,6 +71,14 @@ let memoryToken: string | null = null; let memoryCsrfToken: string | null = null; let memorySessionOrigin: string | null = null; +/** + * Cookie-session arm, fired once at install when no memory credential exists. A returning + * visitor with a live `opencodex_gui_session` cookie authenticates through the cookie alone; + * this probe harvests the csrf/origin pair that /api requests must carry alongside it. Any + * failure (401, bad shape, foreign origin) is a silent no-op — the header paths still work. + */ +let cookieSessionArm: Promise | null = null; + function readToken(): string | null { return memoryToken; } @@ -111,6 +121,57 @@ function storeSession(token: string | null, csrfToken: string | null, origin: st return true; } +function startCookieSessionArm(): void { + // Holder: lets the async body compare against the installed arm without a + // use-before-assignment on its own const initializer (TS2454). + const installed: { arm: Promise | null } = { arm: null }; + installed.arm = (async () => { + if (!rawFetch) return; + const bounded = createBoundedFetch(rebootstrapTimeoutMs); + try { + const response = await rawFetch(AUTH_SESSION_PATH, { cache: "no-store", signal: bounded.signal }); + if (!response.ok) return; + const session = await response.json().catch(() => null) as { csrfToken?: unknown; origin?: unknown } | null; + if (!session + || typeof session.csrfToken !== "string" + || !session.csrfToken + || session.origin !== window.location.origin) return; + // Superseded by a reset/reinstall (tests) or by a memory credential that landed meanwhile. + if (cookieSessionArm !== installed.arm || readToken() !== null) return; + // memoryToken stays null on purpose: the HttpOnly cookie is the credential here. + memoryCsrfToken = session.csrfToken; + memorySessionOrigin = session.origin; + } catch { + /* best-effort */ + } finally { + bounded.clear(); + } + })(); + cookieSessionArm = installed.arm; +} + +/** + * Mint the HttpOnly cookie alongside a manually entered admin token, so the next page load + * signs in silently. Best-effort: on failure the header credential still authenticates every + * request; the cookie is an enhancement, never a dependency. + */ +async function mintCookieSession(token: string): Promise { + if (!rawFetch) return; + const bounded = createBoundedFetch(rebootstrapTimeoutMs); + try { + await rawFetch(AUTH_SESSION_PATH, { + method: "POST", + cache: "no-store", + signal: bounded.signal, + headers: { "X-OpenCodex-API-Key": token }, + }); + } catch { + /* best-effort */ + } finally { + bounded.clear(); + } +} + /** Read one named meta tag out of a served HTML document (attribute order varies). */ function metaContentFromHtml(html: string, name: string): string | null { for (const tag of html.match(/]*>/gi) ?? []) { @@ -185,10 +246,14 @@ function clearLegacySessionToken(): void { } } -function withToken(input: RequestInfo | URL, init: RequestInit | undefined, token: string): [RequestInfo | URL, RequestInit | undefined] { +function withToken(input: RequestInfo | URL, init: RequestInit | undefined, token: string | null): [RequestInfo | URL, RequestInit | undefined] { + // Nothing to attach (no credential, no cookie-session pair) — pass through untouched. + if (!token && !(memorySessionOrigin && memoryCsrfToken)) return [input, init]; 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_")) { + if (token) headers.set("X-OpenCodex-API-Key", token); + // Cookie-backed sessions carry the GUI binding headers with any credential shape; the + // server ignores them on the raw-admin-token header path. + if (memorySessionOrigin && memoryCsrfToken) { headers.set("X-OpenCodex-GUI-Origin", memorySessionOrigin); const method = (init?.method ?? (input instanceof Request ? input.method : "GET")).toUpperCase(); if (method !== "GET" && method !== "HEAD") { @@ -234,6 +299,8 @@ async function resolveTokenAfter401(failedToken: string | null, callerSignal?: A const prompted = await requestAdminToken(verifyAdminToken); if (prompted) { storeToken(prompted); + // Bounded and fast; awaiting keeps the retry wave ordered after the mint. + await mintCookieSession(prompted); return prompted; } promptCancelled = true; @@ -270,12 +337,35 @@ export function installApiAuthFetch(): void { loadInjectedSession(); const originalFetch = window.fetch.bind(window); rawFetch = originalFetch; + // No injected meta session (non-loopback or cookie-only visitor): probe the HttpOnly cookie + // session so the first /api wave carries origin/CSRF instead of 401-ing into a spurious prompt. + if (readToken() === null) startCookieSessionArm(); window.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { if (!needsApiAuth(input)) return originalFetch(input, init); const callerSignal = init?.signal ?? (input instanceof Request ? input.signal : undefined); + // While credential-less, wait once for the cookie arm. Racing it would 401 the first wave + // and pop a spurious admin-token prompt. The promise is settle-once; awaiting it again is free. + if (!callerSignal?.aborted && readToken() === null && cookieSessionArm) { + // An aborted caller must not sit out the arm probe's full timeout: race the signal so + // this fetch unwinds immediately while other callers keep waiting for the shared arm. + if (callerSignal) { + let onAbort: (() => void) | undefined; + await Promise.race([ + cookieSessionArm, + new Promise((resolve) => { + onAbort = () => resolve(); + callerSignal.addEventListener("abort", onAbort, { once: true }); + }), + ]).finally(() => { + if (onAbort) callerSignal.removeEventListener("abort", onAbort); + }); + } else { + await cookieSessionArm; + } + } const token = readToken(); - const [firstInput, firstInit] = token ? withToken(input, init, token) : [input, init]; + const [firstInput, firstInit] = withToken(input, init, token); const response = await originalFetch(firstInput, firstInit); if (response.status !== 401) return response; @@ -306,6 +396,7 @@ export function resetApiAuthFetchForTests(adminTokenPrompt: AdminTokenPrompt = p memoryToken = null; memoryCsrfToken = null; memorySessionOrigin = null; + cookieSessionArm = null; resolutionInFlight = null; rawFetch = null; promptCancelled = false; diff --git a/gui/tests/api-auth-cookie-session.test.ts b/gui/tests/api-auth-cookie-session.test.ts new file mode 100644 index 00000000000..6a3383ba32c --- /dev/null +++ b/gui/tests/api-auth-cookie-session.test.ts @@ -0,0 +1,219 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { Window } from "happy-dom"; +import { installApiAuthFetch, resetApiAuthFetchForTests } from "../src/api"; + +const globals = ["document", "window", "navigator", "sessionStorage", "fetch"] as const; +let previousGlobals: Record<(typeof globals)[number], unknown>; +let testWindow: Window; +let promptCalls: number; + +beforeEach(() => { + previousGlobals = Object.fromEntries(globals.map((key) => [key, Reflect.get(globalThis, key)])) as typeof previousGlobals; + testWindow = new Window({ url: "http://localhost/" }); + Object.defineProperties(globalThis, { + document: { configurable: true, value: testWindow.document }, + window: { configurable: true, value: testWindow }, + navigator: { configurable: true, value: testWindow.navigator }, + sessionStorage: { configurable: true, value: testWindow.sessionStorage }, + fetch: { configurable: true, value: testWindow.fetch.bind(testWindow) }, + }); + promptCalls = 0; + resetApiAuthFetchForTests(async () => { + promptCalls += 1; + return null; + }); +}); + +afterEach(() => { + resetApiAuthFetchForTests(); + testWindow.close(); + for (const key of globals) { + Object.defineProperty(globalThis, key, { configurable: true, value: previousGlobals[key] }); + } +}); + +async function installMockAuthFetch(handler: typeof fetch): Promise { + Object.defineProperty(globalThis, "fetch", { configurable: true, value: handler }); + Object.defineProperty(window, "fetch", { configurable: true, value: handler }); + installApiAuthFetch(); + Object.defineProperty(globalThis, "fetch", { configurable: true, value: window.fetch }); +} + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } }); +} + +function pathnameOf(input: RequestInfo | URL): string { + return new URL(input instanceof Request ? input.url : String(input), "http://localhost/").pathname; +} + +function headersOf(input: RequestInfo | URL, init?: RequestInit): Headers { + return new Headers(init?.headers ?? (input instanceof Request ? input.headers : undefined)); +} + +test("cookie arm on install arms origin/CSRF headers with no API key", async () => { + let armCalls = 0; + const seen: Array<{ path: string; method: string; key: string | null; origin: string | null; csrf: string | null }> = []; + const mockFetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + if (pathnameOf(input) === "/api/auth/session") { + armCalls += 1; + return jsonResponse({ csrfToken: "cookie-csrf", origin: "http://localhost", expiresAt: 123 }); + } + const headers = headersOf(input, init); + const method = (init?.method ?? (input instanceof Request ? input.method : "GET")).toUpperCase(); + seen.push({ + path: pathnameOf(input), + method, + key: headers.get("X-OpenCodex-API-Key"), + origin: headers.get("X-OpenCodex-GUI-Origin"), + csrf: headers.get("X-OpenCodex-CSRF-Token"), + }); + if (!headers.get("X-OpenCodex-API-Key") && headers.get("X-OpenCodex-GUI-Origin") === "http://localhost") { + return jsonResponse({}); + } + return new Response("unauthorized", { status: 401 }); + }) as typeof fetch; + await installMockAuthFetch(mockFetch); + + expect((await fetch("/api/config")).status).toBe(200); + expect((await fetch("/api/providers", { method: "POST", body: "{}" })).status).toBe(200); + expect((await fetch("/api/models")).status).toBe(200); + // Settle-once: three requests, one arm probe; nothing prompted, nothing persisted. + expect(armCalls).toBe(1); + expect(promptCalls).toBe(0); + expect(sessionStorage.length).toBe(0); + expect(seen).toEqual([ + { path: "/api/config", method: "GET", key: null, origin: "http://localhost", csrf: null }, + { path: "/api/providers", method: "POST", key: null, origin: "http://localhost", csrf: "cookie-csrf" }, + { path: "/api/models", method: "GET", key: null, origin: "http://localhost", csrf: null }, + ]); +}); + +test("failing cookie arm adds no headers and never prompts by itself", async () => { + let armCalls = 0; + const seenOrigins: Array = []; + const mockFetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const path = pathnameOf(input); + if (path === "/api/auth/session") { + armCalls += 1; + return new Response("unauthorized", { status: 401 }); + } + if (path === "/opencodex-session") return new Response("unauthorized", { status: 401 }); + seenOrigins.push(headersOf(input, init).get("X-OpenCodex-GUI-Origin")); + return new Response("unauthorized", { status: 401 }); + }) as typeof fetch; + await installMockAuthFetch(mockFetch); + + // Let the arm settle: it must be a pure no-op — no headers armed, no prompt opened. + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(armCalls).toBe(1); + expect(promptCalls).toBe(0); + expect(seenOrigins).toEqual([]); + + // A real wave still flows through the ordinary resolution path (prompt here returns null). + expect((await fetch("/api/config")).status).toBe(401); + expect(seenOrigins).toEqual([null]); + expect(promptCalls).toBe(1); +}); + +test("the first /api wave waits for the pending cookie arm instead of racing it", async () => { + let releaseArm!: () => void; + let bareRequests = 0; + const mockFetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + if (pathnameOf(input) === "/api/auth/session") { + await new Promise((resolve) => { + releaseArm = resolve; + }); + return jsonResponse({ csrfToken: "cookie-csrf", origin: "http://localhost", expiresAt: 123 }); + } + const headers = headersOf(input, init); + if (!headers.get("X-OpenCodex-GUI-Origin")) bareRequests += 1; + if (!headers.get("X-OpenCodex-API-Key") && headers.get("X-OpenCodex-GUI-Origin") === "http://localhost") { + return jsonResponse({}); + } + return new Response("unauthorized", { status: 401 }); + }) as typeof fetch; + await installMockAuthFetch(mockFetch); + + const pending = fetch("/api/config").then((response) => response.status); + await new Promise((resolve) => setTimeout(resolve, 20)); + let settled = false; + void pending.then(() => { + settled = true; + }); + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(settled).toBe(false); + + releaseArm(); + expect(await pending).toBe(200); + expect(bareRequests).toBe(0); + expect(promptCalls).toBe(0); +}); + +test("an aborted caller unwinds while the cookie arm is still pending", async () => { + let releaseArm!: () => void; + const mockFetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + if (pathnameOf(input) === "/api/auth/session") { + await new Promise((resolve) => { + releaseArm = resolve; + }); + return jsonResponse({ csrfToken: "cookie-csrf", origin: "http://localhost", expiresAt: 123 }); + } + // A real browser rejects an aborted fetch outright; mirror that so the wrapper cannot + // paper over the race by handing the aborted call to a signal-ignoring mock. + const signal = init?.signal; + if (signal?.aborted) throw new DOMException("Aborted", "AbortError"); + return jsonResponse({}); + }) as typeof fetch; + await installMockAuthFetch(mockFetch); + + const controller = new AbortController(); + const pending = fetch("/api/config", { signal: controller.signal }); + await new Promise((resolve) => setTimeout(resolve, 20)); + controller.abort(); + + // Must reject promptly (AbortError), not sit parked until the arm probe settles. + let rejection: unknown; + await Promise.race([ + pending.catch((error) => { + rejection = error; + }), + new Promise((resolve) => setTimeout(resolve, 100)).then(() => { + throw new Error("aborted fetch did not settle while the arm was pending"); + }), + ]); + expect(rejection).toBeInstanceOf(DOMException); + expect((rejection as DOMException).name).toBe("AbortError"); + + releaseArm(); + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(promptCalls).toBe(0); +}); + +test("admin-token sign-in mints the cookie session best-effort and survives mint failure", async () => { + const mintCalls: Array = []; + resetApiAuthFetchForTests(async () => { + promptCalls += 1; + return "admin-token"; + }); + const mockFetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const path = pathnameOf(input); + const headers = headersOf(input, init); + if (path === "/api/auth/session") { + if ((init?.method ?? "GET").toUpperCase() === "POST") { + mintCalls.push(headers.get("X-OpenCodex-API-Key")); + return new Response("mint refused", { status: 403 }); + } + return new Response("unauthorized", { status: 401 }); + } + if (path === "/opencodex-session") return new Response("unauthorized", { status: 401 }); + if (headers.get("X-OpenCodex-API-Key") === "admin-token") return jsonResponse({}); + return new Response("unauthorized", { status: 401 }); + }) as typeof fetch; + await installMockAuthFetch(mockFetch); + + const response = await fetch("/api/config"); + expect(response.status).toBe(200); + expect(mintCalls).toEqual(["admin-token"]); + expect(promptCalls).toBe(1); +}); diff --git a/gui/tests/api-auth-deadline.test.ts b/gui/tests/api-auth-deadline.test.ts index de4520ad88c..94b89926b02 100644 --- a/gui/tests/api-auth-deadline.test.ts +++ b/gui/tests/api-auth-deadline.test.ts @@ -178,6 +178,8 @@ test("the retried request carries the caller signal", async () => { const seenSignals: Array = []; const mockFetch = (async (input: RequestInfo | URL, init?: RequestInit) => { if (pathnameOf(input) === "/opencodex-session") return MINTED(); + // The install-time cookie arm probe is not a data request; keep it out of seenSignals. + if (pathnameOf(input) === "/api/auth/session") return new Response("unauthorized", { status: 401 }); seenSignals.push(init?.signal ?? (input instanceof Request ? input.signal : undefined)); const key = new Headers(init?.headers ?? (input instanceof Request ? input.headers : undefined)).get("X-OpenCodex-API-Key"); if (key === "ocx_session_fresh") return new Response("{}", { status: 200 }); diff --git a/gui/tests/api-auth-memory.test.ts b/gui/tests/api-auth-memory.test.ts index ca70303e263..d2ef5bbdb5d 100644 --- a/gui/tests/api-auth-memory.test.ts +++ b/gui/tests/api-auth-memory.test.ts @@ -155,9 +155,10 @@ test("concurrent 401s share one token prompt and all retry with the stored token const release401: Array<() => void> = []; const mockFetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { const headers = new Headers(init?.headers); - // Session re-bootstrap probe: this fixture never mints sessions, so fail it fast - // instead of letting it join the release queue below. - if (new URL(_input instanceof Request ? _input.url : String(_input), "http://localhost/").pathname === "/opencodex-session") { + // Session probes (meta re-bootstrap, cookie arm, cookie mint): this fixture never mints + // sessions, so fail them fast instead of letting them join the release queue below. + const path = new URL(_input instanceof Request ? _input.url : String(_input), "http://localhost/").pathname; + if (path === "/opencodex-session" || path === "/api/auth/session") { return new Response("unauthorized", { status: 401 }); } if (headers.get("X-OpenCodex-API-Key") === "shared-token") { @@ -278,7 +279,9 @@ test("canceling the token prompt once does not reopen it for the rest of the 401 const release401: Array<() => void> = []; const mockFetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { const headers = new Headers(init?.headers); - if (new URL(_input instanceof Request ? _input.url : String(_input), "http://localhost/").pathname === "/opencodex-session") { + // Session probes fail fast: this fixture queues only /api data requests. + const path = new URL(_input instanceof Request ? _input.url : String(_input), "http://localhost/").pathname; + if (path === "/opencodex-session" || path === "/api/auth/session") { return new Response("unauthorized", { status: 401 }); } if (headers.get("X-OpenCodex-API-Key")) { @@ -325,6 +328,9 @@ test("data-plane requests never receive the management token or prompt", async ( let phase: "seed" | "cross" = "seed"; const seenHeaders: Array = []; const stateful = (async (_input: RequestInfo | URL, init?: RequestInit) => { + // The install-time cookie-session arm must not count as a data-plane probe here. + const path = new URL(_input instanceof Request ? _input.url : String(_input), "http://localhost/").pathname; + if (path === "/api/auth/session") return new Response("unauthorized", { status: 401 }); const headers = new Headers(init?.headers); seenHeaders.push(headers.get("X-OpenCodex-API-Key")); if (phase === "seed") { diff --git a/src/cli/index.ts b/src/cli/index.ts index 40ad1dca3d8..84c69038b9f 100755 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -146,6 +146,12 @@ async function chooseListenPort(requestedPort?: number): Promise { const reservedLoopbackPort = config.unauthenticatedLoopbackListener?.enabled ? config.unauthenticatedLoopbackListener.port : undefined; + const reservedDashboardPort = config.dashboardListener?.enabled + ? config.dashboardListener.port + : undefined; + const reservedPorts = [reservedLoopbackPort, reservedDashboardPort].filter( + (port): port is number => port !== undefined, + ); // Before the reclaim path, not after (#1102). Asking for the port the loopback listener is // configured to bind is a configuration mistake, and reclaim would spend up to 60 seconds // waiting for a socket to free before reporting "port is busy" — the wrong diagnosis for a @@ -155,6 +161,11 @@ async function chooseListenPort(requestedPort?: number): Promise { `Port ${preferred} is reserved for unauthenticatedLoopbackListener; choose a different proxy port.`, ); } + if (reservedDashboardPort !== undefined && preferred === reservedDashboardPort) { + throw new Error( + `Port ${preferred} is reserved for dashboardListener; choose a different proxy port.`, + ); + } // Soft start: brief prefer-retry then ephemeral hop. // Explicit `--port` (service wrappers / update restart): wait for the pinned port // to free without killing any listener (healthy ocx / foreign). Never hop. @@ -182,7 +193,7 @@ async function chooseListenPort(requestedPort?: number): Promise { // bind (#1102). Without this, `--port ` binds the public listener // first and the loopback bind then fails, rolling back a startup that was only // ever a config collision. - ...(reservedLoopbackPort !== undefined ? { reservedPort: reservedLoopbackPort } : {}), + ...(reservedPorts.length > 0 ? { reservedPort: reservedPorts } : {}), }); if (preferred > 0 && selected !== preferred) { console.log(`⚠️ Port ${preferred} is busy; starting opencodex on ${selected}.`); diff --git a/src/config.ts b/src/config.ts index c24bcb4f0e5..f9ce1c4b730 100644 --- a/src/config.ts +++ b/src/config.ts @@ -878,6 +878,17 @@ const configSchema = z.object({ z.object({ enabled: z.literal(false) }), z.object({ enabled: z.literal(true), port: z.number().int().min(1).max(65535) }), ]).optional().catch(undefined), + // Same discriminated shape and degradation policy as unauthenticatedLoopbackListener: an + // opt-in surface whose hand-edit typos must never reset providers/apiKeys through the + // backup-and-defaults repair path. Write-time rejection lives in dashboardListenerError(). + dashboardListener: z.union([ + z.object({ enabled: z.literal(false) }), + z.object({ + enabled: z.literal(true), + port: z.number().int().min(1).max(65535), + hostname: z.string().trim().min(1), + }), + ]).optional().catch(undefined), providers: z.record(z.string(), providerConfigSchema), defaultProvider: z.string().min(1).default("openai"), defaultModelAliases: z.boolean().optional(), @@ -2189,6 +2200,45 @@ function loopbackListenerPortError(value: unknown): string | null { } export function validateConfigCandidate(value: unknown): { ok: true; config: OcxConfig } | { ok: false; error: string } { + /** + * Write-time relationship checks for the dashboard listener, mirroring + * loopbackListenerPortError: the schema validates each field alone, while port collisions + * are between fields. A live caller is told; a hand-edited config on the read path + * degrades to undefined via the schema catch rather than resetting the file. + */ + function dashboardListenerError(value: unknown): string | null { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + const listener = (value as Record).dashboardListener; + if (listener === undefined) return null; + if (!listener || typeof listener !== "object" || Array.isArray(listener)) { + return "schema_invalid: dashboardListener: must be an object or omitted"; + } + const entry = listener as Record; + // Real boolean required, for the same reason as the loopback listener: a "true" string + // would otherwise be silently deleted by the schema catch while the operator believes + // the listener is on. + if (typeof entry.enabled !== "boolean") { + return "schema_invalid: dashboardListener.enabled: must be a boolean"; + } + if (entry.enabled !== true) return null; + const listenerPort = entry.port; + if (typeof listenerPort !== "number" || !Number.isInteger(listenerPort) || listenerPort < 1 || listenerPort > 65535) { + return "schema_invalid: dashboardListener.port: must be an integer port when enabled"; + } + const listenerHostname = entry.hostname; + if (typeof listenerHostname !== "string" || !listenerHostname.trim()) { + return "schema_invalid: dashboardListener.hostname: must be a non-blank bind address when enabled"; + } + const proxyPort = (value as Record).port; + if (typeof proxyPort === "number" && proxyPort === listenerPort) { + return "schema_invalid: dashboardListener.port: must differ from the proxy port"; + } + const loopback = (value as Record).unauthenticatedLoopbackListener as Record | undefined; + if (loopback && loopback.enabled === true && loopback.port === listenerPort) { + return "schema_invalid: dashboardListener.port: must differ from unauthenticatedLoopbackListener.port"; + } + return null; + } const boundaryError = blankHostnameError(value) ?? claudeSubagentEffortError(value) ?? appOwnedMemoryBudgetError(value) @@ -2199,7 +2249,8 @@ export function validateConfigCandidate(value: unknown): { ok: true; config: Ocx ?? codexAccountPickerEnabledError(value) ?? emptyCompletionRetryError(value) ?? oauthOpenBrowserError(value) - ?? loopbackListenerPortError(value); + ?? loopbackListenerPortError(value) + ?? dashboardListenerError(value); if (boundaryError) return { ok: false, error: boundaryError }; const result = configSchema.safeParse(value); if (result.success) { diff --git a/src/server/auth-cors.ts b/src/server/auth-cors.ts index 9e5bcbb3d36..a8bb18aca9f 100644 --- a/src/server/auth-cors.ts +++ b/src/server/auth-cors.ts @@ -34,6 +34,8 @@ import { xaiResponsesOptInState } from "../providers/xai-responses-opt-in"; let _corsOrigin = "http://localhost:10100"; export function setCorsOrigin(port: number): void { _corsOrigin = `http://localhost:${port}`; } +/** The config slice management-origin decisions read. Full configs and per-listener policy views are both assignable. */ +export type ManagementPolicyView = Pick; /** The proxy's own listening port. No admission check uses it: both loopback predicates key on hostname alone. */ export function configuredPort(): string { try { return new URL(_corsOrigin).port; } catch { return "10100"; } @@ -117,7 +119,7 @@ function comparableOrigin(value: string): string | null { } } -export function managementRequestOrigin(req: Request, config: OcxConfig): string | null { +export function managementRequestOrigin(req: Request, config: Pick): string | null { const host = req.headers.get("Host"); const parsedHost = parseHttpHost(host); if (!host || !parsedHost) return null; @@ -131,7 +133,10 @@ export function managementRequestOrigin(req: Request, config: OcxConfig): string } } -export function isAllowedManagementOrigin(req: Request, config: OcxConfig): boolean { +// The config parameter is a policy view: the dashboard listener passes its own bind +// hostname so management origin checks reflect the listener that received the request, +// exactly like requestPolicyView does for data-plane admission (#1102 pattern). +export function isAllowedManagementOrigin(req: Request, config: Pick): boolean { const requestOrigin = managementRequestOrigin(req, config); if (!requestOrigin) return false; const origin = req.headers.get("Origin"); @@ -198,7 +203,7 @@ export function corsHeaders(req?: Request, config?: RequestPolicyView): Record { +export function managementCorsHeaders(req?: Request, config?: Pick): Record { const headers = corsHeaders(); const origin = req?.headers.get("Origin"); if (origin && req && config && isAllowedManagementOrigin(req, config)) { @@ -219,7 +224,7 @@ export function withCors(response: Response, req: Request, config: RequestPolicy }); } -export function withManagementCors(response: Response, req: Request, config: OcxConfig): Response { +export function withManagementCors(response: Response, req: Request, config: Pick): Response { const headers = new Headers(response.headers); for (const [name, value] of Object.entries(managementCorsHeaders(req, config))) { headers.set(name, value); diff --git a/src/server/index.ts b/src/server/index.ts index c525dad05ee..571241a21f8 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -155,6 +155,7 @@ import { resolveResponsesApiAuth, requestPolicyView, type RequestPolicyView, + type ManagementPolicyView, safeConfigDTO, setCorsOrigin, withCors, @@ -191,6 +192,7 @@ import { fetchAllModels, handleManagementAPI, VERSION, type ManagementApiDeps } import { initializeManagementAuthState, issueGuiSession, + handleGuiSessionEndpoint, managementPrincipal, requireManagementAuth, type ManagementAuthState, @@ -689,6 +691,16 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server requestPolicyView(config, "127.0.0.1"); void publicPolicy; + // Authenticated remote dashboard listener. The bind address is operator-named (typically + // a tailnet IP) and the policy view below is what makes management origin checks treat + // that listener's non-loopback Host as legitimate without touching the main listener's + // loopback policy. Data-plane admission never runs here: the route allowlist below + // refuses /v1/* outright, so no data-plane credential is involved at all. + const dashboardListener = config.dashboardListener; + const dashboardListenerPort = dashboardListener?.enabled ? dashboardListener.port : null; + const dashboardBindHostname = dashboardListener?.enabled ? dashboardListener.hostname.trim() : null; + const dashboardPolicy = (): ManagementPolicyView => requestPolicyView(config, dashboardBindHostname ?? "127.0.0.1"); + /** * Routes the unauthenticated loopback listener will serve. Everything else 404s. * @@ -719,6 +731,28 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server; let loopbackServer: Server | null = null; + let dashboardServer: Server | null = null; let backgroundLifecycle: ReturnType | null = null; try { backgroundLifecycle = acquireServerBackgroundLifecycle(applyPolicy); @@ -892,12 +927,23 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server({ + ...serveOptions, + port: dashboardListenerPort, + hostname: dashboardBindHostname, + }); + } catch (error) { + // Each stop is independent: a throw from the first must not skip the second, + // or a failed dashboard bind strands the unauthenticated loopback listener. + try { + void server.stop(true); + } catch { + /* the original bind error is the one worth reporting */ + } + if (loopbackServer) { + try { + void loopbackServer.stop(true); + } catch { + /* the original bind error is the one worth reporting */ + } + } + throw error; + } + } } catch (error) { userCostOverlayReconciler?.stop(); backgroundLifecycle?.releaseAfterFailedStart(); @@ -1920,6 +2000,7 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server => { @@ -1931,6 +2012,9 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server loopbackListenerRef.stop(closeActiveConnections)] : []), + ...(dashboardListenerRef + ? [() => dashboardListenerRef.stop(closeActiveConnections)] + : []), async () => { userCostOverlayReconciler?.stop(); }, @@ -1965,6 +2049,12 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server = config, ): Promise { - if (!isAllowedManagementOrigin(req, config)) { - return jsonResponse({ error: "cross-origin request blocked" }, 403, req, config); + if (!isAllowedManagementOrigin(req, policy)) { + return jsonResponse({ error: "cross-origin request blocked" }, 403, req, policy); } // Management bodies are small JSON (provider names, key ids, settings). Reject oversized // payloads before any handler buffers them — the data plane has its own decompression cap. diff --git a/src/server/management-auth.ts b/src/server/management-auth.ts index 83c59d8d06c..5ea4cb810e5 100644 --- a/src/server/management-auth.ts +++ b/src/server/management-auth.ts @@ -46,12 +46,19 @@ import { isApiAuthRequired, isDataPlaneAdmissionSecret, isLoopbackHostname, + type ManagementPolicyView, managementRequestOrigin, parseHttpHost, } from "./auth-cors"; const GUI_SESSION_TTL_MS = 5 * 60_000; +// Cookie-carried sessions are minted only in exchange for the admin token, so they can +// outlive the auto-minted loopback sessions without widening trust: same origin binding, +// same CSRF gate, and the token itself stays HttpOnly (unreadable from script). +const GUI_COOKIE_SESSION_TTL_MS = 12 * 60 * 60_000; const GUI_SESSION_LIMIT = 128; +export const GUI_SESSION_COOKIE_NAME = "opencodex_gui_session"; +const GUI_SESSION_ENDPOINT_PATH = "/api/auth/session"; const LOCAL_READ_REPLAY_LIMIT = 256; const consumedLocalReadCapabilities = new Map(); const admittedLocalReadRequests = new WeakSet(); @@ -242,16 +249,11 @@ function randomSessionSecret(prefix: "ocx_session_"): string { return `${prefix}${randomBytes(32).toString("base64url")}`; } -export function issueGuiSession( - req: Request, - config: OcxConfig, - state: ManagementAuthState, -): GuiSessionBootstrap | null { - if (isApiAuthRequired(config) || !state.available || req.method !== "GET" || !isAllowedManagementOrigin(req, config)) return null; - const host = parseHttpHost(req.headers.get("Host")); - if (!host || !isLoopbackHostname(host.hostname)) return null; - const origin = managementRequestOrigin(req, config); - if (!origin) return null; +function mintGuiSession( + state: Extract, + origin: string, + ttlMs: number, +): GuiSessionBootstrap { const now = Date.now(); removeExpiredSessions(state, now); while (state.sessions.size >= GUI_SESSION_LIMIT) { @@ -263,12 +265,148 @@ export function issueGuiSession( const session: GuiSessionRecord = { csrfToken: randomBytes(32).toString("base64url"), origin, - expiresAt: now + GUI_SESSION_TTL_MS, + expiresAt: now + ttlMs, }; state.sessions.set(token, session); return { token, ...session }; } +export function issueGuiSession( + req: Request, + config: OcxConfig, + state: ManagementAuthState, +): GuiSessionBootstrap | null { + if (isApiAuthRequired(config) || !state.available || req.method !== "GET" || !isAllowedManagementOrigin(req, config)) return null; + const host = parseHttpHost(req.headers.get("Host")); + if (!host || !isLoopbackHostname(host.hostname)) return null; + const origin = managementRequestOrigin(req, config); + if (!origin) return null; + return mintGuiSession(state, origin, GUI_SESSION_TTL_MS); +} + +/** Parse the GUI session cookie. Values are opaque base64url tokens, so a plain first-= split is exact. */ +export function readGuiSessionCookie(req: Request): string | null { + const header = req.headers.get("cookie"); + if (!header) return null; + for (const part of header.split(";")) { + const trimmed = part.trim(); + if (!trimmed.startsWith(`${GUI_SESSION_COOKIE_NAME}=`)) continue; + const value = trimmed.slice(GUI_SESSION_COOKIE_NAME.length + 1); + return value || null; + } + return null; +} + +/** The management credential a request presented: header token first, then the session cookie. */ +function managementCredential(req: Request): string | null { + const header = req.headers.get("x-opencodex-api-key")?.trim() + || req.headers.get("authorization")?.replace(/^Bearer\s+/i, "").trim(); + return header || readGuiSessionCookie(req); +} + +/** The origin/CSRF admission a session credential must pass, shared by every gate that reads one. */ +function guiSessionAdmitted(req: Request, session: GuiSessionRecord, config: ManagementPolicyView): boolean { + const requestOrigin = managementRequestOrigin(req, config); + const claimedOrigin = req.headers.get("x-opencodex-gui-origin"); + const browserOrigin = req.headers.get("Origin"); + // Safe methods include the page-refresh probe: no in-memory origin/CSRF exists yet + // (that is what the probe fetches), and a same-origin browser GET sends no Origin + // header either. The Host-derived origin binding plus the SameSite=Strict cookie + // carry the CSRF burden for reads; mutations below still demand the full arm. + const sameOrigin = requestOrigin === session.origin + && (!claimedOrigin || claimedOrigin === session.origin) + && (!browserOrigin || browserOrigin === session.origin); + const safeMethod = req.method === "GET" || req.method === "HEAD"; + const csrf = req.headers.get("x-opencodex-csrf-token")?.trim(); + return sameOrigin + && (safeMethod + || (claimedOrigin === session.origin + && browserOrigin === session.origin + && !!csrf + && equalSecret(csrf, session.csrfToken))); +} + +/** + * Mint a GUI session in exchange for the raw admin token (POST /api/auth/session). + * + * Unlike issueGuiSession this is not loopback-only: the credential, not the transport, + * carries the trust. The browser receives the token as an HttpOnly SameSite=Strict cookie + * so a remote dashboard keeps its sign-in across refreshes without the token ever being + * readable from script. No `Secure` flag: the intended deployment is a plain-http bind on + * a WireGuard-encrypted tailnet address, where Secure would suppress the cookie entirely. + */ +export function issueGuiSessionForAdmin( + req: Request, + config: ManagementPolicyView, + state: ManagementAuthState, +): GuiSessionBootstrap | null { + if (!state.available) return null; + const presented = req.headers.get("x-opencodex-api-key")?.trim() + || req.headers.get("authorization")?.replace(/^Bearer\s+/i, "").trim(); + if (!presented || !equalSecret(presented, state.token)) return null; + if (!isAllowedManagementOrigin(req, config)) return null; + const origin = managementRequestOrigin(req, config); + if (!origin) return null; + return mintGuiSession(state, origin, GUI_COOKIE_SESSION_TTL_MS); +} + +export interface GuiSessionInfo { + csrfToken: string; + origin: string; + expiresAt: number; +} + +/** Resolve the current session credential (header session token or cookie) for GET /api/auth/session. */ +export function guiSessionCredentialInfo( + req: Request, + state: ManagementAuthState, + config: ManagementPolicyView, +): GuiSessionInfo | null { + if (!state.available) return null; + const credential = managementCredential(req); + if (!credential) return null; + removeExpiredSessions(state); + const session = state.sessions.get(credential); + if (!session || !guiSessionAdmitted(req, session, config)) return null; + return { csrfToken: session.csrfToken, origin: session.origin, expiresAt: session.expiresAt }; +} + +/** + * POST /api/auth/session exchanges the admin token for an HttpOnly cookie session; GET + * reports the current session's CSRF material so a refreshed page can re-arm its in-memory + * headers without re-prompting. Returns null for other paths so the caller falls through + * to the normal management gate. + */ +export function handleGuiSessionEndpoint( + req: Request, + url: URL, + state: ManagementAuthState, + config: ManagementPolicyView, +): Response | null { + if (url.pathname !== GUI_SESSION_ENDPOINT_PATH) return null; + if (req.method === "POST") { + const bootstrap = issueGuiSessionForAdmin(req, config, state); + if (!bootstrap) return Response.json({ error: "opencodex admin token required" }, { status: 401 }); + const response = Response.json({ + csrfToken: bootstrap.csrfToken, + origin: bootstrap.origin, + expiresAt: bootstrap.expiresAt, + }); + const headers = new Headers(response.headers); + headers.append( + "Set-Cookie", + `${GUI_SESSION_COOKIE_NAME}=${bootstrap.token}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${Math.floor(GUI_COOKIE_SESSION_TTL_MS / 1000)}`, + ); + return new Response(response.body, { status: response.status, headers }); + } + if (req.method === "GET") { + const info = guiSessionCredentialInfo(req, state, config); + if (!info) return Response.json({ error: "opencodex admin token required" }, { status: 401 }); + return Response.json(info); + } + return Response.json({ error: "method not allowed" }, { status: 405 }); +} + /** * Which credential actually authorized a management request. * @@ -426,15 +564,14 @@ function hasLocalProviderReloadCapability( export function managementPrincipal( req: Request, state: ManagementAuthState, - config?: OcxConfig, + config?: ManagementPolicyView, local?: LocalManagementAuthContext, ): ManagementPrincipal | null { if (hasSystemRestartCapability(req, local)) return "system-restart-capability"; if (hasLocalProviderReloadCapability(req, local)) return "local-provider-reload-capability"; if (hasLocalReadCapability(req, local)) return "local-read-capability"; if (!state.available) return null; - const actual = req.headers.get("x-opencodex-api-key")?.trim() - || req.headers.get("authorization")?.replace(/^Bearer\s+/i, "").trim(); + const actual = managementCredential(req); if (!actual) return null; if (equalSecret(actual, state.token)) return "admin-token"; if (!config) return null; @@ -445,7 +582,7 @@ export function managementPrincipal( export function requireManagementAuth( req: Request, state: ManagementAuthState, - config?: OcxConfig, + config?: ManagementPolicyView, local?: LocalManagementAuthContext, ): Response | null { if (hasSystemRestartCapability(req, local)) return null; @@ -458,25 +595,12 @@ export function requireManagementAuth( hint: "Set OPENCODEX_ADMIN_AUTH_TOKEN to bypass file-backed admin token ACL hardening", }, { status: 503 }); } - const actual = req.headers.get("x-opencodex-api-key")?.trim() - || req.headers.get("authorization")?.replace(/^Bearer\s+/i, "").trim(); + const actual = managementCredential(req); if (actual && equalSecret(actual, state.token)) return null; if (actual && config) { removeExpiredSessions(state); const session = state.sessions.get(actual); - if (session) { - const requestOrigin = managementRequestOrigin(req, config); - const claimedOrigin = req.headers.get("x-opencodex-gui-origin"); - const browserOrigin = req.headers.get("Origin"); - const sameOrigin = requestOrigin === session.origin - && claimedOrigin === session.origin - && (!browserOrigin || browserOrigin === session.origin); - const safeMethod = req.method === "GET" || req.method === "HEAD"; - const csrf = req.headers.get("x-opencodex-csrf-token")?.trim(); - if (sameOrigin && (safeMethod || (browserOrigin === session.origin && !!csrf && equalSecret(csrf, session.csrfToken)))) { - return null; - } - } + if (session && guiSessionAdmitted(req, session, config)) return null; } return Response.json({ error: "opencodex admin token required" }, { status: 401 }); } diff --git a/src/server/ports.ts b/src/server/ports.ts index ae8fe16c1ab..e8f9eb4b6f4 100644 --- a/src/server/ports.ts +++ b/src/server/ports.ts @@ -58,15 +58,16 @@ export type FindAvailablePortOptions = { */ allowEphemeralFallback?: boolean; /** - * A port this selection must never return, even when it is free (#1102). + * A port (or ports) this selection must never return, even when it is free (#1102). * * The unauthenticated loopback listener binds a fixed port from config. If the public * listener took that port first — via an explicit `--port`, a `config.port` of 0, or the * ephemeral fallback happening to land on it — the loopback bind would then fail with * EADDRINUSE, and the startup transaction would roll back a public listener that had - * nothing wrong with it. Excluding the port here fails the right thing at the right time. + * nothing wrong with it. The dashboard listener has the same requirement, so the + * option accepts a list. Excluding the port here fails the right thing at the right time. */ - reservedPort?: number; + reservedPort?: number | number[]; }; export class PortUnavailableError extends Error { @@ -85,10 +86,14 @@ export async function findAvailablePort( ): Promise { const preferRetryMs = opts.preferRetryMs ?? 0; const allowEphemeral = opts.allowEphemeralFallback !== false; - const reserved = opts.reservedPort; + const reserved = opts.reservedPort === undefined + ? [] + : Array.isArray(opts.reservedPort) + ? opts.reservedPort + : [opts.reservedPort]; // An explicit preference for the reserved port is a configuration mistake, not a busy // socket: retrying or hopping would hide it. Refuse before probing anything. - if (reserved !== undefined && preferredPort === reserved) { + if (reserved.includes(preferredPort)) { throw new PortUnavailableError(preferredPort, hostname); } // Port 0 asks the OS to select an ephemeral port. Resolve it to that concrete @@ -113,7 +118,7 @@ export async function findAvailablePort( // async recursion has no way to stop if the assumption is ever wrong. for (let attempt = 0; attempt < EPHEMERAL_REDRAW_LIMIT; attempt += 1) { const port = await allocateEphemeralPort(hostname); - if (port !== reserved) return port; + if (!reserved.includes(port)) return port; } throw new Error("failed to allocate an available port"); } diff --git a/src/types/config.ts b/src/types/config.ts index 0fa86715bda..4683422503e 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -507,6 +507,29 @@ export interface OcxConfig { unauthenticatedLoopbackListener?: | { enabled: false } | { enabled: true; port: number }; + /** + * Opt-in second listener that serves ONLY the web dashboard and the management API + * behind the existing admin token, for reach-from-another-device setups (typically a + * Tailscale tailnet). + * + * The main proxy listener is untouched: it keeps its own hostname/port and admission + * policy, and Codex/clients keep pointing at it. This listener refuses every data-plane + * route (/v1/*, data-plane WebSocket upgrades, /readyz) so binding it to a tailnet + * address never exposes provider credentials or account quota. GET /healthz is served + * for the dashboard overview poll; it discloses operational metadata only (service, + * version, uptime, pid, port, restart/provider-reload capability), never credentials. + * + * Management requests on this listener authenticate with the admin token once and then + * with the HttpOnly cookie session it mints (see /api/auth/session); loopback dashboard + * behavior on the main listener is unchanged. + * + * The port is required when enabled and must differ from the proxy port and from + * unauthenticatedLoopbackListener. The hostname is required so the operator names the + * exact interface (for example the machine's tailnet IP) instead of inheriting a wildcard. + */ + dashboardListener?: + | { enabled: false } + | { enabled: true; port: number; hostname: string }; /** * Outbound HTTP(S) proxy URL for provider requests (e.g. "http://user:pass@proxy:8080", or * "${HTTPS_PROXY}"-style env reference). Mirrored into HTTP_PROXY/HTTPS_PROXY at startup when diff --git a/tests/cli-headless-parity.test.ts b/tests/cli-headless-parity.test.ts index 3900af2d7ae..32100adc8f7 100644 --- a/tests/cli-headless-parity.test.ts +++ b/tests/cli-headless-parity.test.ts @@ -217,6 +217,10 @@ describe("headless GUI parity CLI", () => { } } const coverage: Array<[string, string]> = [ + // GUI-only affordance: the browser dashboard exchanges its once-entered admin + // token for an HttpOnly cookie session here. The CLI already authenticates with + // the admin token directly, so a mirror command would duplicate auth it has. + ["/api/auth/session", "(none — GUI-only)"], ["/api/claude-code", "ocx claude config"], ["/api/claude-desktop", "ocx claude desktop"], ["/api/claude/", "ocx observe"], diff --git a/tests/dashboard-listener-admission.test.ts b/tests/dashboard-listener-admission.test.ts new file mode 100644 index 00000000000..fb72675e874 --- /dev/null +++ b/tests/dashboard-listener-admission.test.ts @@ -0,0 +1,98 @@ +/** + * Write-time validation for the authenticated dashboard listener, mirroring the + * loopback listener contract: field shape is schema work, but port collisions are + * relationships between fields and belong at the boundary (#1102 pattern). + */ +import { describe, expect, test } from "bun:test"; +import { validateConfigCandidate } from "../src/config"; + +const base = { + port: 10100, + providers: { openai: { adapter: "openai", baseUrl: "https://chatgpt.com/backend-api/codex" } }, + defaultProvider: "openai", +} as const; + +describe("dashboard listener configuration", () => { + test("an enabled listener sharing the proxy port is rejected at write time", () => { + const result = validateConfigCandidate({ + ...base, + dashboardListener: { enabled: true, port: 10100, hostname: "100.88.9.100" }, + }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toContain("must differ from the proxy port"); + }); + + test("an enabled listener sharing the loopback listener port is rejected", () => { + const result = validateConfigCandidate({ + ...base, + unauthenticatedLoopbackListener: { enabled: true, port: 10200 }, + dashboardListener: { enabled: true, port: 10200, hostname: "100.88.9.100" }, + }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toContain("must differ from unauthenticatedLoopbackListener.port"); + }); + + test("an enabled listener without a hostname is rejected", () => { + // Unlike the loopback listener, the bind address is the operator's choice; a silent + // default would bind an interface the operator never named. + const result = validateConfigCandidate({ + ...base, + dashboardListener: { enabled: true, port: 10200 }, + }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toContain("hostname"); + }); + + test("a blank hostname is rejected", () => { + const result = validateConfigCandidate({ + ...base, + dashboardListener: { enabled: true, port: 10200, hostname: " " }, + }); + expect(result.ok).toBe(false); + }); + + test("a disabled listener needs neither port nor hostname", () => { + const result = validateConfigCandidate({ + ...base, + dashboardListener: { enabled: false }, + }); + expect(result.ok).toBe(true); + }); + + test("a string enabled flag is rejected instead of silently dropped", () => { + // The schema would `.catch(undefined)` a bad enabled value away; the explicit + // check is what keeps the operator from believing the listener is on while it is off. + const result = validateConfigCandidate({ + ...base, + dashboardListener: { enabled: "true", port: 10200, hostname: "100.88.9.100" }, + }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toContain("dashboardListener.enabled"); + }); + + test("a non-object listener is rejected", () => { + const result = validateConfigCandidate({ ...base, dashboardListener: 10200 }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toContain("must be an object or omitted"); + }); + + test("an out-of-range port is rejected", () => { + const result = validateConfigCandidate({ + ...base, + dashboardListener: { enabled: true, port: 70000, hostname: "100.88.9.100" }, + }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toContain("dashboardListener.port"); + }); + + test("a distinct port with a named bind address is accepted and survives the parse", () => { + const result = validateConfigCandidate({ + ...base, + dashboardListener: { enabled: true, port: 10101, hostname: "100.88.9.100" }, + }); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.config.dashboardListener).toEqual({ enabled: true, port: 10101, hostname: "100.88.9.100" }); + } + }); +}); diff --git a/tests/dashboard-listener-integration.test.ts b/tests/dashboard-listener-integration.test.ts new file mode 100644 index 00000000000..baef5a57828 --- /dev/null +++ b/tests/dashboard-listener-integration.test.ts @@ -0,0 +1,246 @@ +/** + * Integration coverage for the authenticated dashboard listener. + * + * These tests start real servers and speak HTTP to both sockets. The point under + * test is the split surface: the dashboard port admits GUI + management routes with + * the admin credential, refuses the whole /v1 data plane and the health endpoints, + * and mints a cookie session whose CSRF arm guards mutations. The public listener + * must behave identically with and without the dashboard listener configured. + */ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { existsSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { saveConfig } from "../src/config"; +import { startServer } from "../src/server"; +import { findAvailablePort } from "../src/server/ports"; +import type { OcxConfig } from "../src/types"; + +const ADMIN_TOKEN = "admin-secret-for-dashboard-listener"; +const previousHome = process.env.OPENCODEX_HOME; +const previousDataToken = process.env.OPENCODEX_API_AUTH_TOKEN; +const previousAdminToken = process.env.OPENCODEX_ADMIN_AUTH_TOKEN; +let testHome = ""; + +function baseConfig(dashboardPort: number | null, hostname = "127.0.0.1"): OcxConfig { + return { + port: 0, + hostname: "127.0.0.1", + defaultProvider: "test", + providers: { + test: { + adapter: "openai-chat", + baseUrl: "https://example.test/v1", + apiKey: "provider-credential-placeholder", + disabled: true, + models: ["gpt-test"], + }, + }, + ...(dashboardPort === null + ? {} + : { dashboardListener: { enabled: true, port: dashboardPort, hostname } }), + } as unknown as OcxConfig; +} + +/** A free port the way production would choose one, so tests cannot collide on a fixed number. */ +async function freePort(hostname = "127.0.0.1"): Promise { + return await findAvailablePort(0, hostname); +} + +function dashboardUrl(port: number, path: string): string { + return `http://127.0.0.1:${port}${path}`; +} + +beforeEach(() => { + testHome = mkdtempSync(join(tmpdir(), "ocx-dashboard-listener-")); + process.env.OPENCODEX_HOME = testHome; + delete process.env.OPENCODEX_API_AUTH_TOKEN; + process.env.OPENCODEX_ADMIN_AUTH_TOKEN = ADMIN_TOKEN; +}); + +afterEach(() => { + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + if (previousDataToken === undefined) delete process.env.OPENCODEX_API_AUTH_TOKEN; + else process.env.OPENCODEX_API_AUTH_TOKEN = previousDataToken; + if (previousAdminToken === undefined) delete process.env.OPENCODEX_ADMIN_AUTH_TOKEN; + else process.env.OPENCODEX_ADMIN_AUTH_TOKEN = previousAdminToken; + if (testHome && existsSync(testHome)) rmSync(testHome, { recursive: true, force: true }); + testHome = ""; +}); + +describe("dashboard listener surface", () => { + test("serves the SPA root and the management API, and demands the admin token", async () => { + const dashboardPort = await freePort(); + saveConfig(baseConfig(dashboardPort)); + const server = startServer(0); + try { + const root = await fetch(dashboardUrl(dashboardPort, "/")); + expect(root.status).toBe(200); + + const anon = await fetch(dashboardUrl(dashboardPort, "/api/config")); + expect(anon.status).toBe(401); + + const authorized = await fetch(dashboardUrl(dashboardPort, "/api/config"), { + headers: { "x-opencodex-api-key": ADMIN_TOKEN }, + }); + expect(authorized.status).toBe(200); + } finally { + await server.stop(true); + } + }); + + test("refuses the /v1 data plane and /readyz even with the admin token, serves /healthz", async () => { + const dashboardPort = await freePort(); + saveConfig(baseConfig(dashboardPort)); + const server = startServer(0); + try { + for (const path of ["/v1/models", "/v1/responses", "/readyz"]) { + const res = await fetch(dashboardUrl(dashboardPort, path), { + headers: { "x-opencodex-api-key": ADMIN_TOKEN }, + }); + expect(res.status).toBe(404); + } + // Pin the upgrade refusal separately so reordering the allowlist cannot pass silently. + const upgrade = await fetch(dashboardUrl(dashboardPort, "/v1/responses"), { + headers: { + "x-opencodex-api-key": ADMIN_TOKEN, + Upgrade: "websocket", + Connection: "Upgrade", + }, + }); + expect(upgrade.status).toBe(404); + // The dashboard overview polls /healthz; it must work (and discloses no more + // than the already-public SPA shell). + const health = await fetch(dashboardUrl(dashboardPort, "/healthz")); + expect(health.status).toBe(200); + } finally { + await server.stop(true); + } + }); + + test("minting a cookie session keeps the dashboard usable across page refreshes", async () => { + const dashboardPort = await freePort(); + saveConfig(baseConfig(dashboardPort)); + const server = startServer(0); + try { + const mint = await fetch(dashboardUrl(dashboardPort, "/api/auth/session"), { + method: "POST", + headers: { "x-opencodex-api-key": ADMIN_TOKEN }, + }); + expect(mint.status).toBe(200); + const cookieHeader = mint.headers.get("set-cookie") ?? ""; + const cookie = cookieHeader.split(";")[0] ?? ""; + expect(cookie).toMatch(/^opencodex_gui_session=/); + expect(cookieHeader).toContain("HttpOnly"); + expect(cookieHeader).toContain("SameSite=Strict"); + const session = await mint.json() as { csrfToken: string; origin: string }; + expect(session.csrfToken).toBeTruthy(); + expect(session.origin).toContain(String(dashboardPort)); + + // Refreshed page: no token in script, only the cookie the browser replays. + const refreshed = await fetch(dashboardUrl(dashboardPort, "/api/auth/session"), { + headers: { Cookie: cookie }, + }); + expect(refreshed.status).toBe(200); + const again = await refreshed.json() as { csrfToken: string }; + expect(again.csrfToken).toBe(session.csrfToken); + + const read = await fetch(dashboardUrl(dashboardPort, "/api/config"), { + headers: { Cookie: cookie }, + }); + expect(read.status).toBe(200); + + // Mutations need the CSRF arm: the cookie alone must not pass the gate. + const mutationHeaders = (csrf?: string) => ({ + Cookie: cookie, + Origin: session.origin, + "X-OpenCodex-GUI-Origin": session.origin, + ...(csrf === undefined ? {} : { "x-opencodex-csrf-token": csrf }), + }); + const noCsrf = await fetch(dashboardUrl(dashboardPort, "/api/settings"), { + method: "PUT", + headers: mutationHeaders(), + body: "{}", + }); + expect(noCsrf.status).toBe(401); + + const withCsrf = await fetch(dashboardUrl(dashboardPort, "/api/settings"), { + method: "PUT", + headers: mutationHeaders(session.csrfToken), + body: "{}", + }); + // 403 would mean the cross-origin gate regressed; other statuses may reflect body validation. + expect(withCsrf.status).not.toBe(401); + expect(withCsrf.status).not.toBe(403); + } finally { + await server.stop(true); + } + }); + + test("rejects a session mint with a wrong admin token", async () => { + const dashboardPort = await freePort(); + saveConfig(baseConfig(dashboardPort)); + const server = startServer(0); + try { + const mint = await fetch(dashboardUrl(dashboardPort, "/api/auth/session"), { + method: "POST", + headers: { "x-opencodex-api-key": "wrong-token" }, + }); + expect(mint.status).toBe(401); + } finally { + await server.stop(true); + } + }); + + test("leaves the public listener unchanged: loopback data plane stays token-free", async () => { + const dashboardPort = await freePort(); + saveConfig(baseConfig(dashboardPort)); + const server = startServer(0); + try { + const publicRoot = await fetch(`http://127.0.0.1:${server.port}/`); + expect(publicRoot.status).toBe(200); + // Loopback bind: the data plane admits without a credential (#1102 semantics), + // and the admin token still gates /api on both sockets. + const models = await fetch(`http://127.0.0.1:${server.port}/v1/models`); + expect(models.status).toBe(200); + const anon = await fetch(`http://127.0.0.1:${server.port}/api/config`); + expect(anon.status).toBe(401); + } finally { + await server.stop(true); + } + }); + + test("stop closes the dashboard socket too", async () => { + const dashboardPort = await freePort(); + saveConfig(baseConfig(dashboardPort)); + const server = startServer(0); + await server.stop(true); + let stillServing = false; + try { + const res = await fetch(dashboardUrl(dashboardPort, "/api/config")); + stillServing = res.status > 0; + } catch { + stillServing = false; + } + expect(stillServing).toBe(false); + }); + + test("management origin gate follows the listener policy when the proxy stays loopback", async () => { + // The regression this pins: handleManagementAPI re-derived the request origin + // from the shared loopback config, so a non-loopback dashboard Host was rejected + // as cross-origin (403) even after the listener policy had admitted it. 127.0.0.2 + // is non-loopback for the origin check yet bindable on every supported platform. + const dashboardPort = await freePort("127.0.0.2"); + saveConfig(baseConfig(dashboardPort, "127.0.0.2")); + const server = startServer(0); + try { + const authorized = await fetch(`http://127.0.0.2:${dashboardPort}/api/config`, { + headers: { "x-opencodex-api-key": ADMIN_TOKEN }, + }); + expect(authorized.status).toBe(200); + } finally { + await server.stop(true); + } + }); +}); From 08c6772a44b2e019c2960e340b191742dcb4b042 Mon Sep 17 00:00:00 2001 From: x3M3x Date: Sat, 29 Aug 2026 18:20:44 +0400 Subject: [PATCH 2/5] test(dashboard): bind the origin-gate listener to a real interface address --- tests/dashboard-listener-integration.test.ts | 35 ++++++++++++++++---- 1 file changed, 29 insertions(+), 6 deletions(-) diff --git a/tests/dashboard-listener-integration.test.ts b/tests/dashboard-listener-integration.test.ts index baef5a57828..64ca2bb70a6 100644 --- a/tests/dashboard-listener-integration.test.ts +++ b/tests/dashboard-listener-integration.test.ts @@ -9,7 +9,7 @@ */ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { existsSync, mkdtempSync, rmSync } from "node:fs"; -import { tmpdir } from "node:os"; +import { networkInterfaces, tmpdir } from "node:os"; import { join } from "node:path"; import { saveConfig } from "../src/config"; import { startServer } from "../src/server"; @@ -51,6 +51,21 @@ function dashboardUrl(port: number, path: string): string { return `http://127.0.0.1:${port}${path}`; } +/** + * The origin-gate test needs a bind hostname the management gate treats as non-loopback. + * 127.0.0.2 is that on Linux and Windows, but macOS does not bind it (EADDRNOTAVAIL), so + * use the host's real interface address the way tests/loopback-listener-integration.test.ts + * does — the production shape for this listener is a tailnet/LAN bind anyway. + */ +function firstNonLoopbackIPv4(): string | null { + for (const entries of Object.values(networkInterfaces())) { + for (const entry of entries ?? []) { + if (entry.family === "IPv4" && !entry.internal) return entry.address; + } + } + return null; +} + beforeEach(() => { testHome = mkdtempSync(join(tmpdir(), "ocx-dashboard-listener-")); process.env.OPENCODEX_HOME = testHome; @@ -229,13 +244,21 @@ describe("dashboard listener surface", () => { test("management origin gate follows the listener policy when the proxy stays loopback", async () => { // The regression this pins: handleManagementAPI re-derived the request origin // from the shared loopback config, so a non-loopback dashboard Host was rejected - // as cross-origin (403) even after the listener policy had admitted it. 127.0.0.2 - // is non-loopback for the origin check yet bindable on every supported platform. - const dashboardPort = await freePort("127.0.0.2"); - saveConfig(baseConfig(dashboardPort, "127.0.0.2")); + // as cross-origin (403) even after the listener policy had admitted it. The + // listener binds the host's real interface address: non-loopback for the origin + // check and bindable on Linux, Windows, and macOS alike (127.0.0.2 is not bound + // on macOS, which is exactly how this test originally failed there). + const dashboardHost = firstNonLoopbackIPv4(); + if (!dashboardHost) { + // A host with no external IPv4 cannot prove this. Say so rather than pass silently. + console.warn("[dashboard-listener] no non-loopback IPv4 interface; origin-gate check not run"); + return; + } + const dashboardPort = await freePort(dashboardHost); + saveConfig(baseConfig(dashboardPort, dashboardHost)); const server = startServer(0); try { - const authorized = await fetch(`http://127.0.0.2:${dashboardPort}/api/config`, { + const authorized = await fetch(`http://${dashboardHost}:${dashboardPort}/api/config`, { headers: { "x-opencodex-api-key": ADMIN_TOKEN }, }); expect(authorized.status).toBe(200); From d40b9d03caa91fb07582cb65cafbc9df5df6b825 Mon Sep 17 00:00:00 2001 From: x3M3x Date: Sat, 29 Aug 2026 20:47:33 +0400 Subject: [PATCH 3/5] fix(dashboard): harden remote session boundary --- gui/src/api.ts | 142 +++++++------- gui/tests/api-auth-cookie-session.test.ts | 192 +++++++------------ src/config.ts | 19 ++ src/server/index.ts | 14 +- src/server/management-auth.ts | 85 ++------ tests/dashboard-listener-integration.test.ts | 34 ++-- 6 files changed, 200 insertions(+), 286 deletions(-) diff --git a/gui/src/api.ts b/gui/src/api.ts index f915851d920..724a2a27113 100644 --- a/gui/src/api.ts +++ b/gui/src/api.ts @@ -25,7 +25,7 @@ let requestAdminToken: AdminTokenPrompt = promptForAdminToken; const SESSION_REBOOTSTRAP_PATH = "/opencodex-session"; /** Safe authenticated read used to validate a raw admin token before closing the sign-in form. */ const ADMIN_TOKEN_VALIDATION_PATH = "/api/settings"; -/** Cookie-session endpoint: GET probes the HttpOnly cookie, POST mints one from an admin token. */ +/** Remote-dashboard session endpoint: POST mints an opaque session from an admin token. */ const AUTH_SESSION_PATH = "/api/auth/session"; /** @@ -65,19 +65,14 @@ function needsApiAuth(input: RequestInfo | URL): boolean { /** Legacy sessionStorage key from pre-memory auth — wiped once on install, never read. */ const LEGACY_TOKEN_KEY = "opencodex-api-token"; +/** Persistent remote session: origin-scoped and opaque; never contains the raw admin token. */ +const PERSISTENT_GUI_SESSION_KEY = "opencodex-gui-session"; -/** In-memory only — never write tokens to web storage (XSS can read sessionStorage/localStorage). */ +/** The raw admin token stays memory-only. Remote GUI sessions are opaque and process-local. */ let memoryToken: string | null = null; let memoryCsrfToken: string | null = null; let memorySessionOrigin: string | null = null; - -/** - * Cookie-session arm, fired once at install when no memory credential exists. A returning - * visitor with a live `opencodex_gui_session` cookie authenticates through the cookie alone; - * this probe harvests the csrf/origin pair that /api requests must carry alongside it. Any - * failure (401, bad shape, foreign origin) is a silent no-op — the header paths still work. - */ -let cookieSessionArm: Promise | null = null; +let memorySessionPersistent = false; function readToken(): string | null { return memoryToken; @@ -85,12 +80,17 @@ function readToken(): string | null { function storeToken(token: string): void { memoryToken = token; + memoryCsrfToken = null; + memorySessionOrigin = null; + memorySessionPersistent = false; } function clearToken(): void { + if (memorySessionPersistent) clearPersistentGuiSession(); memoryToken = null; memoryCsrfToken = null; memorySessionOrigin = null; + memorySessionPersistent = false; } function takeMetaContent(name: string): string | null { @@ -118,55 +118,76 @@ function storeSession(token: string | null, csrfToken: string | null, origin: st memoryToken = token; memoryCsrfToken = csrfToken; memorySessionOrigin = origin; + memorySessionPersistent = false; return true; } -function startCookieSessionArm(): void { - // Holder: lets the async body compare against the installed arm without a - // use-before-assignment on its own const initializer (TS2454). - const installed: { arm: Promise | null } = { arm: null }; - installed.arm = (async () => { - if (!rawFetch) return; - const bounded = createBoundedFetch(rebootstrapTimeoutMs); - try { - const response = await rawFetch(AUTH_SESSION_PATH, { cache: "no-store", signal: bounded.signal }); - if (!response.ok) return; - const session = await response.json().catch(() => null) as { csrfToken?: unknown; origin?: unknown } | null; - if (!session - || typeof session.csrfToken !== "string" - || !session.csrfToken - || session.origin !== window.location.origin) return; - // Superseded by a reset/reinstall (tests) or by a memory credential that landed meanwhile. - if (cookieSessionArm !== installed.arm || readToken() !== null) return; - // memoryToken stays null on purpose: the HttpOnly cookie is the credential here. - memoryCsrfToken = session.csrfToken; - memorySessionOrigin = session.origin; - } catch { - /* best-effort */ - } finally { - bounded.clear(); +function clearPersistentGuiSession(): void { + try { + localStorage.removeItem(PERSISTENT_GUI_SESSION_KEY); + } catch { + /* browser storage may be disabled */ + } +} + +function loadPersistentGuiSession(): void { + try { + const raw = localStorage.getItem(PERSISTENT_GUI_SESSION_KEY); + if (!raw) return; + const session = JSON.parse(raw) as { token?: unknown; csrfToken?: unknown; origin?: unknown; expiresAt?: unknown }; + if (typeof session.expiresAt !== "number" + || !Number.isFinite(session.expiresAt) + || session.expiresAt <= Date.now() + || !storeSession( + typeof session.token === "string" ? session.token : null, + typeof session.csrfToken === "string" ? session.csrfToken : null, + typeof session.origin === "string" ? session.origin : null, + )) { + clearPersistentGuiSession(); + return; } - })(); - cookieSessionArm = installed.arm; + memorySessionPersistent = true; + } catch { + clearPersistentGuiSession(); + } } -/** - * Mint the HttpOnly cookie alongside a manually entered admin token, so the next page load - * signs in silently. Best-effort: on failure the header credential still authenticates every - * request; the cookie is an enhancement, never a dependency. - */ -async function mintCookieSession(token: string): Promise { - if (!rawFetch) return; +function persistGuiSession(token: string, csrfToken: string, origin: string, expiresAt: number): void { + try { + localStorage.setItem(PERSISTENT_GUI_SESSION_KEY, JSON.stringify({ token, csrfToken, origin, expiresAt })); + memorySessionPersistent = true; + } catch { + /* storage is an enhancement; keep the current page session alive */ + } +} + +/** Mint an opaque session alongside a manually entered admin token for future page loads. */ +async function mintPersistentGuiSession(token: string): Promise { + if (!rawFetch) return null; const bounded = createBoundedFetch(rebootstrapTimeoutMs); try { - await rawFetch(AUTH_SESSION_PATH, { + const response = await rawFetch(AUTH_SESSION_PATH, { method: "POST", cache: "no-store", signal: bounded.signal, headers: { "X-OpenCodex-API-Key": token }, }); + if (!response.ok) return null; + const session = await response.json().catch(() => null) as { + token?: unknown; csrfToken?: unknown; origin?: unknown; expiresAt?: unknown; + } | null; + if (!session + || typeof session.token !== "string" + || typeof session.csrfToken !== "string" + || typeof session.origin !== "string" + || typeof session.expiresAt !== "number" + || !Number.isFinite(session.expiresAt) + || session.expiresAt <= Date.now() + || !storeSession(session.token, session.csrfToken, session.origin)) return null; + persistGuiSession(session.token, session.csrfToken, session.origin, session.expiresAt); + return session.token; } catch { - /* best-effort */ + return null; } finally { bounded.clear(); } @@ -300,8 +321,8 @@ async function resolveTokenAfter401(failedToken: string | null, callerSignal?: A if (prompted) { storeToken(prompted); // Bounded and fast; awaiting keeps the retry wave ordered after the mint. - await mintCookieSession(prompted); - return prompted; + // The raw token remains a current-page fallback if the remote session mint fails. + return await mintPersistentGuiSession(prompted) ?? prompted; } promptCancelled = true; return null; @@ -337,33 +358,12 @@ export function installApiAuthFetch(): void { loadInjectedSession(); const originalFetch = window.fetch.bind(window); rawFetch = originalFetch; - // No injected meta session (non-loopback or cookie-only visitor): probe the HttpOnly cookie - // session so the first /api wave carries origin/CSRF instead of 401-ing into a spurious prompt. - if (readToken() === null) startCookieSessionArm(); + // Remote dashboards restore only an opaque session scoped to this exact browser origin. + if (readToken() === null) loadPersistentGuiSession(); window.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { if (!needsApiAuth(input)) return originalFetch(input, init); const callerSignal = init?.signal ?? (input instanceof Request ? input.signal : undefined); - // While credential-less, wait once for the cookie arm. Racing it would 401 the first wave - // and pop a spurious admin-token prompt. The promise is settle-once; awaiting it again is free. - if (!callerSignal?.aborted && readToken() === null && cookieSessionArm) { - // An aborted caller must not sit out the arm probe's full timeout: race the signal so - // this fetch unwinds immediately while other callers keep waiting for the shared arm. - if (callerSignal) { - let onAbort: (() => void) | undefined; - await Promise.race([ - cookieSessionArm, - new Promise((resolve) => { - onAbort = () => resolve(); - callerSignal.addEventListener("abort", onAbort, { once: true }); - }), - ]).finally(() => { - if (onAbort) callerSignal.removeEventListener("abort", onAbort); - }); - } else { - await cookieSessionArm; - } - } const token = readToken(); const [firstInput, firstInit] = withToken(input, init, token); const response = await originalFetch(firstInput, firstInit); @@ -396,7 +396,7 @@ export function resetApiAuthFetchForTests(adminTokenPrompt: AdminTokenPrompt = p memoryToken = null; memoryCsrfToken = null; memorySessionOrigin = null; - cookieSessionArm = null; + memorySessionPersistent = false; resolutionInFlight = null; rawFetch = null; promptCancelled = false; diff --git a/gui/tests/api-auth-cookie-session.test.ts b/gui/tests/api-auth-cookie-session.test.ts index 6a3383ba32c..6683bd324ac 100644 --- a/gui/tests/api-auth-cookie-session.test.ts +++ b/gui/tests/api-auth-cookie-session.test.ts @@ -2,19 +2,20 @@ import { afterEach, beforeEach, expect, test } from "bun:test"; import { Window } from "happy-dom"; import { installApiAuthFetch, resetApiAuthFetchForTests } from "../src/api"; -const globals = ["document", "window", "navigator", "sessionStorage", "fetch"] as const; +const globals = ["document", "window", "navigator", "sessionStorage", "localStorage", "fetch"] as const; let previousGlobals: Record<(typeof globals)[number], unknown>; let testWindow: Window; let promptCalls: number; beforeEach(() => { previousGlobals = Object.fromEntries(globals.map((key) => [key, Reflect.get(globalThis, key)])) as typeof previousGlobals; - testWindow = new Window({ url: "http://localhost/" }); + testWindow = new Window({ url: "http://100.88.9.100:10101/" }); Object.defineProperties(globalThis, { document: { configurable: true, value: testWindow.document }, window: { configurable: true, value: testWindow }, navigator: { configurable: true, value: testWindow.navigator }, sessionStorage: { configurable: true, value: testWindow.sessionStorage }, + localStorage: { configurable: true, value: testWindow.localStorage }, fetch: { configurable: true, value: testWindow.fetch.bind(testWindow) }, }); promptCalls = 0; @@ -44,176 +45,111 @@ function jsonResponse(body: unknown, status = 200): Response { } function pathnameOf(input: RequestInfo | URL): string { - return new URL(input instanceof Request ? input.url : String(input), "http://localhost/").pathname; + return new URL(input instanceof Request ? input.url : String(input), window.location.href).pathname; } function headersOf(input: RequestInfo | URL, init?: RequestInit): Headers { return new Headers(init?.headers ?? (input instanceof Request ? input.headers : undefined)); } -test("cookie arm on install arms origin/CSRF headers with no API key", async () => { - let armCalls = 0; - const seen: Array<{ path: string; method: string; key: string | null; origin: string | null; csrf: string | null }> = []; +test("a persisted opaque session is origin-scoped and re-arms dashboard requests without a prompt", async () => { + const session = { + token: "ocx_session_saved", + csrfToken: "saved-csrf", + origin: window.location.origin, + expiresAt: Date.now() + 60_000, + }; + localStorage.setItem("opencodex-gui-session", JSON.stringify(session)); + const seen: Array<{ path: string; key: string | null; origin: string | null; csrf: string | null; cookie: string | null }> = []; const mockFetch = (async (input: RequestInfo | URL, init?: RequestInit) => { - if (pathnameOf(input) === "/api/auth/session") { - armCalls += 1; - return jsonResponse({ csrfToken: "cookie-csrf", origin: "http://localhost", expiresAt: 123 }); - } const headers = headersOf(input, init); - const method = (init?.method ?? (input instanceof Request ? input.method : "GET")).toUpperCase(); seen.push({ path: pathnameOf(input), - method, key: headers.get("X-OpenCodex-API-Key"), origin: headers.get("X-OpenCodex-GUI-Origin"), csrf: headers.get("X-OpenCodex-CSRF-Token"), + cookie: headers.get("Cookie"), }); - if (!headers.get("X-OpenCodex-API-Key") && headers.get("X-OpenCodex-GUI-Origin") === "http://localhost") { - return jsonResponse({}); - } - return new Response("unauthorized", { status: 401 }); + return jsonResponse({}); }) as typeof fetch; await installMockAuthFetch(mockFetch); expect((await fetch("/api/config")).status).toBe(200); expect((await fetch("/api/providers", { method: "POST", body: "{}" })).status).toBe(200); - expect((await fetch("/api/models")).status).toBe(200); - // Settle-once: three requests, one arm probe; nothing prompted, nothing persisted. - expect(armCalls).toBe(1); expect(promptCalls).toBe(0); - expect(sessionStorage.length).toBe(0); expect(seen).toEqual([ - { path: "/api/config", method: "GET", key: null, origin: "http://localhost", csrf: null }, - { path: "/api/providers", method: "POST", key: null, origin: "http://localhost", csrf: "cookie-csrf" }, - { path: "/api/models", method: "GET", key: null, origin: "http://localhost", csrf: null }, + { path: "/api/config", key: "ocx_session_saved", origin: window.location.origin, csrf: null, cookie: null }, + { path: "/api/providers", key: "ocx_session_saved", origin: window.location.origin, csrf: "saved-csrf", cookie: null }, ]); }); -test("failing cookie arm adds no headers and never prompts by itself", async () => { - let armCalls = 0; - const seenOrigins: Array = []; +test("admin-token sign-in persists only the opaque session returned by the remote listener", async () => { + resetApiAuthFetchForTests(async () => { + promptCalls += 1; + return "admin-token"; + }); + const seenKeys: string[] = []; const mockFetch = (async (input: RequestInfo | URL, init?: RequestInit) => { const path = pathnameOf(input); + const headers = headersOf(input, init); + const key = headers.get("X-OpenCodex-API-Key"); + if (path === "/opencodex-session") return new Response("missing", { status: 404 }); if (path === "/api/auth/session") { - armCalls += 1; - return new Response("unauthorized", { status: 401 }); + expect(init?.method).toBe("POST"); + expect(key).toBe("admin-token"); + return jsonResponse({ + token: "ocx_session_minted", + csrfToken: "minted-csrf", + origin: window.location.origin, + expiresAt: Date.now() + 60_000, + }); } - if (path === "/opencodex-session") return new Response("unauthorized", { status: 401 }); - seenOrigins.push(headersOf(input, init).get("X-OpenCodex-GUI-Origin")); - return new Response("unauthorized", { status: 401 }); + seenKeys.push(key ?? ""); + return key === "ocx_session_minted" ? jsonResponse({}) : new Response("unauthorized", { status: 401 }); }) as typeof fetch; await installMockAuthFetch(mockFetch); - // Let the arm settle: it must be a pure no-op — no headers armed, no prompt opened. - await new Promise((resolve) => setTimeout(resolve, 10)); - expect(armCalls).toBe(1); - expect(promptCalls).toBe(0); - expect(seenOrigins).toEqual([]); - - // A real wave still flows through the ordinary resolution path (prompt here returns null). - expect((await fetch("/api/config")).status).toBe(401); - expect(seenOrigins).toEqual([null]); + expect((await fetch("/api/config")).status).toBe(200); expect(promptCalls).toBe(1); + expect(seenKeys).toEqual(["", "ocx_session_minted"]); + const persisted = localStorage.getItem("opencodex-gui-session") ?? ""; + expect(persisted).toContain("ocx_session_minted"); + expect(persisted).not.toContain("admin-token"); }); -test("the first /api wave waits for the pending cookie arm instead of racing it", async () => { - let releaseArm!: () => void; - let bareRequests = 0; - const mockFetch = (async (input: RequestInfo | URL, init?: RequestInit) => { - if (pathnameOf(input) === "/api/auth/session") { - await new Promise((resolve) => { - releaseArm = resolve; - }); - return jsonResponse({ csrfToken: "cookie-csrf", origin: "http://localhost", expiresAt: 123 }); - } - const headers = headersOf(input, init); - if (!headers.get("X-OpenCodex-GUI-Origin")) bareRequests += 1; - if (!headers.get("X-OpenCodex-API-Key") && headers.get("X-OpenCodex-GUI-Origin") === "http://localhost") { - return jsonResponse({}); - } - return new Response("unauthorized", { status: 401 }); - }) as typeof fetch; - await installMockAuthFetch(mockFetch); - - const pending = fetch("/api/config").then((response) => response.status); - await new Promise((resolve) => setTimeout(resolve, 20)); - let settled = false; - void pending.then(() => { - settled = true; +test("a failed session mint keeps the raw admin token in memory only for the current page", async () => { + resetApiAuthFetchForTests(async () => { + promptCalls += 1; + return "admin-token"; }); - await new Promise((resolve) => setTimeout(resolve, 10)); - expect(settled).toBe(false); - - releaseArm(); - expect(await pending).toBe(200); - expect(bareRequests).toBe(0); - expect(promptCalls).toBe(0); -}); - -test("an aborted caller unwinds while the cookie arm is still pending", async () => { - let releaseArm!: () => void; const mockFetch = (async (input: RequestInfo | URL, init?: RequestInit) => { - if (pathnameOf(input) === "/api/auth/session") { - await new Promise((resolve) => { - releaseArm = resolve; - }); - return jsonResponse({ csrfToken: "cookie-csrf", origin: "http://localhost", expiresAt: 123 }); - } - // A real browser rejects an aborted fetch outright; mirror that so the wrapper cannot - // paper over the race by handing the aborted call to a signal-ignoring mock. - const signal = init?.signal; - if (signal?.aborted) throw new DOMException("Aborted", "AbortError"); - return jsonResponse({}); + const path = pathnameOf(input); + const key = headersOf(input, init).get("X-OpenCodex-API-Key"); + if (path === "/opencodex-session") return new Response("missing", { status: 404 }); + if (path === "/api/auth/session") return new Response("mint refused", { status: 403 }); + return key === "admin-token" ? jsonResponse({}) : new Response("unauthorized", { status: 401 }); }) as typeof fetch; await installMockAuthFetch(mockFetch); - const controller = new AbortController(); - const pending = fetch("/api/config", { signal: controller.signal }); - await new Promise((resolve) => setTimeout(resolve, 20)); - controller.abort(); - - // Must reject promptly (AbortError), not sit parked until the arm probe settles. - let rejection: unknown; - await Promise.race([ - pending.catch((error) => { - rejection = error; - }), - new Promise((resolve) => setTimeout(resolve, 100)).then(() => { - throw new Error("aborted fetch did not settle while the arm was pending"); - }), - ]); - expect(rejection).toBeInstanceOf(DOMException); - expect((rejection as DOMException).name).toBe("AbortError"); - - releaseArm(); - await new Promise((resolve) => setTimeout(resolve, 10)); - expect(promptCalls).toBe(0); + expect((await fetch("/api/config")).status).toBe(200); + expect(promptCalls).toBe(1); + expect(localStorage.getItem("opencodex-gui-session")).toBeNull(); }); -test("admin-token sign-in mints the cookie session best-effort and survives mint failure", async () => { - const mintCalls: Array = []; - resetApiAuthFetchForTests(async () => { - promptCalls += 1; - return "admin-token"; - }); - const mockFetch = (async (input: RequestInfo | URL, init?: RequestInit) => { - const path = pathnameOf(input); - const headers = headersOf(input, init); - if (path === "/api/auth/session") { - if ((init?.method ?? "GET").toUpperCase() === "POST") { - mintCalls.push(headers.get("X-OpenCodex-API-Key")); - return new Response("mint refused", { status: 403 }); - } - return new Response("unauthorized", { status: 401 }); - } - if (path === "/opencodex-session") return new Response("unauthorized", { status: 401 }); - if (headers.get("X-OpenCodex-API-Key") === "admin-token") return jsonResponse({}); +test("a rejected persisted session is removed before the next prompt", async () => { + localStorage.setItem("opencodex-gui-session", JSON.stringify({ + token: "ocx_session_expired", + csrfToken: "csrf", + origin: window.location.origin, + expiresAt: Date.now() + 60_000, + })); + const mockFetch = (async (input: RequestInfo | URL) => { + if (pathnameOf(input) === "/opencodex-session") return new Response("missing", { status: 404 }); return new Response("unauthorized", { status: 401 }); }) as typeof fetch; await installMockAuthFetch(mockFetch); - const response = await fetch("/api/config"); - expect(response.status).toBe(200); - expect(mintCalls).toEqual(["admin-token"]); + expect((await fetch("/api/config")).status).toBe(401); expect(promptCalls).toBe(1); + expect(localStorage.getItem("opencodex-gui-session")).toBeNull(); }); diff --git a/src/config.ts b/src/config.ts index f9ce1c4b730..535da607e31 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,5 +1,6 @@ import { createHash } from "node:crypto"; import { chmodSync, constants as fsConstants, copyFileSync, existsSync, linkSync, mkdirSync, readFileSync, truncateSync, unlinkSync, writeFileSync } from "node:fs"; +import { isIP } from "node:net"; import { dirname, join } from "node:path"; import { Database } from "bun:sqlite"; import * as z from "zod/v4"; @@ -2229,6 +2230,24 @@ export function validateConfigCandidate(value: unknown): { ok: true; config: Ocx if (typeof listenerHostname !== "string" || !listenerHostname.trim()) { return "schema_invalid: dashboardListener.hostname: must be a non-blank bind address when enabled"; } + const bindAddress = listenerHostname.trim(); + const ipVersion = isIP(bindAddress); + const privateDashboardAddress = ipVersion === 4 + ? (() => { + const [first, second] = bindAddress.split(".").map(Number); + return first === 10 + || (first === 172 && second >= 16 && second <= 31) + || (first === 192 && second === 168) + || (first === 100 && second >= 64 && second <= 127); + })() + : ipVersion === 6 + && (() => { + const firstHextet = Number.parseInt(bindAddress.split(":")[0] || "0", 16); + return firstHextet >= 0xfc00 && firstHextet <= 0xfdff; + })(); + if (!privateDashboardAddress) { + return "schema_invalid: dashboardListener.hostname: must be a literal private or tailnet IP address (RFC1918, 100.64.0.0/10, or IPv6 ULA); DNS names, wildcard, loopback, and public addresses are not allowed"; + } const proxyPort = (value as Record).port; if (typeof proxyPort === "number" && proxyPort === listenerPort) { return "schema_invalid: dashboardListener.port: must differ from the proxy port"; diff --git a/src/server/index.ts b/src/server/index.ts index 571241a21f8..e33faadbbaa 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -191,6 +191,7 @@ import { handleSearch } from "./search"; import { fetchAllModels, handleManagementAPI, VERSION, type ManagementApiDeps } from "./management-api"; import { initializeManagementAuthState, + GUI_SESSION_ENDPOINT_PATH, issueGuiSession, handleGuiSessionEndpoint, managementPrincipal, @@ -1099,8 +1100,17 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server(); const admittedLocalReadRequests = new WeakSet(); @@ -284,24 +283,10 @@ export function issueGuiSession( return mintGuiSession(state, origin, GUI_SESSION_TTL_MS); } -/** Parse the GUI session cookie. Values are opaque base64url tokens, so a plain first-= split is exact. */ -export function readGuiSessionCookie(req: Request): string | null { - const header = req.headers.get("cookie"); - if (!header) return null; - for (const part of header.split(";")) { - const trimmed = part.trim(); - if (!trimmed.startsWith(`${GUI_SESSION_COOKIE_NAME}=`)) continue; - const value = trimmed.slice(GUI_SESSION_COOKIE_NAME.length + 1); - return value || null; - } - return null; -} - -/** The management credential a request presented: header token first, then the session cookie. */ +/** The management credential a request presented in a header. */ function managementCredential(req: Request): string | null { - const header = req.headers.get("x-opencodex-api-key")?.trim() + return req.headers.get("x-opencodex-api-key")?.trim() || req.headers.get("authorization")?.replace(/^Bearer\s+/i, "").trim(); - return header || readGuiSessionCookie(req); } /** The origin/CSRF admission a session credential must pass, shared by every gate that reads one. */ @@ -309,10 +294,8 @@ function guiSessionAdmitted(req: Request, session: GuiSessionRecord, config: Man const requestOrigin = managementRequestOrigin(req, config); const claimedOrigin = req.headers.get("x-opencodex-gui-origin"); const browserOrigin = req.headers.get("Origin"); - // Safe methods include the page-refresh probe: no in-memory origin/CSRF exists yet - // (that is what the probe fetches), and a same-origin browser GET sends no Origin - // header either. The Host-derived origin binding plus the SameSite=Strict cookie - // carry the CSRF burden for reads; mutations below still demand the full arm. + // Safe methods need no CSRF token. The opaque session credential is carried in a + // header and remains bound to this exact origin; mutations still demand the full arm. const sameOrigin = requestOrigin === session.origin && (!claimedOrigin || claimedOrigin === session.origin) && (!browserOrigin || browserOrigin === session.origin); @@ -330,10 +313,8 @@ function guiSessionAdmitted(req: Request, session: GuiSessionRecord, config: Man * Mint a GUI session in exchange for the raw admin token (POST /api/auth/session). * * Unlike issueGuiSession this is not loopback-only: the credential, not the transport, - * carries the trust. The browser receives the token as an HttpOnly SameSite=Strict cookie - * so a remote dashboard keeps its sign-in across refreshes without the token ever being - * readable from script. No `Secure` flag: the intended deployment is a plain-http bind on - * a WireGuard-encrypted tailnet address, where Secure would suppress the cookie entirely. + * carries the trust. The browser stores this opaque, process-local token at its exact + * dashboard origin, avoiding hostname-scoped cookie replay to another HTTP port. */ export function issueGuiSessionForAdmin( req: Request, @@ -347,35 +328,13 @@ export function issueGuiSessionForAdmin( if (!isAllowedManagementOrigin(req, config)) return null; const origin = managementRequestOrigin(req, config); if (!origin) return null; - return mintGuiSession(state, origin, GUI_COOKIE_SESSION_TTL_MS); -} - -export interface GuiSessionInfo { - csrfToken: string; - origin: string; - expiresAt: number; -} - -/** Resolve the current session credential (header session token or cookie) for GET /api/auth/session. */ -export function guiSessionCredentialInfo( - req: Request, - state: ManagementAuthState, - config: ManagementPolicyView, -): GuiSessionInfo | null { - if (!state.available) return null; - const credential = managementCredential(req); - if (!credential) return null; - removeExpiredSessions(state); - const session = state.sessions.get(credential); - if (!session || !guiSessionAdmitted(req, session, config)) return null; - return { csrfToken: session.csrfToken, origin: session.origin, expiresAt: session.expiresAt }; + return mintGuiSession(state, origin, GUI_PERSISTENT_SESSION_TTL_MS); } /** - * POST /api/auth/session exchanges the admin token for an HttpOnly cookie session; GET - * reports the current session's CSRF material so a refreshed page can re-arm its in-memory - * headers without re-prompting. Returns null for other paths so the caller falls through - * to the normal management gate. + * POST /api/auth/session exchanges the admin token for an opaque browser-stored session. + * The route is mounted only on the dedicated remote dashboard listener; ordinary loopback + * dashboards use their injected short-lived session instead. */ export function handleGuiSessionEndpoint( req: Request, @@ -387,22 +346,12 @@ export function handleGuiSessionEndpoint( if (req.method === "POST") { const bootstrap = issueGuiSessionForAdmin(req, config, state); if (!bootstrap) return Response.json({ error: "opencodex admin token required" }, { status: 401 }); - const response = Response.json({ + return Response.json({ + token: bootstrap.token, csrfToken: bootstrap.csrfToken, origin: bootstrap.origin, expiresAt: bootstrap.expiresAt, }); - const headers = new Headers(response.headers); - headers.append( - "Set-Cookie", - `${GUI_SESSION_COOKIE_NAME}=${bootstrap.token}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${Math.floor(GUI_COOKIE_SESSION_TTL_MS / 1000)}`, - ); - return new Response(response.body, { status: response.status, headers }); - } - if (req.method === "GET") { - const info = guiSessionCredentialInfo(req, state, config); - if (!info) return Response.json({ error: "opencodex admin token required" }, { status: 401 }); - return Response.json(info); } return Response.json({ error: "method not allowed" }, { status: 405 }); } diff --git a/tests/dashboard-listener-integration.test.ts b/tests/dashboard-listener-integration.test.ts index 64ca2bb70a6..ef05a4aa013 100644 --- a/tests/dashboard-listener-integration.test.ts +++ b/tests/dashboard-listener-integration.test.ts @@ -4,7 +4,7 @@ * These tests start real servers and speak HTTP to both sockets. The point under * test is the split surface: the dashboard port admits GUI + management routes with * the admin credential, refuses the whole /v1 data plane and the health endpoints, - * and mints a cookie session whose CSRF arm guards mutations. The public listener + * and mints an opaque session whose CSRF arm guards mutations. The public listener * must behave identically with and without the dashboard listener configured. */ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; @@ -134,7 +134,7 @@ describe("dashboard listener surface", () => { } }); - test("minting a cookie session keeps the dashboard usable across page refreshes", async () => { + test("minting an opaque session keeps the dashboard usable across page refreshes", async () => { const dashboardPort = await freePort(); saveConfig(baseConfig(dashboardPort)); const server = startServer(0); @@ -144,31 +144,26 @@ describe("dashboard listener surface", () => { headers: { "x-opencodex-api-key": ADMIN_TOKEN }, }); expect(mint.status).toBe(200); - const cookieHeader = mint.headers.get("set-cookie") ?? ""; - const cookie = cookieHeader.split(";")[0] ?? ""; - expect(cookie).toMatch(/^opencodex_gui_session=/); - expect(cookieHeader).toContain("HttpOnly"); - expect(cookieHeader).toContain("SameSite=Strict"); - const session = await mint.json() as { csrfToken: string; origin: string }; + expect(mint.headers.get("set-cookie")).toBeNull(); + const session = await mint.json() as { token: string; csrfToken: string; origin: string }; + expect(session.token).toMatch(/^ocx_session_/); expect(session.csrfToken).toBeTruthy(); expect(session.origin).toContain(String(dashboardPort)); - // Refreshed page: no token in script, only the cookie the browser replays. - const refreshed = await fetch(dashboardUrl(dashboardPort, "/api/auth/session"), { - headers: { Cookie: cookie }, + // The session endpoint only mints; a refresh uses the opaque browser-stored token. + const sessionGet = await fetch(dashboardUrl(dashboardPort, "/api/auth/session"), { + headers: { "x-opencodex-api-key": session.token }, }); - expect(refreshed.status).toBe(200); - const again = await refreshed.json() as { csrfToken: string }; - expect(again.csrfToken).toBe(session.csrfToken); + expect(sessionGet.status).toBe(405); const read = await fetch(dashboardUrl(dashboardPort, "/api/config"), { - headers: { Cookie: cookie }, + headers: { "x-opencodex-api-key": session.token }, }); expect(read.status).toBe(200); - // Mutations need the CSRF arm: the cookie alone must not pass the gate. + // Mutations need the CSRF arm: the session token alone must not pass the gate. const mutationHeaders = (csrf?: string) => ({ - Cookie: cookie, + "x-opencodex-api-key": session.token, Origin: session.origin, "X-OpenCodex-GUI-Origin": session.origin, ...(csrf === undefined ? {} : { "x-opencodex-csrf-token": csrf }), @@ -221,6 +216,11 @@ describe("dashboard listener surface", () => { expect(models.status).toBe(200); const anon = await fetch(`http://127.0.0.1:${server.port}/api/config`); expect(anon.status).toBe(401); + const sessionEndpoint = await fetch(`http://127.0.0.1:${server.port}/api/auth/session`, { + method: "POST", + headers: { "x-opencodex-api-key": ADMIN_TOKEN }, + }); + expect(sessionEndpoint.status).toBe(404); } finally { await server.stop(true); } From 98f401ef6a82997e1091141bfadf7421255f673d Mon Sep 17 00:00:00 2001 From: x3M3x Date: Sat, 29 Aug 2026 21:19:09 +0400 Subject: [PATCH 4/5] fix(dashboard): scope remote sessions to browser origin --- .../src/content/docs/guides/web-dashboard.md | 37 +++++++++---------- .../docs/reference/configuration/server.md | 21 +++++++---- gui/src/api.ts | 6 +-- gui/tests/api-auth-deadline.test.ts | 2 +- gui/tests/api-auth-memory.test.ts | 4 +- ...ts => api-auth-persistent-session.test.ts} | 20 ++++++++++ src/server/management-auth.ts | 3 +- src/types/config.ts | 7 ++-- tests/cli-headless-parity.test.ts | 2 +- tests/dashboard-listener-admission.test.ts | 29 +++++++++++---- 10 files changed, 85 insertions(+), 46 deletions(-) rename gui/tests/{api-auth-cookie-session.test.ts => api-auth-persistent-session.test.ts} (88%) diff --git a/docs-site/src/content/docs/guides/web-dashboard.md b/docs-site/src/content/docs/guides/web-dashboard.md index fa0c2acc539..2d28fea9a7d 100644 --- a/docs-site/src/content/docs/guides/web-dashboard.md +++ b/docs-site/src/content/docs/guides/web-dashboard.md @@ -30,9 +30,8 @@ the admin token (`OPENCODEX_ADMIN_AUTH_TOKEN`, or the auto-generated `~/.opencodex/admin-api-token` file). When a remote dashboard needs that credential, it presents a standard password form so a browser -password manager can offer to save and autofill it. The dashboard itself still keeps the token only -in memory and does not write it to `localStorage` or `sessionStorage`; whether it is saved is entirely -the browser or password manager's decision. +password manager can offer to save and autofill it. The raw admin token stays only in memory; after +sign-in, the dashboard stores a separate opaque session for that exact browser origin. ## Remote dashboard access (Tailscale) @@ -51,11 +50,12 @@ that serves only the dashboard: the web app and the `/api/*` management API. The } ``` -`hostname` is required and must be a specific non-blank bind address; the typical choice is the -machine's Tailscale IP, making the dashboard reachable at `http://100.88.9.100:10101` from anywhere -on your tailnet. The key is absent by default, and `{ "enabled": false }` is also accepted. The -`port` must differ from the proxy port and from `unauthenticatedLoopbackListener`'s port — a -collision is rejected when the config is written. +`hostname` is required and must be a literal private address: RFC1918 IPv4, Tailscale CGNAT +(`100.64.0.0/10`), or IPv6 ULA (`fc00::/7`). The typical choice is the machine's Tailscale IP, +making the dashboard reachable at `http://100.88.9.100:10101` from anywhere on your tailnet. +Wildcard, loopback, public, and DNS addresses are rejected. The key is absent by default, and +`{ "enabled": false }` is also accepted. The `port` must differ from the proxy port and from +`unauthenticatedLoopbackListener`'s port — a collision is rejected when the config is written. The main proxy listener is completely unchanged: it stays on `127.0.0.1`, and Codex and every other client keep pointing at the original proxy port. Nothing about provider or API-key mode changes. @@ -67,22 +67,21 @@ Messages, Realtime/live), data-plane WebSocket upgrades, and `/readyz` all retur Management calls on this listener require the existing admin token (`OPENCODEX_ADMIN_AUTH_TOKEN` or the generated `~/.opencodex/admin-api-token` file) — the same credential as the local dashboard. -After sign-in the dashboard exchanges the token for a session automatically. `POST /api/auth/session` -with the token in `X-OpenCodex-API-Key` mints a 12-hour GUI session and sets the HttpOnly -`opencodex_gui_session` cookie (`Path=/`; `SameSite=Strict`), while `GET /api/auth/session` returns -the current session's `csrfToken`, `origin`, and `expiresAt` when the cookie is valid. The cookie is -host-scoped and unreadable by page JavaScript, and mutations additionally require the per-session -CSRF token. The phone therefore asks for the admin token once per 12 hours instead of on every -refresh. +After sign-in the dashboard exchanges the token for a 12-hour opaque session automatically. +`POST /api/auth/session` receives the admin token in `X-OpenCodex-API-Key` and returns the opaque +session, CSRF token, bound origin, and expiry. The browser stores that separate session only for the +exact scheme, host, and port; it never stores the raw admin token. Mutations additionally require the +per-session CSRF token. The phone therefore asks for the admin token once per 12 hours instead of on +every refresh. Loopback dashboard behavior is unchanged: the local dashboard keeps auto-minting its 5-minute sessions into the served page. :::caution[Keep it on the tailnet] -The bind hostname should be a private or tailnet address; Tailscale is the recommended setup. The -session cookie travels over plain HTTP to that address, which is acceptable inside a -WireGuard-encrypted tailnet but should not be exposed on an untrusted network. The cookie carries no -`Secure` flag because the tailnet URL is `http://`. +The bind hostname is limited to a private or tailnet address; Tailscale is the recommended setup. +This feature does not add TLS, so use it only over a trusted private transport such as a +WireGuard-encrypted tailnet. The persistent opaque session is readable by JavaScript at that exact +origin, so keep the dashboard free of untrusted scripts and do not expose it on an untrusted network. ::: ## What you can do diff --git a/docs-site/src/content/docs/reference/configuration/server.md b/docs-site/src/content/docs/reference/configuration/server.md index df4a130b73f..5be3f7269f1 100644 --- a/docs-site/src/content/docs/reference/configuration/server.md +++ b/docs-site/src/content/docs/reference/configuration/server.md @@ -144,10 +144,12 @@ original `127.0.0.1` address exactly as before. } ``` -`hostname` is required when the listener is enabled and must be a specific non-blank bind address, -typically the machine's Tailscale IP (`100.x.y.z`). The key is absent by default; an -`{ "enabled": false }` shape is also accepted. `port` must differ from the proxy `port` and from -`unauthenticatedLoopbackListener`'s port; a collision is a write-time validation error. +`hostname` is required when the listener is enabled and must be a literal private address: RFC1918 +IPv4, Tailscale CGNAT (`100.64.0.0/10`), or IPv6 ULA (`fc00::/7`). Typically this is the machine's +Tailscale IP (`100.x.y.z`). Wildcard, loopback, public, and DNS addresses are rejected. The key is +absent by default; an `{ "enabled": false }` shape is also accepted. `port` must differ from the +proxy `port` and from `unauthenticatedLoopbackListener`'s port; a collision is a write-time +validation error. Every data-plane route is refused on this listener: `/v1/*` (Responses, Chat Completions, models, Messages, Realtime/live), data-plane WebSocket upgrades, and `/readyz` all return `404`. `GET /healthz` is served because the dashboard itself polls it; it returns the standard health payload — service name, version, uptime, pid, port, and restart/provider-reload capability flags — operational metadata only, with no credentials or provider data. @@ -156,10 +158,13 @@ generated `~/.opencodex/admin-api-token` file) — the same credential the local bind address should be a private or tailnet address; see [Remote dashboard access (Tailscale)](/guides/web-dashboard/#remote-dashboard-access-tailscale). -Session endpoints exist on the proxy listener and the dashboard listener: `POST /api/auth/session` -with the admin token in `X-OpenCodex-API-Key` mints a 12-hour GUI session cookie, and -`GET /api/auth/session` returns the current session's `csrfToken`, `origin`, and `expiresAt`. -The unauthenticated loopback listener returns `404` for all of `/api/*`. +Only the dashboard listener exposes `POST /api/auth/session`. With the admin token in +`X-OpenCodex-API-Key`, it mints a 12-hour opaque session carrying its CSRF token, exact bound origin, +and expiry. The dashboard stores that session only at the exact browser origin; it never persists the +raw admin token. `GET /api/auth/session` is not supported, and the proxy and unauthenticated loopback +listeners return `404` for this endpoint. This feature does not add TLS: use the allowed private bind +only through a trusted transport such as Tailscale, and keep the dashboard free of untrusted scripts +because that origin can read its opaque session. ### SSH port forwarding diff --git a/gui/src/api.ts b/gui/src/api.ts index 724a2a27113..bda15c0cfce 100644 --- a/gui/src/api.ts +++ b/gui/src/api.ts @@ -268,12 +268,12 @@ function clearLegacySessionToken(): void { } function withToken(input: RequestInfo | URL, init: RequestInit | undefined, token: string | null): [RequestInfo | URL, RequestInit | undefined] { - // Nothing to attach (no credential, no cookie-session pair) — pass through untouched. + // Nothing to attach (no credential or opaque session) — pass through untouched. if (!token && !(memorySessionOrigin && memoryCsrfToken)) return [input, init]; const headers = new Headers(init?.headers ?? (input instanceof Request ? input.headers : undefined)); if (token) headers.set("X-OpenCodex-API-Key", token); - // Cookie-backed sessions carry the GUI binding headers with any credential shape; the - // server ignores them on the raw-admin-token header path. + // Opaque sessions carry the GUI binding headers; the server ignores them on the + // raw-admin-token header path. if (memorySessionOrigin && memoryCsrfToken) { headers.set("X-OpenCodex-GUI-Origin", memorySessionOrigin); const method = (init?.method ?? (input instanceof Request ? input.method : "GET")).toUpperCase(); diff --git a/gui/tests/api-auth-deadline.test.ts b/gui/tests/api-auth-deadline.test.ts index 94b89926b02..44763201cf9 100644 --- a/gui/tests/api-auth-deadline.test.ts +++ b/gui/tests/api-auth-deadline.test.ts @@ -178,7 +178,7 @@ test("the retried request carries the caller signal", async () => { const seenSignals: Array = []; const mockFetch = (async (input: RequestInfo | URL, init?: RequestInit) => { if (pathnameOf(input) === "/opencodex-session") return MINTED(); - // The install-time cookie arm probe is not a data request; keep it out of seenSignals. + // The session endpoint is not a data request; keep it out of seenSignals. if (pathnameOf(input) === "/api/auth/session") return new Response("unauthorized", { status: 401 }); seenSignals.push(init?.signal ?? (input instanceof Request ? input.signal : undefined)); const key = new Headers(init?.headers ?? (input instanceof Request ? input.headers : undefined)).get("X-OpenCodex-API-Key"); diff --git a/gui/tests/api-auth-memory.test.ts b/gui/tests/api-auth-memory.test.ts index d2ef5bbdb5d..a951aa8d3c2 100644 --- a/gui/tests/api-auth-memory.test.ts +++ b/gui/tests/api-auth-memory.test.ts @@ -155,7 +155,7 @@ test("concurrent 401s share one token prompt and all retry with the stored token const release401: Array<() => void> = []; const mockFetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { const headers = new Headers(init?.headers); - // Session probes (meta re-bootstrap, cookie arm, cookie mint): this fixture never mints + // Session bootstrap/mint requests: this fixture never mints // sessions, so fail them fast instead of letting them join the release queue below. const path = new URL(_input instanceof Request ? _input.url : String(_input), "http://localhost/").pathname; if (path === "/opencodex-session" || path === "/api/auth/session") { @@ -328,7 +328,7 @@ test("data-plane requests never receive the management token or prompt", async ( let phase: "seed" | "cross" = "seed"; const seenHeaders: Array = []; const stateful = (async (_input: RequestInfo | URL, init?: RequestInit) => { - // The install-time cookie-session arm must not count as a data-plane probe here. + // Session endpoints must not count as data-plane probes here. const path = new URL(_input instanceof Request ? _input.url : String(_input), "http://localhost/").pathname; if (path === "/api/auth/session") return new Response("unauthorized", { status: 401 }); const headers = new Headers(init?.headers); diff --git a/gui/tests/api-auth-cookie-session.test.ts b/gui/tests/api-auth-persistent-session.test.ts similarity index 88% rename from gui/tests/api-auth-cookie-session.test.ts rename to gui/tests/api-auth-persistent-session.test.ts index 6683bd324ac..b6095d02efd 100644 --- a/gui/tests/api-auth-cookie-session.test.ts +++ b/gui/tests/api-auth-persistent-session.test.ts @@ -83,6 +83,26 @@ test("a persisted opaque session is origin-scoped and re-arms dashboard requests ]); }); +test("a stored session for a different origin is discarded without sending its token", async () => { + localStorage.setItem("opencodex-gui-session", JSON.stringify({ + token: "ocx_session_other_origin", + csrfToken: "csrf", + origin: "http://100.88.9.100:10102", + expiresAt: Date.now() + 60_000, + })); + const seenKeys: Array = []; + const mockFetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + if (pathnameOf(input) === "/opencodex-session") return new Response("missing", { status: 404 }); + seenKeys.push(headersOf(input, init).get("X-OpenCodex-API-Key")); + return new Response("unauthorized", { status: 401 }); + }) as typeof fetch; + await installMockAuthFetch(mockFetch); + + expect((await fetch("/api/config")).status).toBe(401); + expect(seenKeys).toEqual([null]); + expect(localStorage.getItem("opencodex-gui-session")).toBeNull(); +}); + test("admin-token sign-in persists only the opaque session returned by the remote listener", async () => { resetApiAuthFetchForTests(async () => { promptCalls += 1; diff --git a/src/server/management-auth.ts b/src/server/management-auth.ts index df3470569c6..9dca880748e 100644 --- a/src/server/management-auth.ts +++ b/src/server/management-auth.ts @@ -286,7 +286,8 @@ export function issueGuiSession( /** The management credential a request presented in a header. */ function managementCredential(req: Request): string | null { return req.headers.get("x-opencodex-api-key")?.trim() - || req.headers.get("authorization")?.replace(/^Bearer\s+/i, "").trim(); + || req.headers.get("authorization")?.replace(/^Bearer\s+/i, "").trim() + || null; } /** The origin/CSRF admission a session credential must pass, shared by every gate that reads one. */ diff --git a/src/types/config.ts b/src/types/config.ts index 4683422503e..42b6b3e54ff 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -520,12 +520,13 @@ export interface OcxConfig { * version, uptime, pid, port, restart/provider-reload capability), never credentials. * * Management requests on this listener authenticate with the admin token once and then - * with the HttpOnly cookie session it mints (see /api/auth/session); loopback dashboard + * with its opaque origin-scoped browser session (see /api/auth/session); loopback dashboard * behavior on the main listener is unchanged. * * The port is required when enabled and must differ from the proxy port and from - * unauthenticatedLoopbackListener. The hostname is required so the operator names the - * exact interface (for example the machine's tailnet IP) instead of inheriting a wildcard. + * unauthenticatedLoopbackListener. The hostname must be a literal RFC1918, tailnet-CGNAT, + * or IPv6 ULA address, so the operator names an exact private interface instead of inheriting + * a wildcard, public address, or DNS name. */ dashboardListener?: | { enabled: false } diff --git a/tests/cli-headless-parity.test.ts b/tests/cli-headless-parity.test.ts index 32100adc8f7..2de02a83a1d 100644 --- a/tests/cli-headless-parity.test.ts +++ b/tests/cli-headless-parity.test.ts @@ -218,7 +218,7 @@ describe("headless GUI parity CLI", () => { } const coverage: Array<[string, string]> = [ // GUI-only affordance: the browser dashboard exchanges its once-entered admin - // token for an HttpOnly cookie session here. The CLI already authenticates with + // token for an opaque browser session here. The CLI already authenticates with // the admin token directly, so a mirror command would duplicate auth it has. ["/api/auth/session", "(none — GUI-only)"], ["/api/claude-code", "ocx claude config"], diff --git a/tests/dashboard-listener-admission.test.ts b/tests/dashboard-listener-admission.test.ts index fb72675e874..777be454d67 100644 --- a/tests/dashboard-listener-admission.test.ts +++ b/tests/dashboard-listener-admission.test.ts @@ -85,14 +85,27 @@ describe("dashboard listener configuration", () => { if (!result.ok) expect(result.error).toContain("dashboardListener.port"); }); - test("a distinct port with a named bind address is accepted and survives the parse", () => { - const result = validateConfigCandidate({ - ...base, - dashboardListener: { enabled: true, port: 10101, hostname: "100.88.9.100" }, - }); - expect(result.ok).toBe(true); - if (result.ok) { - expect(result.config.dashboardListener).toEqual({ enabled: true, port: 10101, hostname: "100.88.9.100" }); + test("accepts literal private and tailnet bind addresses", () => { + for (const hostname of ["10.1.2.3", "172.16.0.1", "192.168.1.1", "100.88.9.100", "fc00::1", "fdff::1"]) { + const result = validateConfigCandidate({ + ...base, + dashboardListener: { enabled: true, port: 10101, hostname }, + }); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.config.dashboardListener).toEqual({ enabled: true, port: 10101, hostname }); + } + } + }); + + test("rejects wildcard, loopback, public, and DNS bind addresses", () => { + for (const hostname of ["0.0.0.0", "::", "127.0.0.1", "::1", "8.8.8.8", "dashboard.example"]) { + const result = validateConfigCandidate({ + ...base, + dashboardListener: { enabled: true, port: 10101, hostname }, + }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toContain("literal private or tailnet IP address"); } }); }); From eb2b2094ec9c0cc4aa7cf0bb65ef36e852593fa2 Mon Sep 17 00:00:00 2001 From: x3M3x Date: Sat, 29 Aug 2026 21:50:51 +0400 Subject: [PATCH 5/5] fix(server): fail closed for dashboard listener binds --- src/config.ts | 119 ++++++++++--------- src/server/index.ts | 8 +- tests/dashboard-listener-integration.test.ts | 79 ++++++++---- 3 files changed, 122 insertions(+), 84 deletions(-) diff --git a/src/config.ts b/src/config.ts index 535da607e31..d75aecea52e 100644 --- a/src/config.ts +++ b/src/config.ts @@ -850,6 +850,27 @@ const agentTaskRecoverySchema = z.object({ cacheEntries: z.number().int().min(1).max(512).optional(), }).strict(); +/** Whether an address is safe for the authenticated dashboard listener to bind. */ +export function isDashboardListenerBindAddress(hostname: string): boolean { + const ipVersion = isIP(hostname); + if (ipVersion === 4) { + const [first, second] = hostname.split(".").map(Number); + return first === 10 + || (first === 172 && second >= 16 && second <= 31) + || (first === 192 && second === 168) + || (first === 100 && second >= 64 && second <= 127); + } + if (ipVersion === 6) { + const firstHextet = Number.parseInt(hostname.split(":")[0] || "0", 16); + return firstHextet >= 0xfc00 && firstHextet <= 0xfdff; + } + return false; +} + +const dashboardListenerHostnameSchema = z.string().trim().min(1).refine(isDashboardListenerBindAddress, { + message: "must be a literal private or tailnet IP address (RFC1918, 100.64.0.0/10, or IPv6 ULA)", +}); + const configSchema = z.object({ port: z.number().int().min(0).max(65535).default(10100), managementUsageMaxReadBytes: z.number().int().positive().default(64 * 1024 * 1024), @@ -881,13 +902,14 @@ const configSchema = z.object({ ]).optional().catch(undefined), // Same discriminated shape and degradation policy as unauthenticatedLoopbackListener: an // opt-in surface whose hand-edit typos must never reset providers/apiKeys through the - // backup-and-defaults repair path. Write-time rejection lives in dashboardListenerError(). + // backup-and-defaults repair path. The address predicate is enforced here too: malformed or + // unsafe entries degrade to disabled before startup can bind them. dashboardListener: z.union([ z.object({ enabled: z.literal(false) }), z.object({ enabled: z.literal(true), port: z.number().int().min(1).max(65535), - hostname: z.string().trim().min(1), + hostname: dashboardListenerHostnameSchema, }), ]).optional().catch(undefined), providers: z.record(z.string(), providerConfigSchema), @@ -2200,64 +2222,43 @@ function loopbackListenerPortError(value: unknown): string | null { return null; } -export function validateConfigCandidate(value: unknown): { ok: true; config: OcxConfig } | { ok: false; error: string } { - /** - * Write-time relationship checks for the dashboard listener, mirroring - * loopbackListenerPortError: the schema validates each field alone, while port collisions - * are between fields. A live caller is told; a hand-edited config on the read path - * degrades to undefined via the schema catch rather than resetting the file. - */ - function dashboardListenerError(value: unknown): string | null { - if (!value || typeof value !== "object" || Array.isArray(value)) return null; - const listener = (value as Record).dashboardListener; - if (listener === undefined) return null; - if (!listener || typeof listener !== "object" || Array.isArray(listener)) { - return "schema_invalid: dashboardListener: must be an object or omitted"; - } - const entry = listener as Record; - // Real boolean required, for the same reason as the loopback listener: a "true" string - // would otherwise be silently deleted by the schema catch while the operator believes - // the listener is on. - if (typeof entry.enabled !== "boolean") { - return "schema_invalid: dashboardListener.enabled: must be a boolean"; - } - if (entry.enabled !== true) return null; - const listenerPort = entry.port; - if (typeof listenerPort !== "number" || !Number.isInteger(listenerPort) || listenerPort < 1 || listenerPort > 65535) { - return "schema_invalid: dashboardListener.port: must be an integer port when enabled"; - } - const listenerHostname = entry.hostname; - if (typeof listenerHostname !== "string" || !listenerHostname.trim()) { - return "schema_invalid: dashboardListener.hostname: must be a non-blank bind address when enabled"; - } - const bindAddress = listenerHostname.trim(); - const ipVersion = isIP(bindAddress); - const privateDashboardAddress = ipVersion === 4 - ? (() => { - const [first, second] = bindAddress.split(".").map(Number); - return first === 10 - || (first === 172 && second >= 16 && second <= 31) - || (first === 192 && second === 168) - || (first === 100 && second >= 64 && second <= 127); - })() - : ipVersion === 6 - && (() => { - const firstHextet = Number.parseInt(bindAddress.split(":")[0] || "0", 16); - return firstHextet >= 0xfc00 && firstHextet <= 0xfdff; - })(); - if (!privateDashboardAddress) { - return "schema_invalid: dashboardListener.hostname: must be a literal private or tailnet IP address (RFC1918, 100.64.0.0/10, or IPv6 ULA); DNS names, wildcard, loopback, and public addresses are not allowed"; - } - const proxyPort = (value as Record).port; - if (typeof proxyPort === "number" && proxyPort === listenerPort) { - return "schema_invalid: dashboardListener.port: must differ from the proxy port"; - } - const loopback = (value as Record).unauthenticatedLoopbackListener as Record | undefined; - if (loopback && loopback.enabled === true && loopback.port === listenerPort) { - return "schema_invalid: dashboardListener.port: must differ from unauthenticatedLoopbackListener.port"; - } - return null; +function dashboardListenerError(value: unknown): string | null { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + const listener = (value as Record).dashboardListener; + if (listener === undefined) return null; + if (!listener || typeof listener !== "object" || Array.isArray(listener)) { + return "schema_invalid: dashboardListener: must be an object or omitted"; + } + const entry = listener as Record; + // A string such as `"true"` would otherwise be silently deleted by the schema catch + // while the operator believes the listener is on. + if (typeof entry.enabled !== "boolean") { + return "schema_invalid: dashboardListener.enabled: must be a boolean"; } + if (entry.enabled !== true) return null; + const listenerPort = entry.port; + if (typeof listenerPort !== "number" || !Number.isInteger(listenerPort) || listenerPort < 1 || listenerPort > 65535) { + return "schema_invalid: dashboardListener.port: must be an integer port when enabled"; + } + const listenerHostname = entry.hostname; + if (typeof listenerHostname !== "string" || !listenerHostname.trim()) { + return "schema_invalid: dashboardListener.hostname: must be a non-blank bind address when enabled"; + } + if (!isDashboardListenerBindAddress(listenerHostname.trim())) { + return "schema_invalid: dashboardListener.hostname: must be a literal private or tailnet IP address (RFC1918, 100.64.0.0/10, or IPv6 ULA); DNS names, wildcard, loopback, and public addresses are not allowed"; + } + const proxyPort = (value as Record).port; + if (typeof proxyPort === "number" && proxyPort === listenerPort) { + return "schema_invalid: dashboardListener.port: must differ from the proxy port"; + } + const loopback = (value as Record).unauthenticatedLoopbackListener as Record | undefined; + if (loopback && loopback.enabled === true && loopback.port === listenerPort) { + return "schema_invalid: dashboardListener.port: must differ from unauthenticatedLoopbackListener.port"; + } + return null; +} + +export function validateConfigCandidate(value: unknown): { ok: true; config: OcxConfig } | { ok: false; error: string } { const boundaryError = blankHostnameError(value) ?? claudeSubagentEffortError(value) ?? appOwnedMemoryBudgetError(value) diff --git a/src/server/index.ts b/src/server/index.ts index e33faadbbaa..9ce09038b92 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -15,6 +15,7 @@ import { DEFAULT_SUBAGENT_MODELS, applyProxyEnv, armClaudeCodeBaseline, + isDashboardListenerBindAddress, loadConfig, saveConfig, getConfigDir, @@ -697,7 +698,12 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server requestPolicyView(config, dashboardBindHostname ?? "127.0.0.1"); diff --git a/tests/dashboard-listener-integration.test.ts b/tests/dashboard-listener-integration.test.ts index ef05a4aa013..78984aa993d 100644 --- a/tests/dashboard-listener-integration.test.ts +++ b/tests/dashboard-listener-integration.test.ts @@ -8,10 +8,10 @@ * must behave identically with and without the dashboard listener configured. */ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { existsSync, mkdtempSync, rmSync } from "node:fs"; +import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { networkInterfaces, tmpdir } from "node:os"; import { join } from "node:path"; -import { saveConfig } from "../src/config"; +import { isDashboardListenerBindAddress, loadConfig, saveConfig } from "../src/config"; import { startServer } from "../src/server"; import { findAvailablePort } from "../src/server/ports"; import type { OcxConfig } from "../src/types"; @@ -22,7 +22,7 @@ const previousDataToken = process.env.OPENCODEX_API_AUTH_TOKEN; const previousAdminToken = process.env.OPENCODEX_ADMIN_AUTH_TOKEN; let testHome = ""; -function baseConfig(dashboardPort: number | null, hostname = "127.0.0.1"): OcxConfig { +function baseConfig(dashboardPort: number | null, hostname = dashboardHost ?? "127.0.0.1"): OcxConfig { return { port: 0, hostname: "127.0.0.1", @@ -43,12 +43,12 @@ function baseConfig(dashboardPort: number | null, hostname = "127.0.0.1"): OcxCo } /** A free port the way production would choose one, so tests cannot collide on a fixed number. */ -async function freePort(hostname = "127.0.0.1"): Promise { +async function freePort(hostname = dashboardHost ?? "127.0.0.1"): Promise { return await findAvailablePort(0, hostname); } function dashboardUrl(port: number, path: string): string { - return `http://127.0.0.1:${port}${path}`; + return `http://${dashboardHost ?? "127.0.0.1"}:${port}${path}`; } /** @@ -57,15 +57,20 @@ function dashboardUrl(port: number, path: string): string { * use the host's real interface address the way tests/loopback-listener-integration.test.ts * does — the production shape for this listener is a tailnet/LAN bind anyway. */ -function firstNonLoopbackIPv4(): string | null { +function firstDashboardIPv4(): string | null { for (const entries of Object.values(networkInterfaces())) { for (const entry of entries ?? []) { - if (entry.family === "IPv4" && !entry.internal) return entry.address; + if (entry.family === "IPv4" && !entry.internal && isDashboardListenerBindAddress(entry.address)) { + return entry.address; + } } } return null; } +const dashboardHost = firstDashboardIPv4(); +const dashboardTest = test.skipIf(!dashboardHost); + beforeEach(() => { testHome = mkdtempSync(join(tmpdir(), "ocx-dashboard-listener-")); process.env.OPENCODEX_HOME = testHome; @@ -85,7 +90,40 @@ afterEach(() => { }); describe("dashboard listener surface", () => { - test("serves the SPA root and the management API, and demands the admin token", async () => { + test("drops unsafe raw listener bind addresses before startup", async () => { + const dashboardPort = await freePort("127.0.0.1"); + for (const hostname of ["0.0.0.0", "127.0.0.1", "8.8.8.8", "dashboard.example"]) { + writeFileSync( + join(testHome, "config.json"), + JSON.stringify({ + ...baseConfig(null), + dashboardListener: { enabled: true, port: dashboardPort, hostname }, + }), + ); + expect(loadConfig().dashboardListener).toBeUndefined(); + } + + // A wildcard used to survive loading and bind this socket. Loading it must leave the + // reserved port untouched even though the rest of the server starts normally. + writeFileSync( + join(testHome, "config.json"), + JSON.stringify({ + ...baseConfig(null), + dashboardListener: { enabled: true, port: dashboardPort, hostname: "0.0.0.0" }, + }), + ); + const server = startServer(0); + try { + const dashboardBound = await fetch(`http://127.0.0.1:${dashboardPort}/healthz`) + .then(() => true) + .catch(() => false); + expect(dashboardBound).toBe(false); + } finally { + await server.stop(true); + } + }); + + dashboardTest("serves the SPA root and the management API, and demands the admin token", async () => { const dashboardPort = await freePort(); saveConfig(baseConfig(dashboardPort)); const server = startServer(0); @@ -105,7 +143,7 @@ describe("dashboard listener surface", () => { } }); - test("refuses the /v1 data plane and /readyz even with the admin token, serves /healthz", async () => { + dashboardTest("refuses the /v1 data plane and /readyz even with the admin token, serves /healthz", async () => { const dashboardPort = await freePort(); saveConfig(baseConfig(dashboardPort)); const server = startServer(0); @@ -134,7 +172,7 @@ describe("dashboard listener surface", () => { } }); - test("minting an opaque session keeps the dashboard usable across page refreshes", async () => { + dashboardTest("minting an opaque session keeps the dashboard usable across page refreshes", async () => { const dashboardPort = await freePort(); saveConfig(baseConfig(dashboardPort)); const server = startServer(0); @@ -188,7 +226,7 @@ describe("dashboard listener surface", () => { } }); - test("rejects a session mint with a wrong admin token", async () => { + dashboardTest("rejects a session mint with a wrong admin token", async () => { const dashboardPort = await freePort(); saveConfig(baseConfig(dashboardPort)); const server = startServer(0); @@ -203,7 +241,7 @@ describe("dashboard listener surface", () => { } }); - test("leaves the public listener unchanged: loopback data plane stays token-free", async () => { + dashboardTest("leaves the public listener unchanged: loopback data plane stays token-free", async () => { const dashboardPort = await freePort(); saveConfig(baseConfig(dashboardPort)); const server = startServer(0); @@ -226,7 +264,7 @@ describe("dashboard listener surface", () => { } }); - test("stop closes the dashboard socket too", async () => { + dashboardTest("stop closes the dashboard socket too", async () => { const dashboardPort = await freePort(); saveConfig(baseConfig(dashboardPort)); const server = startServer(0); @@ -241,20 +279,13 @@ describe("dashboard listener surface", () => { expect(stillServing).toBe(false); }); - test("management origin gate follows the listener policy when the proxy stays loopback", async () => { + dashboardTest("management origin gate follows the listener policy when the proxy stays loopback", async () => { // The regression this pins: handleManagementAPI re-derived the request origin // from the shared loopback config, so a non-loopback dashboard Host was rejected // as cross-origin (403) even after the listener policy had admitted it. The - // listener binds the host's real interface address: non-loopback for the origin - // check and bindable on Linux, Windows, and macOS alike (127.0.0.2 is not bound - // on macOS, which is exactly how this test originally failed there). - const dashboardHost = firstNonLoopbackIPv4(); - if (!dashboardHost) { - // A host with no external IPv4 cannot prove this. Say so rather than pass silently. - console.warn("[dashboard-listener] no non-loopback IPv4 interface; origin-gate check not run"); - return; - } - const dashboardPort = await freePort(dashboardHost); + // listener binds a real private/tailnet interface: non-loopback for the origin + // check and admissible under the public bind contract. + const dashboardPort = await freePort(dashboardHost!); saveConfig(baseConfig(dashboardPort, dashboardHost)); const server = startServer(0); try {