Skip to content

Commit 43f4429

Browse files
committed
merge: security hardening batch 1 (#697)
Closes several items from the private 2026-07-28 security review and partially addresses two more. Finding-level detail stays in the private report. Reviewed for release: no changes to .github/workflows or scripts/release.ts, no new credential logging, constant-time secret comparison, 0600/0700 secret files with symlink rejection and ACL hardening, and proxy admission secrets are blocked from reaching upstreams. BREAKING CHANGES: - A blank "hostname" is rejected on write and degraded to 127.0.0.1 on read with a warning. - Data-plane credentials (OPENCODEX_API_AUTH_TOKEN, service-api-token, config.apiKeys) no longer reach /api/*. Operational scripts must move to OPENCODEX_ADMIN_AUTH_TOKEN. Model clients are unaffected. - A non-loopback dashboard no longer mints session tokens.
2 parents 1c36e7a + 7d995ef commit 43f4429

117 files changed

Lines changed: 3522 additions & 367 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

bin/ocx.mjs

Lines changed: 13 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
1414
import { homedir } from "node:os";
1515
import { dirname, join, resolve } from "node:path";
1616
import { fileURLToPath } from "node:url";
17+
import { npmInvocation } from "../src/update/npm-invocation.mjs";
1718
import { handoffWindowsTrayForUpdate, planWindowsTrayUpdate } from "../src/update/tray-update-plan.mjs";
1819

1920
const PKG = "@bitkyc08/opencodex";
@@ -29,10 +30,6 @@ function isBunGlobalInstall() {
2930
return /[\\/]\.bun[\\/]/.test(here);
3031
}
3132

32-
function npmBin() {
33-
return process.platform === "win32" ? "npm.cmd" : "npm";
34-
}
35-
3633
function currentPackageVersion() {
3734
try {
3835
return JSON.parse(readFileSync(join(here, "..", "package.json"), "utf8")).version ?? "?";
@@ -115,15 +112,17 @@ function runTrayLifecycle(launcher, action) {
115112
function runNpmSelfUpdate() {
116113
const current = currentPackageVersion();
117114
const tag = updateTag(current);
118-
const npm = npmBin();
119-
// Node ≥18.20/20.12 refuses to spawn .cmd/.bat without a shell (CVE-2024-27980
120-
// hardening) — spawning "npm.cmd" shell-less throws EINVAL on Windows.
121-
const winShell = process.platform === "win32";
122-
const latestResult = spawnSync(npm, ["view", `${PKG}@${tag}`, "version"], {
115+
const latestInvocation = npmInvocation(["view", `${PKG}@${tag}`, "version"]);
116+
const installInvocation = npmInvocation(["install", "-g", `${PKG}@${tag}`]);
117+
if (!latestInvocation || !installInvocation) {
118+
console.error("opencodex: could not resolve npm from a trusted absolute PATH entry; aborting before stopping the proxy.");
119+
process.exit(1);
120+
}
121+
const latestResult = spawnSync(latestInvocation.file, latestInvocation.args, {
123122
encoding: "utf8",
124123
timeout: 12000,
125124
windowsHide: true,
126-
shell: winShell,
125+
...latestInvocation.options,
127126
});
128127
const latest = latestResult.status === 0 ? latestResult.stdout.trim() : "";
129128

@@ -221,12 +220,12 @@ function runNpmSelfUpdate() {
221220
}
222221
}
223222

224-
console.log(`Updating${latest ? ` to v${latest}` : ""}...\n$ ${npm} install -g ${PKG}@${tag}`);
225-
const res = spawnSync(npm, ["install", "-g", `${PKG}@${tag}`], {
223+
console.log(`Updating${latest ? ` to v${latest}` : ""}...\n$ npm install -g ${PKG}@${tag}`);
224+
const res = spawnSync(installInvocation.file, installInvocation.args, {
226225
stdio: "inherit",
227226
timeout: 180000,
228227
windowsHide: true,
229-
shell: winShell,
228+
...installInvocation.options,
230229
});
231230
if (res.status === 0) {
232231
console.log(`\nUpdated${latest ? ` to v${latest}` : ""}.`);
@@ -278,7 +277,7 @@ function runNpmSelfUpdate() {
278277
process.exit(0);
279278
}
280279
if (trayBeforeUpdate.restoreOnFailure) runTrayLifecycle(launcher, "start");
281-
console.error(`\nUpdate failed (${npm} exit ${res.status ?? "?"}). Try manually: ${npm} install -g ${PKG}@${tag}`);
280+
console.error(`\nUpdate failed (npm exit ${res.status ?? "?"}). Try manually: npm install -g ${PKG}@${tag}`);
282281
process.exit(1);
283282
}
284283

docs-site/src/content/docs/reference/configuration.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,33 @@ inert: it does not create model-picker entries, pin sessions, or alter Pool or D
8686
`max_concurrent_threads_per_session` value under `[features.multi_agent_v2]` in Codex's
8787
`$CODEX_HOME/config.toml`; enable v2 first so that table exists.
8888

89+
## Provider diagnostic outbound safety
90+
91+
The dashboard provider connection test and live model discovery use a bounded GET-only outbound
92+
transport. Without an outbound proxy, opencodex resolves the provider hostname once and connects
93+
only to that validated address. HTTPS keeps the original hostname for Host, SNI, and certificate
94+
verification; certificate verification cannot be disabled by provider config.
95+
96+
When `HTTP_PROXY`, `HTTPS_PROXY`, or `ALL_PROXY` applies, these two operations keep Bun's native fetch
97+
so existing proxy behavior is not silently bypassed. URL/literal checks still run. Successful local DNS answers
98+
are classified, but a local DNS failure is allowed through because proxy-only networks commonly
99+
delegate name resolution to the proxy. The proxy chooses the final route, DNS answer, and peer, so
100+
opencodex logs that this path cannot pin or verify the proxy-selected peer. This is an explicit
101+
security limitation, not equivalent protection against DNS rebinding.
102+
103+
Private/local provider destinations require both `allowPrivateNetwork: true` and a matching
104+
`NO_PROXY` entry whenever an outbound proxy is configured. Loopback entries are added to `NO_PROXY`
105+
automatically. A LAN provider such as `192.168.1.50` must be added explicitly; otherwise connection
106+
tests and model discovery reject it with an actionable message instead of sending it to the proxy.
107+
Metadata and link-local destinations remain blocked even when `allowPrivateNetwork` is enabled.
108+
The safety guard accepts exact hosts, domain suffixes, optional ports, bracketed IPv6, and `*` in
109+
`NO_PROXY`; it does not interpret CIDR entries, so list each private provider host or address explicitly.
110+
111+
Both direct and proxied diagnostic paths reject redirects and report a credential-stripped target;
112+
configure the final provider URL directly. Ordinary provider requests, streaming responses, and
113+
retry paths are not migrated in this phase. Their redirect handling and per-hop destination review
114+
remain deferred, so this phase does not close the main-request redirect finding.
115+
89116
## Combos (`config.combos`)
90117

91118
Failover / round-robin aliases live under `combos.<id>` with `targets` (provider + model), optional

gui/src/api.ts

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ function needsApiAuth(input: RequestInfo | URL): boolean {
1414
const url = new URL(raw, window.location.href);
1515
// Absolute cross-origin URLs must never get the local API token or 401 prompt.
1616
if (url.origin !== window.location.origin) return false;
17-
return url.pathname.startsWith("/api/") || url.pathname.startsWith("/v1/");
17+
return url.pathname.startsWith("/api/");
1818
} catch {
1919
return false;
2020
}
@@ -25,6 +25,8 @@ const LEGACY_TOKEN_KEY = "opencodex-api-token";
2525

2626
/** In-memory only — never write tokens to web storage (XSS can read sessionStorage/localStorage). */
2727
let memoryToken: string | null = null;
28+
let memoryCsrfToken: string | null = null;
29+
let memorySessionOrigin: string | null = null;
2830

2931
function readToken(): string | null {
3032
return memoryToken;
@@ -36,6 +38,25 @@ function storeToken(token: string): void {
3638

3739
function clearToken(): void {
3840
memoryToken = null;
41+
memoryCsrfToken = null;
42+
memorySessionOrigin = null;
43+
}
44+
45+
function takeMetaContent(name: string): string | null {
46+
const element = document.querySelector(`meta[name="${name}"]`) as HTMLMetaElement | null;
47+
const content = element?.content.trim() || null;
48+
element?.remove();
49+
return content;
50+
}
51+
52+
function loadInjectedSession(): void {
53+
const token = takeMetaContent("opencodex-session-token");
54+
const csrfToken = takeMetaContent("opencodex-session-csrf");
55+
const origin = takeMetaContent("opencodex-session-origin");
56+
if (!token?.startsWith("ocx_session_") || !csrfToken || origin !== window.location.origin) return;
57+
memoryToken = token;
58+
memoryCsrfToken = csrfToken;
59+
memorySessionOrigin = origin;
3960
}
4061

4162
/** Clear memory only when it still holds `expected` (avoid wiping a newer concurrent store). */
@@ -54,6 +75,13 @@ function clearLegacySessionToken(): void {
5475
function withToken(input: RequestInfo | URL, init: RequestInit | undefined, token: string): [RequestInfo | URL, RequestInit | undefined] {
5576
const headers = new Headers(init?.headers ?? (input instanceof Request ? input.headers : undefined));
5677
headers.set("X-OpenCodex-API-Key", token);
78+
if (memorySessionOrigin && memoryCsrfToken && token.startsWith("ocx_session_")) {
79+
headers.set("X-OpenCodex-GUI-Origin", memorySessionOrigin);
80+
const method = (init?.method ?? (input instanceof Request ? input.method : "GET")).toUpperCase();
81+
if (method !== "GET" && method !== "HEAD") {
82+
headers.set("X-OpenCodex-CSRF-Token", memoryCsrfToken);
83+
}
84+
}
5785
if (input instanceof Request) return [new Request(input, { headers }), init ? { ...init, headers } : undefined];
5886
return [input, { ...init, headers }];
5987
}
@@ -91,6 +119,7 @@ export function installApiAuthFetch(): void {
91119
installed = true;
92120
// Drop any leftover XSS-readable token; new tokens stay memory-only (no read/migrate).
93121
clearLegacySessionToken();
122+
loadInjectedSession();
94123
const originalFetch = window.fetch.bind(window);
95124
window.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
96125
if (!needsApiAuth(input)) return originalFetch(input, init);
@@ -125,6 +154,8 @@ export function installApiAuthFetch(): void {
125154
export function resetApiAuthFetchForTests(): void {
126155
installed = false;
127156
memoryToken = null;
157+
memoryCsrfToken = null;
158+
memorySessionOrigin = null;
128159
promptInFlight = null;
129160
promptCancelled = false;
130161
}

gui/tests/api-auth-memory.test.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -279,7 +279,7 @@ test("canceling the token prompt once does not reopen it for the rest of the 401
279279
expect([...new Set(statuses)]).toEqual([401]);
280280
});
281281

282-
test("cross-origin /v1/* requests do not receive the API key or token prompt", async () => {
282+
test("data-plane requests never receive the management token or prompt", async () => {
283283
let promptCalls = 0;
284284
let phase: "seed" | "cross" = "seed";
285285
const seenHeaders: Array<string | null> = [];
@@ -298,8 +298,9 @@ test("cross-origin /v1/* requests do not receive the API key or token prompt", a
298298
};
299299
await installMockAuthFetch(stateful);
300300

301-
expect((await fetch("/v1/models")).status).toBe(200);
302-
expect(promptCalls).toBe(1);
301+
expect((await fetch("/v1/models")).status).toBe(401);
302+
expect(seenHeaders).toEqual([null]);
303+
expect(promptCalls).toBe(0);
303304

304305
phase = "cross";
305306
const beforeCrossPrompts = promptCalls;

gui/tests/models-empty-provider.test.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ async function providerDto(
5252
): Promise<Record<string, unknown>> {
5353
const requestUrl = new URL("http://127.0.0.1/api/providers");
5454
const response = await handleManagementAPI(
55-
new Request(requestUrl),
55+
new Request(requestUrl, { headers: { Host: requestUrl.host } }),
5656
requestUrl,
5757
{
5858
providers: {

src/adapters/mimo-free.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto";
22
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
33
import { join } from "node:path";
44
import { getConfigDir } from "../config";
5+
import { recordOwnedConfigPath } from "../lib/config-ownership";
56
import type { OcxProviderConfig, OcxParsedRequest } from "../types";
67
import { createOpenAIChatAdapter } from "./openai-chat";
78
import type { ProviderAdapter, AdapterRequest } from "./base";
@@ -59,6 +60,7 @@ export function getMimoClientId(): string {
5960
} catch { /* fall through to regenerate */ }
6061
const fresh = randomUUID();
6162
try {
63+
recordOwnedConfigPath(dir, file);
6264
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
6365
writeFileSync(file, `${fresh}\n`, "utf8");
6466
} catch { /* persist best-effort; still usable for this process */ }

src/cli/claude.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { refreshGatewayModelCacheFromProxy } from "../claude/gateway-cache";
1414
import { commandInvocation } from "../lib/win-exec";
1515
import { findLiveProxy } from "../server/proxy-liveness";
1616
import type { OcxConfig } from "../types";
17+
import { configuredAdminToken } from "../lib/admin-secrets";
1718
import { PROXY_MARKER, ownAdmissionTokens, defaultAuthDetectDeps, detectClaudeAuth, type AuthDetectDeps } from "../claude/auth-detect";
1819
import { resolveClaudeAuthMode } from "../claude/auth-mode";
1920

@@ -149,14 +150,13 @@ export function buildClaudeEnv(
149150

150151
/**
151152
* Context-window map from the RUNNING proxy's management API (warm TTL cache; the
152-
* daemon registers every selector form — audit R3#1). 3s bound + auth header
153-
* (OPENCODEX_API_AUTH_TOKEN first, config key fallback — audit R4#1). Failure → {}
153+
* daemon registers every selector form — audit R3#1). 3s bound + management auth header.
154154
* (no [1m] marking, conservative).
155155
*/
156156
export async function fetchClaudeContextWindows(config: OcxConfig, port: number, timeoutMs = 3_000): Promise<Record<string, number>> {
157157
try {
158158
const headers = new Headers();
159-
const token = process.env.OPENCODEX_API_AUTH_TOKEN || config.apiKeys?.[0]?.key;
159+
const token = configuredAdminToken();
160160
if (token) headers.set("x-opencodex-api-key", token);
161161
const res = await fetch(`http://127.0.0.1:${port}/api/claude-code`, {
162162
headers,

src/cli/doctor.ts

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@ import { getConfigDir, getConfigPath, readConfigDiagnostics, readPid, readRuntim
1414
import { findLiveProxy } from "../server/proxy-liveness";
1515
import { gracefulStopHost } from "../lib/process-control";
1616
import { maskAccountId } from "../lib/privacy";
17-
import { loadServiceTokenFromFile } from "../lib/service-secrets";
17+
import { PROXY_ENV_KEYS, proxyEnvPresent } from "../lib/proxy-env";
18+
import { configuredAdminToken } from "../lib/admin-secrets";
1819
import { readCodexTokens } from "../codex/auth-collision";
1920
import { collectOrcaCodexHomeDiagnostic, resolveCodexHomeDir as resolveCodexHomeDirImpl, isWslRuntime, listWslWindowsCodexHomes, wslAutomountRoot, type CodexHomeDeps } from "../codex/home";
2021
import { findCodexOnPath, isWindowsInteropDir } from "../codex/shim";
@@ -286,17 +287,15 @@ export function collectWslDualInstall(deps: WslDualInstallDeps = {}): WslDualIns
286287
};
287288
}
288289

289-
const PROXY_KEYS = ["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "NO_PROXY"] as const;
290-
291290
export type ProxyEnvRow = { key: string; present: boolean };
292291
export type EnvMap = Record<string, string | undefined>;
293292

294293
/** Report only presence/absence of proxy env vars - never the value (it may
295294
* embed credentials). Checks both upper- and lower-case forms. */
296295
export function collectProxyEnv(env: EnvMap = process.env): ProxyEnvRow[] {
297-
return PROXY_KEYS.map(key => ({
296+
return PROXY_ENV_KEYS.map(key => ({
298297
key,
299-
present: !!(env[key]?.trim() || env[key.toLowerCase()]?.trim()),
298+
present: proxyEnvPresent(key, env),
300299
}));
301300
}
302301

@@ -572,7 +571,7 @@ export function formatServiceMemoryLines(report: ServiceMemoryReport): string[]
572571
const lines: string[] = [];
573572
lines.push(` -- doctor process Bun ${Bun.version} (this is NOT the service process)`);
574573
if (report.status === "unauthorized") {
575-
lines.push(" -- proxy reachable but rejected the request — set OPENCODEX_API_AUTH_TOKEN to match the service");
574+
lines.push(" -- proxy reachable but rejected the request — set OPENCODEX_ADMIN_AUTH_TOKEN to match the service");
576575
return lines;
577576
}
578577
if (report.status === "unreachable") {
@@ -762,7 +761,7 @@ export async function runDoctor(args: string[] = []): Promise<void> {
762761
console.log(` -- doctor process Bun ${Bun.version} (this is NOT the service process)`);
763762
console.log(" -- no running ocx proxy found (no live pid/runtime record)");
764763
} else {
765-
const token = process.env.OPENCODEX_API_AUTH_TOKEN ?? loadServiceTokenFromFile(process.env);
764+
const token = configuredAdminToken();
766765
const report = await fetchServiceMemory(gracefulStopHost(runtime.hostname), runtime.port, token);
767766
for (const line of formatServiceMemoryLines(report)) console.log(line);
768767
}

src/cli/help.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,12 +30,18 @@ const helpEntries: Record<string, HelpEntry> = {
3030
uninstall: {
3131
usage: "ocx uninstall",
3232
summary: "Remove service/shim/config and restore native Codex.",
33-
details: ["Alias: ocx remove"],
33+
details: [
34+
"Alias: ocx remove",
35+
"Config cleanup requires ownership metadata created by a fresh install; legacy or shared directories are left in place.",
36+
],
3437
},
3538
remove: {
3639
usage: "ocx remove",
3740
summary: "Remove service/shim/config and restore native Codex.",
38-
details: ["Alias of: ocx uninstall"],
41+
details: [
42+
"Alias of: ocx uninstall",
43+
"Config cleanup requires ownership metadata created by a fresh install; legacy or shared directories are left in place.",
44+
],
3945
},
4046
service: {
4147
usage: "ocx service [install|start|stop|status|uninstall|remove]",

src/cli/index.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
#!/usr/bin/env bun
22
import { spawn } from "node:child_process";
3-
import { rmSync } from "node:fs";
43
import { currentExternalCodexModelProvider, restoreNativeCodex, shouldInjectApiAuthHeader } from "../codex/inject";
54
import { stripGrokConfig } from "../grok/inject";
65
import { restoreLegacyOpenaiHistory } from "../codex/history-provider";
@@ -43,6 +42,7 @@ import { maybeShowUpdatePrompt } from "../update/notify";
4342
import { syncModelsToCodex } from "../codex/sync";
4443
import { normalizeUpdateChannel, runGuiUpdateWorker } from "../update/job";
4544
import { collectOrcaCodexHomeDiagnostic } from "../codex/home";
45+
import { removeOwnedConfigState } from "../lib/config-ownership";
4646

4747
const args = process.argv.slice(2);
4848
const command = args[0];
@@ -608,7 +608,13 @@ async function handleUninstall() {
608608

609609
if (failures.length === 0) {
610610
await runStep("opencodex config removed", () => {
611-
rmSync(getConfigDir(), { recursive: true, force: true });
611+
const result = removeOwnedConfigState(getConfigDir());
612+
if (result.status === "absent") return false;
613+
if (result.status === "removed") return true;
614+
const residual = result.residualPaths.length > 0
615+
? ` Residual path(s): ${result.residualPaths.join(", ")}`
616+
: "";
617+
throw new Error(`${result.status} uninstall: ${result.reason ?? "config state was not removed"}.${residual}`);
612618
});
613619
} else {
614620
console.error("Leaving opencodex config/backups in place so the failed restore step can be retried.");

0 commit comments

Comments
 (0)