Skip to content

Commit 503786a

Browse files
committed
fix(consent): key the star guard on the authenticating credential, not headers
The agent-consent refusal on POST /api/github/star asked whether the request carried an Origin plus the GUI-origin and CSRF headers, on the stated premise that requireManagementAuth had already matched them against a minted session. It had not. The gate accepts a raw admin token and returns BEFORE it consults the session table, so those headers were never validated for a token-authorized call, and the admin token is readable by anything running as the user, which is precisely the caller this guard exists to refuse. Three headers with arbitrary values were enough to star the repository with the user's identity. managementPrincipal() now resolves which credential passed the gate, from the same session table and the same CSRF comparison the gate uses, and the server passes it into the management dispatcher. The route asks for a gui-session principal: a session this process minted for a browser, which is only accepted for a mutation after origin and per-session CSRF both match. An unresolved principal (direct dispatch in tests, any future internal caller) is untrusted. Behavior for real users is unchanged: dashboard clicks still star, hand-typed runs still star, and the non-loopback operator dashboard on a raw admin token keeps the documented fail-closed edge. Both regressions were driven red against the old header check.
1 parent 3ffb7e6 commit 503786a

7 files changed

Lines changed: 127 additions & 22 deletions

File tree

src/server/index.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -157,7 +157,7 @@ import { handleImages } from "./images";
157157
import { handleLive, logLiveSidebandFrame, parseLiveSidebandTarget, resolveLiveSidebandUpgrade } from "./live";
158158
import { handleSearch } from "./search";
159159
import { fetchAllModels, handleManagementAPI, VERSION } from "./management-api";
160-
import { initializeManagementAuthState, issueGuiSession, requireManagementAuth } from "./management-auth";
160+
import { initializeManagementAuthState, issueGuiSession, managementPrincipal, requireManagementAuth } from "./management-auth";
161161

162162
const MAX_WS_FRAME_BYTES = 50 * 1024 * 1024;
163163
const WEBSOCKET_IDLE_TIMEOUT_SECONDS = 0;
@@ -448,7 +448,11 @@ export function startServer(port?: number) {
448448
if (url.pathname.startsWith("/api/")) {
449449
const apiAuthError = requireManagementAuth(req, managementAuth, config);
450450
if (apiAuthError) return withManagementCors(apiAuthError, req, config);
451-
const mgmtResponse = await handleManagementAPI(req, url, config);
451+
// Which credential passed the gate, resolved from the same session table the
452+
// gate used. Consent-bearing routes need this: request headers are forgeable
453+
// by anything holding the admin token, the credential is not.
454+
const principal = managementPrincipal(req, managementAuth, config) ?? undefined;
455+
const mgmtResponse = await handleManagementAPI(req, url, config, {}, principal);
452456
if (mgmtResponse) return withManagementCors(mgmtResponse, req, config);
453457
return withManagementCors(formatErrorResponse(404, "not_found", `Unknown endpoint: ${req.method} ${url.pathname}`), req, config);
454458
}

src/server/management-api.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ import { handleSystemRoutes } from "./management/system-routes";
6868
import { handleSidebarRoutes } from "./management/sidebar-routes";
6969
import { handleIntegrationRoutes } from "./management/integration-routes";
7070
import type { ManagementContext } from "./management/context";
71+
import type { ManagementPrincipal } from "./management-auth";
7172
export type { ManagementApiDeps } from "./management/context";
7273
import { fetchAllModels } from "./management/shared";
7374
import { CatalogGatherBusyError } from "../codex/catalog/provider-fetch";
@@ -82,7 +83,13 @@ export const VERSION = (() => {
8283
}
8384
})();
8485

85-
export async function handleManagementAPI(req: Request, url: URL, config: OcxConfig, deps: ManagementApiDeps = {}): Promise<Response | null> {
86+
export async function handleManagementAPI(
87+
req: Request,
88+
url: URL,
89+
config: OcxConfig,
90+
deps: ManagementApiDeps = {},
91+
principal?: ManagementPrincipal,
92+
): Promise<Response | null> {
8693
if (!isAllowedManagementOrigin(req, config)) {
8794
return jsonResponse({ error: "cross-origin request blocked" }, 403, req, config);
8895
}
@@ -125,7 +132,7 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon
125132
}
126133
} catch { /* best-effort */ }
127134
}
128-
const ctx: ManagementContext = { req, url, config, deps, refreshCodexCatalogBestEffort, syncClaudeAgentDefsBestEffort };
135+
const ctx: ManagementContext = { req, url, config, deps, principal, refreshCodexCatalogBestEffort, syncClaudeAgentDefsBestEffort };
129136
let routed: Response | null;
130137
try {
131138
routed = (await handleConfigRoutes(ctx))

src/server/management-auth.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,38 @@ export function issueGuiSession(
237237
return { token, ...session };
238238
}
239239

240+
/**
241+
* Which credential actually authorized a management request.
242+
*
243+
* `admin-token` is the raw token from disk/env: anything running as the user can
244+
* read it, including a coding agent. `gui-session` is a session token this process
245+
* minted for a browser, and it only authorizes a mutation after the origin and the
246+
* per-session CSRF token match. Consent-bearing routes must key off this value
247+
* rather than off request headers, which the token holder can forge freely.
248+
*/
249+
export type ManagementPrincipal = "admin-token" | "gui-session";
250+
251+
/**
252+
* The principal for a request that already passed `requireManagementAuth`. Kept as a
253+
* separate resolution (rather than a changed return type) so every existing caller
254+
* keeps its `Response | null` contract; the value is derived from the same session
255+
* table and the same CSRF comparison the gate uses, so the two cannot disagree.
256+
*/
257+
export function managementPrincipal(
258+
req: Request,
259+
state: ManagementAuthState,
260+
config?: OcxConfig,
261+
): ManagementPrincipal | null {
262+
if (!state.available) return null;
263+
const actual = req.headers.get("x-opencodex-api-key")?.trim()
264+
|| req.headers.get("authorization")?.replace(/^Bearer\s+/i, "").trim();
265+
if (!actual) return null;
266+
if (equalSecret(actual, state.token)) return "admin-token";
267+
if (!config) return null;
268+
removeExpiredSessions(state);
269+
return state.sessions.has(actual) ? "gui-session" : null;
270+
}
271+
240272
export function requireManagementAuth(
241273
req: Request,
242274
state: ManagementAuthState,

src/server/management/context.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { OcxConfig } from "../../types";
22
import type { StartupInstallAction } from "../startup-action-control";
3+
import type { ManagementPrincipal } from "../management-auth";
34

45
export interface ManagementApiDeps {
56
toggleCodexMultiAgentV2?: (enabled: boolean) => void;
@@ -26,6 +27,15 @@ export interface ManagementContext {
2627
url: URL;
2728
config: OcxConfig;
2829
deps: ManagementApiDeps;
30+
/**
31+
* Which credential authorized this request, resolved by the auth gate before
32+
* dispatch. Routes that spend the USER's identity (not just the proxy's) must
33+
* branch on this instead of on request headers: the admin token is readable by
34+
* anything running as the user, so a token holder can forge any header a route
35+
* might otherwise treat as browser evidence. Undefined only in direct-dispatch
36+
* tests, which are treated as the untrusted `admin-token` case.
37+
*/
38+
principal?: ManagementPrincipal;
2939
refreshCodexCatalogBestEffort: () => Promise<void>;
3040
syncClaudeAgentDefsBestEffort: () => Promise<void>;
3141
}

src/server/management/sidebar-routes.ts

Lines changed: 16 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -16,25 +16,28 @@
1616
*
1717
* The dashboard button must keep working even when the proxy itself was started by
1818
* an agent, which is the common case — the person is at the browser, not at the
19-
* spawning shell. A GUI click is therefore distinguished by its browser session
20-
* evidence (a same-origin `Origin` plus the minted CSRF header, both already
21-
* verified by the management auth gate) rather than by the proxy's own env.
19+
* spawning shell. A GUI click is therefore distinguished by the CREDENTIAL that
20+
* authorized the request — a GUI session this process minted for a browser, which
21+
* the auth gate only accepts after matching origin and the per-session CSRF token —
22+
* rather than by the proxy's own env or by request headers.
2223
*/
2324
import { jsonResponse } from "../auth-cors";
2425
import { agentDrivenMarkers, isAgentDriven } from "../../cli/agent-driven";
2526
import type { ManagementContext } from "./context";
2627

2728
/**
28-
* True when this request carries the browser-session evidence a dashboard click
29-
* always has: an `Origin` (only a browser sends one) plus the per-session CSRF
30-
* token, which `requireManagementAuth` has already matched against the minted
31-
* session before dispatch. A shell/HTTP caller holding only the admin token has
32-
* neither, which is exactly the case the agent guard is aimed at.
29+
* True only when a minted GUI session authorized this request.
30+
*
31+
* The previous version of this check looked for an `Origin` plus the CSRF headers
32+
* and reasoned that the auth gate had already validated them. It had not: the gate
33+
* accepts a raw admin token BEFORE it ever consults the session table, so a caller
34+
* holding that token (any process running as the user, a coding agent included)
35+
* could add three nonempty headers of its choosing and satisfy this check without
36+
* a browser ever being involved. The credential itself is the only part of the
37+
* request an agent cannot fabricate, so that is what this now reads.
3338
*/
34-
function hasBrowserSessionEvidence(req: Request): boolean {
35-
return !!req.headers.get("Origin")?.trim()
36-
&& !!req.headers.get("x-opencodex-csrf-token")?.trim()
37-
&& !!req.headers.get("x-opencodex-gui-origin")?.trim();
39+
function hasBrowserSessionEvidence(ctx: ManagementContext): boolean {
40+
return ctx.principal === "gui-session";
3841
}
3942

4043
// Known edge, deliberately fail-closed: a non-loopback operator dashboard signs in
@@ -58,7 +61,7 @@ export async function handleSidebarRoutes(ctx: ManagementContext): Promise<Respo
5861
// account owner. An agent-driven caller without browser-session evidence
5962
// cannot have obtained it, and must relay the question instead of answering
6063
// it with an HTTP call.
61-
if (isAgentDriven() && !hasBrowserSessionEvidence(req)) {
64+
if (isAgentDriven() && !hasBrowserSessionEvidence(ctx)) {
6265
return jsonResponse({
6366
ok: false,
6467
state: "not-starred",

tests/sidebar-routes.test.ts

Lines changed: 48 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,14 @@ async function call(
1919
method: string,
2020
pathname: string,
2121
headers: Record<string, string> = {},
22+
principal?: "admin-token" | "gui-session",
2223
): Promise<{ status: number; body: unknown; raw: string; routed: boolean }> {
2324
// `isAllowedManagementOrigin` derives the expected origin from the Host header and
2425
// rejects the request outright when it is missing, so Host is required here. Omitting
2526
// Origin models the GUI's own same-origin fetch.
2627
const url = new URL(`http://127.0.0.1:10100${pathname}`);
2728
const req = new Request(url, { method, headers: { host: "127.0.0.1:10100", ...headers } });
28-
const res = await handleManagementAPI(req, url, config);
29+
const res = await handleManagementAPI(req, url, config, {}, principal);
2930
if (!res) return { status: 404, body: null, raw: "", routed: false };
3031
const raw = await res.text();
3132
return { status: res.status, body: raw ? JSON.parse(raw) : null, raw, routed: true };
@@ -183,19 +184,62 @@ describe("route surface", () => {
183184
async runGh(args) { calls.push(args); return { status: 0 }; },
184185
}, async () => {
185186
invalidateStarStatusCache();
186-
// Browser-session evidence: same-origin Origin plus the minted CSRF/GUI-origin
187-
// headers the management auth gate has already verified before dispatch.
187+
// Browser-session evidence is the CREDENTIAL, not the headers: the auth gate
188+
// resolved a minted GUI session, which it only issues to a browser and only
189+
// accepts for a mutation after matching origin and the per-session CSRF token.
188190
const { status, body } = await call("POST", "/api/github/star", {
189191
origin: "http://127.0.0.1:10100",
190192
"x-opencodex-gui-origin": "http://127.0.0.1:10100",
191193
"x-opencodex-csrf-token": "csrf-token",
192-
});
194+
}, "gui-session");
193195
expect(status).toBe(200);
194196
expect((body as Record<string, unknown>).ok).toBe(true);
195197
}));
196198
expect(calls.some(args => args.includes("PUT"))).toBe(true);
197199
});
198200

201+
test("forged dashboard headers on an admin-token call cannot star", async () => {
202+
// The consent guard used to read the request's Origin/CSRF/GUI-origin headers and
203+
// trust them as proof of a browser click. The auth gate accepts a raw admin token
204+
// BEFORE it consults the session table, so an agent that can read that token — any
205+
// process running as the user — could send exactly these headers with arbitrary
206+
// values and star the repository with the user's identity.
207+
const calls: string[][] = [];
208+
await withEnv({ ...NO_AGENT_ENV, CODEX_THREAD_ID: "019fbc94" }, () => withStarDeps({
209+
nowMs: () => 0,
210+
async runGh(args) { calls.push(args); return { status: 0 }; },
211+
}, async () => {
212+
invalidateStarStatusCache();
213+
const { status, body } = await call("POST", "/api/github/star", {
214+
origin: "http://127.0.0.1:10100",
215+
"x-opencodex-gui-origin": "http://127.0.0.1:10100",
216+
"x-opencodex-csrf-token": "forged-by-the-token-holder",
217+
}, "admin-token");
218+
expect(status).toBe(403);
219+
expect((body as Record<string, unknown>).code).toBe("agent_consent_required");
220+
}));
221+
expect(calls).toEqual([]);
222+
});
223+
224+
test("a direct dispatch with no resolved principal is treated as untrusted", async () => {
225+
// Defense in depth for callers that bypass the HTTP gate (route-level tests, future
226+
// internal dispatchers): an unknown principal must never satisfy the consent check.
227+
const calls: string[][] = [];
228+
await withEnv({ ...NO_AGENT_ENV, CODEX_THREAD_ID: "019fbc94" }, () => withStarDeps({
229+
nowMs: () => 0,
230+
async runGh(args) { calls.push(args); return { status: 0 }; },
231+
}, async () => {
232+
invalidateStarStatusCache();
233+
const { status } = await call("POST", "/api/github/star", {
234+
origin: "http://127.0.0.1:10100",
235+
"x-opencodex-gui-origin": "http://127.0.0.1:10100",
236+
"x-opencodex-csrf-token": "csrf-token",
237+
});
238+
expect(status).toBe(403);
239+
}));
240+
expect(calls).toEqual([]);
241+
});
242+
199243
test("a hand-typed run is not blocked by the agent guard", async () => {
200244
const calls: string[][] = [];
201245
await withEnv(NO_AGENT_ENV, () => withStarDeps({

tests/startup-prompt.test.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,12 @@ describe("startup star prompt", () => {
120120
// A dashboard click must still work when an agent started the proxy, so the
121121
// refusal is conditioned on the absence of browser-session evidence.
122122
expect(routes).toContain("hasBrowserSessionEvidence");
123-
expect(routes).toMatch(/isAgentDriven\(\)\s*&&\s*!hasBrowserSessionEvidence\(req\)/);
123+
expect(routes).toMatch(/isAgentDriven\(\)\s*&&\s*!hasBrowserSessionEvidence\(ctx\)/);
124+
// And that evidence must be the authenticating CREDENTIAL, never a request
125+
// header: the admin token is readable by anything running as the user, so a
126+
// header-shaped check is forgeable by the exact caller this guard refuses.
127+
expect(routes).toMatch(/principal === "gui-session"/);
128+
expect(routes).not.toMatch(/hasBrowserSessionEvidence[\s\S]*?headers\.get\("x-opencodex-csrf-token"\)/);
124129
});
125130

126131
test("the consent rule is written down where agents and users read it", async () => {

0 commit comments

Comments
 (0)