From 7ceba0bc601beb7e19d8ec86f4dd6ec70c38b654 Mon Sep 17 00:00:00 2001 From: x3M3x Date: Sat, 22 Aug 2026 23:16:09 +0400 Subject: [PATCH] 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 6c2ca81e05..fa0c2acc53 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 fe12dc58a3..b06fdf6e31 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`. | | `emptyCompletionRetry?` | `boolean` | `false` | Opt in to one identical Responses retry when a completion has no text or tool call. The retry may be billable. `OCX_EMPTY_COMPLETION_RETRY=0` disables it without changing config; combo and routed-compaction turns remain excluded. | | `stallTimeoutSec?` | `number` | `300` | Seconds without upstream data before `response.incomplete`. Minimum 1. | @@ -112,6 +113,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 658beac1ff..f915851d92 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 0000000000..6a3383ba32 --- /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 de4520ad88..94b89926b0 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 ca70303e26..d2ef5bbdb5 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 57d5c85c53..63d9bb65a2 100755 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -145,6 +145,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 @@ -154,6 +160,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. @@ -181,7 +192,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 10032fcbcf..ce0ae0bbc2 100644 --- a/src/config.ts +++ b/src/config.ts @@ -862,6 +862,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"), // A retry can be billable, so absence and malformed hand edits both stay off. @@ -2080,6 +2091,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) @@ -2089,7 +2139,8 @@ export function validateConfigCandidate(value: unknown): { ok: true; config: Ocx ?? codexAccountPrioritiesError(value) ?? codexAccountPickerEnabledError(value) ?? emptyCompletionRetryError(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 2257f78923..dc69b493a1 100644 --- a/src/server/auth-cors.ts +++ b/src/server/auth-cors.ts @@ -31,6 +31,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"; } @@ -114,7 +116,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; @@ -128,7 +130,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"); @@ -195,7 +200,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)) { @@ -216,7 +221,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 f59b4d0ae3..58a265550e 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -151,6 +151,7 @@ import { resolveResponsesApiAuth, requestPolicyView, type RequestPolicyView, + type ManagementPolicyView, safeConfigDTO, setCorsOrigin, withCors, @@ -187,6 +188,7 @@ import { fetchAllModels, handleManagementAPI, VERSION, type ManagementApiDeps } import { initializeManagementAuthState, issueGuiSession, + handleGuiSessionEndpoint, managementPrincipal, requireManagementAuth, type ManagementAuthState, @@ -640,6 +642,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. * @@ -670,6 +682,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); @@ -827,12 +862,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(); @@ -1805,6 +1885,7 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server => { @@ -1816,6 +1897,9 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server loopbackListenerRef.stop(closeActiveConnections)] : []), + ...(dashboardListenerRef + ? [() => dashboardListenerRef.stop(closeActiveConnections)] + : []), async () => { userCostOverlayReconciler?.stop(); }, @@ -1850,6 +1934,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 83c59d8d06..5ea4cb810e 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 ae8fe16c1a..e8f9eb4b6f 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 3e1801f08d..fc6b54a355 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -464,6 +464,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 7029566d20..46b911a041 100644 --- a/tests/cli-headless-parity.test.ts +++ b/tests/cli-headless-parity.test.ts @@ -215,6 +215,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 0000000000..fb72675e87 --- /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 0000000000..baef5a5782 --- /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); + } + }); +});