From 6c9c3d89cf5e1d499000398ffe4152164c2296a3 Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Tue, 22 Sep 2026 05:08:30 +0900 Subject: [PATCH 1/2] fix(service): combine token-binding, qualified-localhost bind, and WSL ownership state Combines three fork PRs touching service guards: bind reused-token hardening to the validated file (#502), normalize qualified localhost binds (#458), and accept legacy WSL ownership state (#262), rebased onto current dev. bun test: service-secrets + service-auth-qualified-localhost + codex-home-wsl + server-auth-localhost-bind 21 pass; server-auth + windows-deploy-close-regressions 117 pass with 2 env-flaky (stalled-400 fails on clean dev baseline; catalog admission fails only in full-file ordering, passes isolated and on baseline) --- scripts/test-layout/layout.json | 2 + src/lib/service-secrets.ts | 95 ++++++++++++++++++- src/server/index.ts | 5 +- src/service.ts | 2 +- src/service/guards.ts | 81 ++++++++-------- src/service/state.ts | 13 ++- structure/codex-home.md | 5 +- .../codex-integration/codex-home-wsl.test.ts | 25 ++++- tests/fixtures/test-layout-expected.json | 2 + tests/helpers/server-auth-config.ts | 18 ++++ .../server/server-auth-localhost-bind.test.ts | 42 ++++++++ tests/server/server-auth.test.ts | 18 +--- .../service-auth-qualified-localhost.test.ts | 69 ++++++++++++++ tests/service/service-secrets.test.ts | 46 +++++++++ .../windows-deploy-close-regressions.test.ts | 2 +- 15 files changed, 355 insertions(+), 70 deletions(-) create mode 100644 tests/helpers/server-auth-config.ts create mode 100644 tests/server/server-auth-localhost-bind.test.ts create mode 100644 tests/service/service-auth-qualified-localhost.test.ts diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 0cbb0fc7b38..8ef9cb21959 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1354,6 +1354,7 @@ "self-launch-argv.test.ts": "lib", "server-403-permission-e2e.test.ts": "server", "server-agent-task-recovery-replay.test.ts": "server", + "server-auth-localhost-bind.test.ts": "server", "server-auth.test.ts": "server", "server-background-lifecycle.test.ts": "server", "server-clickjacking-headers.test.ts": "server", @@ -1381,6 +1382,7 @@ "server-xai-oauth-401-replay.test.ts": "server", "server-xai-responses-streaming.test.ts": "server", "service-ownership-compatibility.test.ts": "service", + "service-auth-qualified-localhost.test.ts": "service", "service-ownership-handover.test.ts": "service", "service-ownership-state.test.ts": "service", "service-probe-docker.test.ts": "service", diff --git a/src/lib/service-secrets.ts b/src/lib/service-secrets.ts index 7dbd58a8991..926bd21cc73 100644 --- a/src/lib/service-secrets.ts +++ b/src/lib/service-secrets.ts @@ -1,8 +1,8 @@ import { createHash } from "node:crypto"; -import { closeSync, existsSync, fsyncSync, lstatSync, openSync, readFileSync, unlinkSync } from "node:fs"; +import { closeSync, constants, existsSync, fchmodSync, fstatSync, fsyncSync, lstatSync, openSync, readFileSync, readSync, unlinkSync } from "node:fs"; import { join } from "node:path"; import { getConfigDir } from "../config"; -import { atomicWriteFile } from "../config/atomic-write"; +import { atomicWriteFile, atomicWriteFileNoFollow } from "../config/atomic-write"; const MAX_SERVICE_API_TOKEN_BYTES = 4096; @@ -49,6 +49,97 @@ export function readServiceApiTokenState(): ServiceApiTokenState { } } +/** + * Validate and tighten a reused service token without applying permissions to a + * pathname that may have been replaced since validation. + * + * The token is read off the opened descriptor β€” never off the path a second + * time β€” and once it validates, it is REPUBLISHED through the no-follow atomic + * writer rather than hardened in place. Windows ACL tooling is pathname-based, + * so an in-place harden there could still land on a substituted entry; the + * republish instead replaces whatever entry sits at the path with a freshly + * hardened owner-only file holding the same token. On return the path names + * that file, which is the contract `origin: "file"` reports. On POSIX the + * opened descriptor is also fchmod'd first, so a token-bearing inode a race + * moved aside is still tightened wherever its entry ended up. + * + * Callers must run this under `withConfigMutationLockSync`: client-key rotation + * replaces the token under that lock, and a republish outside it could rename a + * stale token back over a committed rotation. + */ +export function hardenReusedServiceApiToken( + validate: (token: string) => void, +): ServiceApiTokenState { + const path = serviceApiTokenFilePath(); + // O_NOFOLLOW refuses a symlinked entry and O_NONBLOCK keeps a FIFO (or other + // blocking node) from stalling the open before fstat can reject it. Windows + // omits both flags, so there the descriptor is bound to its entry by the + // lstat/fstat identity comparison below. + const flags = process.platform === "win32" + ? constants.O_RDONLY + : constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK; + let fd: number | undefined; + try { + fd = openSync(path, flags); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === "ENOENT") return { kind: "absent" }; + if (code === "ELOOP") return { kind: "unsafe", reason: "service token path is not a bounded regular file" }; + return { kind: "unsafe", reason: "service token path could not be inspected" }; + } + try { + const stat = fstatSync(fd, { bigint: true }); + if (!stat.isFile() || stat.size > BigInt(MAX_SERVICE_API_TOKEN_BYTES)) { + return { kind: "unsafe", reason: "service token path is not a bounded regular file" }; + } + if (process.platform === "win32") { + let entry; + try { + entry = lstatSync(path, { bigint: true }); + } catch { + return { kind: "unsafe", reason: "service token path could not be inspected" }; + } + if (entry.isSymbolicLink() || !entry.isFile() || entry.dev !== stat.dev || entry.ino !== stat.ino) { + return { kind: "unsafe", reason: "service token path is not a bounded regular file" }; + } + } + // Bound the read as well as the stat: a file that grows past the cap after + // fstat is unsafe, not something to buffer whole. + const bytes = Buffer.alloc(MAX_SERVICE_API_TOKEN_BYTES + 1); + let length = 0; + try { + while (length < bytes.length) { + const count = readSync(fd, bytes, length, bytes.length - length, null); + if (!count) break; + length += count; + } + } catch { + return { kind: "unsafe", reason: "service token file could not be read" }; + } + if (length > MAX_SERVICE_API_TOKEN_BYTES) { + return { kind: "unsafe", reason: "service token path is not a bounded regular file" }; + } + const token = bytes.subarray(0, length).toString("utf8").trim(); + if (!token) return { kind: "unsafe", reason: "service token file is empty" }; + validate(token); + if (process.platform !== "win32") { + // Best-effort matches the previous repair behavior: the descriptor binds + // the chmod to the regular file opened above even if its directory entry + // moved, so the validated inode is never left loose under another name. + try { fchmodSync(fd, 0o600); } catch { /* best-effort */ } + } + // The descriptor's work ends here β€” and must: Windows refuses to rename over + // a file this process still holds open, so the republish cannot run while it + // is held. + closeSync(fd); + fd = undefined; + atomicWriteFileNoFollow(path, `${token}\n`); + return { kind: "present", token, fingerprint: serviceApiTokenFingerprint(token) }; + } finally { + if (fd !== undefined) closeSync(fd); + } +} + export function writeServiceApiTokenFile(token: string): PersistedServiceApiToken { const value = token.trim(); if (!value || /[\r\n\0]/.test(value) || Buffer.byteLength(value) > MAX_SERVICE_API_TOKEN_BYTES) { diff --git a/src/server/index.ts b/src/server/index.ts index 61fbcec7d8a..7dba95c8f8a 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -320,12 +320,13 @@ function startServerWithSpendLedgerOwner(port: number | undefined, deps: StartSe const listenPort = port ?? config.port ?? 10100; setCorsOrigin(listenPort); - // Canonicalize an explicit "localhost" bind to IPv4 so it matches the injected base_url (which + // Canonicalize an explicit "localhost" bind (including its fully-qualified spelling) to IPv4 + // so it matches the injected base_url (which // resolves localhostβ†’127.0.0.1): on Windows `localhost` resolves ::1-first, but the injected URL // is 127.0.0.1, so binding literal "localhost" would reintroduce the F4 refusal. Wildcards // (0.0.0.0/::) and specific hosts are left untouched so intentional exposure is preserved. const configuredHost = config.hostname?.trim(); - const bindHost = !configuredHost || /^localhost$/i.test(configuredHost) ? "127.0.0.1" : configuredHost; + const bindHost = !configuredHost || /^localhost\.?$/i.test(configuredHost) ? "127.0.0.1" : configuredHost; // Unauthenticated loopback listener (#1102). Off unless explicitly enabled. // A port-less enabled entry is the companion form: same port as the public listener, on diff --git a/src/service.ts b/src/service.ts index 79cfff6efda..8be7805d0ad 100644 --- a/src/service.ts +++ b/src/service.ts @@ -7,7 +7,7 @@ */ export type { ServiceBackend, ServiceInstallState, ServiceStateEvidence, ServiceStateResolution, ServiceOwner, ServiceOwnership, ServiceOwnershipSubject, ServiceOwnershipResolution, ServiceStateSwapDeps, RecordServiceOwnerRequest, RecordServiceOwnerDeps, ReleaseServiceOwnerDeps, RemoveServiceStateDeps } from "./service/state"; -export { SERVICE_MANAGED_ENV, SERVICE_OWNERSHIP_PROTOCOL_VERSION, SERVICE_OWNERSHIP_MINIMUM_CLI_VERSION, stableLauncherEntry, serviceLogPath, serviceStatePaths, serviceStatePathsForOpenCodexHome, parseServiceInstallState, parseServiceOwnership, inspectServiceStateEvidence, resolveServiceState, currentServiceHomes, serviceHomeMatches, readServiceBackend, serviceReinstallArgs, serviceInstallArgs, ServiceStateConflictError, ServiceOwnershipSubjectMismatchError, ServiceOwnershipSubjectUnknownError, ServiceTakeoverCompatibilityChangedError, swapServiceInstallState, removeServiceInstallStateRecords, serviceOwnership, resolveServiceOwnership, sameServiceOwnershipSubject, desktopOwnsService, ownershipGrantedTo, recordServiceOwner, releaseServiceOwner } from "./service/state"; +export { SERVICE_MANAGED_ENV, SERVICE_OWNERSHIP_PROTOCOL_VERSION, SERVICE_OWNERSHIP_MINIMUM_CLI_VERSION, stableLauncherEntry, serviceLogPath, serviceStatePaths, serviceStatePathsForOpenCodexHome, parseServiceInstallState, parseServiceOwnership, inspectServiceStateEvidence, resolveServiceState, currentServiceHomes, serviceHomeMatches, serviceCodexHomeMatchesInstall, readServiceBackend, serviceReinstallArgs, serviceInstallArgs, ServiceStateConflictError, ServiceOwnershipSubjectMismatchError, ServiceOwnershipSubjectUnknownError, ServiceTakeoverCompatibilityChangedError, swapServiceInstallState, removeServiceInstallStateRecords, serviceOwnership, resolveServiceOwnership, sameServiceOwnershipSubject, desktopOwnsService, ownershipGrantedTo, recordServiceOwner, releaseServiceOwner } from "./service/state"; export type { OwnershipMutationLeaseOptions, OwnershipMutationLease } from "./service/ownership-mutation-lease.mjs"; export { acquireOwnershipMutationLease, withOwnershipMutationLease } from "./service/ownership-mutation-lease.mjs"; export type { ManagingCliRole, ManagingCliObservation, RegisteredManagingCliInvocation, ServiceTakeoverCompatibilityInput, ServiceTakeoverCompatibility } from "./service/ownership-compatibility"; diff --git a/src/service/guards.ts b/src/service/guards.ts index 3afb9b25f99..d052c427eb6 100644 --- a/src/service/guards.ts +++ b/src/service/guards.ts @@ -1,7 +1,8 @@ import { execSync } from "node:child_process"; import { chmodSync, existsSync, mkdirSync, writeFileSync } from "node:fs"; import { getConfigDir, loadConfig } from "../config"; -import { readServiceApiTokenState, serviceApiTokenFilePath } from "../lib/service-secrets"; +import { withConfigMutationLockSync } from "../config/mutation-lock"; +import { hardenReusedServiceApiToken, readServiceApiTokenState, serviceApiTokenFilePath } from "../lib/service-secrets"; import { tokenCollidesWithAdmin } from "../lib/admin-secrets"; import { randomBytes } from "node:crypto"; import { hardenSecretDir, hardenSecretPath } from "../lib/windows-secret-acl"; @@ -9,8 +10,9 @@ import { recordOwnedConfigPath } from "../lib/config-ownership"; import { isTestHomeGuardArmed } from "../lib/test-home-guard"; import { diagnoseService } from "./diagnostics"; import type { ServiceDiagnostic } from "./diagnostics"; -import { currentCodexHome, currentOpenCodexHome, normalizePathForCompare, readServiceInstallState } from "./state"; +import { currentCodexHome, currentOpenCodexHome, normalizePathForCompare, readServiceInstallState, serviceCodexHomeMatchesInstall } from "./state"; import { resolveCodexSqliteHome } from "../codex/paths"; +import { isLoopbackHostname } from "../codex/loopback-target"; import { win32 } from "node:path"; /** @@ -46,9 +48,7 @@ export function assertServiceEnvironmentMatchesInstall(): void { const state = readServiceInstallState(); if (!state) return; const actualCodexHome = currentCodexHome(); - const expected = normalizePathForCompare(state.codexHome); - const actual = normalizePathForCompare(actualCodexHome); - if (expected !== actual) { + if (!serviceCodexHomeMatchesInstall(state.codexHome)) { throw new ServiceOwnershipError( `Service was installed with CODEX_HOME=${state.codexHome}, but current CODEX_HOME=${actualCodexHome}. ` + "Run the service command from the same Codex home so native Codex restore updates the correct config.", @@ -73,11 +73,6 @@ export function assertServiceEnvironmentMatchesInstall(): void { } } -function isLoopbackHostname(hostname: string | undefined): boolean { - const normalized = (hostname ?? "127.0.0.1").trim().toLowerCase(); - return normalized === "" || normalized === "localhost" || normalized === "127.0.0.1" || normalized === "::1" || normalized === "[::1]"; -} - /** * The `ocx` command a user should rerun for the service state they actually have. * @@ -228,42 +223,42 @@ function persistServiceApiToken(token: string): string { * connected to a hub the same file holds that hub's issued client key, which must not be * overwritten by a local install. * + * Provisioning runs inside the cross-process config mutation lock: client-key rotation + * replaces `service-api-token` and records the new fingerprint under the same lock, so a + * reuse republish or a fresh write here can never interleave with a committed rotation and + * silently roll its bytes back. + * * The PATH is logged; the value never is, and never reaches argv, a unit file or a plist. */ export function writeServiceApiTokenFile(): ProvisionedServiceApiToken | null { - const token = process.env.OPENCODEX_API_AUTH_TOKEN?.trim(); - if (token) { - // Last line of defence: every install/repair path funnels through here, so a - // collision cannot reach disk regardless of which caller ran (#2696). - assertNotAdminToken(token); - const path = persistServiceApiToken(token); - console.log(`πŸ” Data-plane token taken from OPENCODEX_API_AUTH_TOKEN and stored at ${path} (owner-only).`); - return { path, origin: "env" }; - } - if (isLoopbackHostname(loadConfig().hostname)) return null; - const existing = readServiceApiTokenState(); - if (existing.kind === "present") { - // The collision check is NOT only for the env branch. A file that already holds the admin - // token -- hand-pasted before #2696, or written by the very incident this unit closes -- - // was silently accepted here, so `ocx status` reported `present (file)` and the hub - // crash-looped at boot with no command pointing at the cause. - const path = serviceApiTokenFilePath(); - assertNotAdminToken(existing.token, process.env, "file"); - // `readServiceApiTokenState` accepts any bounded regular file, so a reused token may well - // be group- or world-readable. Tighten it on the way through rather than claiming - // "owner-only" about a mode nobody checked; best-effort, since a non-owner cannot chmod - // and failing the install over it would be worse than the loose mode. - try { chmodSync(path, 0o600); } catch { /* best-effort */ } - if (process.platform === "win32") hardenSecretPath(path, { required: false }); - // No log line: repair/restart hit this on every run and an unconditional notice about a - // credential file trains operators to ignore the one that matters. - return { path, origin: "file" }; - } - if (existing.kind === "unsafe") throw new Error(`${existing.reason}: ${serviceApiTokenFilePath()}`); - const path = persistServiceApiToken(randomBytes(32).toString("hex")); - console.log(`πŸ” Provisioned an owner-only data-plane token at ${path}; nothing needs to be exported by hand.`); - console.log(" Remote machines get their own per-client key β€” run 'ocx hub invite' instead of copying this file."); - return { path, origin: "generated" }; + return withConfigMutationLockSync(() => { + const token = process.env.OPENCODEX_API_AUTH_TOKEN?.trim(); + if (token) { + // Last line of defence: every install/repair path funnels through here, so a + // collision cannot reach disk regardless of which caller ran (#2696). + assertNotAdminToken(token); + const path = persistServiceApiToken(token); + console.log(`πŸ” Data-plane token taken from OPENCODEX_API_AUTH_TOKEN and stored at ${path} (owner-only).`); + return { path, origin: "env" }; + } + if (isLoopbackHostname(loadConfig().hostname)) return null; + const existing = hardenReusedServiceApiToken(token => assertNotAdminToken(token, process.env, "file")); + if (existing.kind === "present") { + // The collision check is NOT only for the env branch. A file that already holds the admin + // token -- hand-pasted before #2696, or written by the very incident this unit closes -- + // was silently accepted here, so `ocx status` reported `present (file)` and the hub + // crash-looped at boot with no command pointing at the cause. + const path = serviceApiTokenFilePath(); + // No log line: repair/restart hit this on every run and an unconditional notice about a + // credential file trains operators to ignore the one that matters. + return { path, origin: "file" }; + } + if (existing.kind === "unsafe") throw new Error(`${existing.reason}: ${serviceApiTokenFilePath()}`); + const path = persistServiceApiToken(randomBytes(32).toString("hex")); + console.log(`πŸ” Provisioned an owner-only data-plane token at ${path}; nothing needs to be exported by hand.`); + console.log(" Remote machines get their own per-client key β€” run 'ocx hub invite' instead of copying this file."); + return { path, origin: "generated" }; + }); } export function sh(cmd: string): string { diff --git a/src/service/state.ts b/src/service/state.ts index dbd2520b07b..4964f947d8c 100644 --- a/src/service/state.ts +++ b/src/service/state.ts @@ -3,7 +3,7 @@ import { homedir } from "node:os"; import { delimiter, dirname, isAbsolute, join, posix, resolve, win32 } from "node:path"; import { expandUserPath, getConfigDir } from "../config"; import { atomicWriteFileStreamed } from "../config/atomic-write"; -import { resolveCodexHomeDir, type CodexHomeDeps } from "../codex/home"; +import { isWslRuntime, resolveCodexHomeDir, type CodexHomeDeps } from "../codex/home"; import { resolveCodexSqliteHome } from "../codex/paths"; import { durableBunRuntime, type BunRuntimeSource, type DurableBunRuntime } from "../lib/bun-runtime"; import { WINSW_SHA256, WINSW_VERSION } from "../lib/winsw"; @@ -857,6 +857,17 @@ export function serviceHomeMatches(a: string, b: string): boolean { return normalizePathForCompare(a) === normalizePathForCompare(b); } +/** Accept the Linux default written by service versions predating WSL home discovery. */ +export function serviceCodexHomeMatchesInstall(recordedHome: string, deps: CodexHomeDeps = {}): boolean { + const actualHome = currentCodexHome(deps); + if (serviceHomeMatches(recordedHome, actualHome)) return true; + + const env = deps.env ?? process.env; + if (env.CODEX_HOME?.trim() || !isWslRuntime(deps)) return false; + const legacyDefault = join((deps.homedir ?? homedir)(), ".codex"); + return serviceHomeMatches(recordedHome, legacyDefault); +} + /** Single accessor for backend-sensitive service code β€” v1/legacy state maps to scheduler. */ export function readServiceBackend(): ServiceBackend { return readServiceInstallState()?.backend === "native" ? "native" : "scheduler"; diff --git a/structure/codex-home.md b/structure/codex-home.md index 47177b946ae..0f8cdc7a3df 100644 --- a/structure/codex-home.md +++ b/structure/codex-home.md @@ -78,8 +78,9 @@ treat it as destructive, not as an upgrade or restart command. Service install-state ownership uses this same resolver. In WSL, an unset `CODEX_HOME` may resolve to the single discoverable Windows Desktop home; recording Linux `~/.codex` instead would make a later repair or uninstall look foreign even though the service and runtime were started from the -same environment. An explicit `CODEX_HOME` remains authoritative, and existing foreign ownership -records are never migrated implicitly. +same environment. Ownership checks therefore accept that exact legacy Linux-home record when WSL +now discovers a Windows home. An explicit `CODEX_HOME` remains authoritative, and other foreign +ownership records are never migrated implicitly. > Decision record: [ADR-0006](decisions/ADR-0006-codex-home.md) diff --git a/tests/codex-integration/codex-home-wsl.test.ts b/tests/codex-integration/codex-home-wsl.test.ts index 671eb4a5a6c..b0e523eb970 100644 --- a/tests/codex-integration/codex-home-wsl.test.ts +++ b/tests/codex-integration/codex-home-wsl.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test"; import { wslAutomountRoot, listWslWindowsCodexHomes } from "../../src/codex/home"; import { isWindowsInteropDir } from "../../src/codex/shim"; -import { currentServiceHomes } from "../../src/service"; +import { currentServiceHomes, serviceCodexHomeMatchesInstall } from "../../src/service"; describe("wsl.conf automount root", () => { test("defaults to /mnt when wsl.conf is absent or silent", () => { @@ -63,4 +63,27 @@ describe("wsl.conf automount root", () => { expect(homes.codexHome).toBe(windowsCodexHome); expect(homes.codexHome).not.toBe("/home/example/.codex"); }); + + test("service ownership accepts the legacy Linux fallback when WSL now discovers Windows Codex", () => { + const usersRoot = ["/mnt/c", "Users"].join("/"); + const windowsCodexHome = [usersRoot, "windows-user", ".codex"].join("/"); + const deps = { + env: { WSL_DISTRO_NAME: "Ubuntu" }, + platform: "linux", + homedir: () => "/home/example", + usersRoot, + existsSync: (path: string) => path === usersRoot + || path === `${windowsCodexHome}/config.toml`, + readdirSync: () => ["windows-user"], + statSync: (() => ({ isDirectory: () => true })) as never, + realpathSync: (path: string) => path, + }; + + expect(serviceCodexHomeMatchesInstall("/home/example/.codex", deps)).toBe(true); + expect(serviceCodexHomeMatchesInstall("/home/other/.codex", deps)).toBe(false); + expect(serviceCodexHomeMatchesInstall("/home/example/.codex", { + ...deps, + env: { ...deps.env, CODEX_HOME: windowsCodexHome }, + })).toBe(false); + }); }); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index c69955e4208..921c807b9ce 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1183,6 +1183,7 @@ "self-launch-argv.test.ts": "lib", "server-403-permission-e2e.test.ts": "server", "server-agent-task-recovery-replay.test.ts": "server", + "server-auth-localhost-bind.test.ts": "server", "server-auth.test.ts": "server", "server-background-lifecycle.test.ts": "server", "server-clickjacking-headers.test.ts": "server", @@ -1210,6 +1211,7 @@ "server-xai-oauth-401-replay.test.ts": "server", "server-xai-responses-streaming.test.ts": "server", "service-ownership-compatibility.test.ts": "service", + "service-auth-qualified-localhost.test.ts": "service", "service-ownership-handover.test.ts": "service", "service-ownership-state.test.ts": "service", "service-probe-docker.test.ts": "service", diff --git a/tests/helpers/server-auth-config.ts b/tests/helpers/server-auth-config.ts new file mode 100644 index 00000000000..a0593293389 --- /dev/null +++ b/tests/helpers/server-auth-config.ts @@ -0,0 +1,18 @@ +import type { OcxConfig } from "../../src/types"; + +export function serverAuthConfig(hostname?: string): OcxConfig { + return { + port: 10100, + hostname, + defaultProvider: "openai", + providers: { + openai: { + adapter: "openai-chat", + baseUrl: "https://api.example.test/v1", + apiKey: "sk-secret-value", + headers: { "X-Custom": "provider-secret" }, + defaultModel: "gpt-test", + }, + }, + }; +} diff --git a/tests/server/server-auth-localhost-bind.test.ts b/tests/server/server-auth-localhost-bind.test.ts new file mode 100644 index 00000000000..123bad3b896 --- /dev/null +++ b/tests/server/server-auth-localhost-bind.test.ts @@ -0,0 +1,42 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { existsSync, mkdirSync, mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { saveConfig } from "../../src/config"; +import { startServer } from "../../src/server"; +import { installIsolatedCodexHome, type IsolatedCodexHome } from "../helpers/isolated-codex-home"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { serverAuthConfig as config } from "../helpers/server-auth-config"; + +const previousApiToken = process.env.OPENCODEX_API_AUTH_TOKEN; +const previousOpencodexHome = process.env.OPENCODEX_HOME; +const TEST_DIR = mkdtempSync(join(tmpdir(), "ocx-server-auth-localhost-")); +let isolatedCodexHome: IsolatedCodexHome | null = null; + +beforeEach(() => { + isolatedCodexHome = installIsolatedCodexHome("ocx-server-auth-codex-"); + mkdirSync(TEST_DIR, { recursive: true }); + process.env.OPENCODEX_HOME = TEST_DIR; +}); + +afterEach(() => { + if (previousApiToken === undefined) delete process.env.OPENCODEX_API_AUTH_TOKEN; + else process.env.OPENCODEX_API_AUTH_TOKEN = previousApiToken; + if (previousOpencodexHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousOpencodexHome; + isolatedCodexHome?.restore(); + isolatedCodexHome = null; + if (existsSync(TEST_DIR)) removeTreeWithRetry(TEST_DIR); +}); + +describe("server local API auth", () => { + test("fully-qualified localhost binds to the same IPv4 target generated for clients", async () => { + saveConfig(config("localhost.")); + const server = startServer(0); + try { + expect(server.hostname).toBe("127.0.0.1"); + } finally { + await server.stop(true); + } + }); +}); diff --git a/tests/server/server-auth.test.ts b/tests/server/server-auth.test.ts index aec00ee0be2..a6b17ca30de 100644 --- a/tests/server/server-auth.test.ts +++ b/tests/server/server-auth.test.ts @@ -55,6 +55,7 @@ import { resetDebugSettingsForTests, setDebugSettings } from "../../src/lib/debu import { watchdogMs } from "../helpers/ci-watchdog"; import { removeTreeWithRetry } from "../helpers/remove-tree"; import { deferredResetSseUpstream } from "../helpers/deferred-reset-sse-upstream"; +import { serverAuthConfig as config } from "../helpers/server-auth-config"; const previousApiToken = process.env.OPENCODEX_API_AUTH_TOKEN; const previousOpencodexHome = process.env.OPENCODEX_HOME; const originalGlobalFetch = globalThis.fetch; @@ -71,23 +72,6 @@ const originalGlobalWebSocket = globalThis.WebSocket; const TEST_DIR = mkdtempSync(join(tmpdir(), "ocx-server-auth-")); let isolatedCodexHome: IsolatedCodexHome | null = null; -function config(hostname?: string): OcxConfig { - return { - port: 10100, - hostname, - defaultProvider: "openai", - providers: { - openai: { - adapter: "openai-chat", - baseUrl: "https://api.example.test/v1", - apiKey: "sk-secret-value", - headers: { "X-Custom": "provider-secret" }, - defaultModel: "gpt-test", - }, - }, - }; -} - const REMOTE_CATALOG_BYTES = '{"models":[{"slug":"fixture/model","display_name":"Fixture Model","priority":1,"visibility":"list","base_instructions":"Fixture instructions","input_modalities":["text"]}]}'; const REMOTE_DATA_KEY = "ocx_data_remote_catalog"; diff --git a/tests/service/service-auth-qualified-localhost.test.ts b/tests/service/service-auth-qualified-localhost.test.ts new file mode 100644 index 00000000000..324563d21eb --- /dev/null +++ b/tests/service/service-auth-qualified-localhost.test.ts @@ -0,0 +1,69 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { saveConfig } from "../../src/config"; +import { assertServiceAuthEnvironment, writeServiceApiTokenFile } from "../../src/service"; +import { serviceApiTokenFilePath } from "../../src/lib/service-secrets"; +import type { OcxConfig } from "../../src/types"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +/** + * Fully-qualified `localhost.` is the same bind as `localhost`: the server canonicalizes both + * to 127.0.0.1, so the service guards must classify it as loopback too. When the private copy + * of that predicate in src/service/guards.ts did not strip the trailing dot, `localhost.` took + * the remote-bind path β€” install demanded a usable data-plane token file for a listener that + * requires no admission credential at all. Lives beside service.test.ts because that file is + * at its committed size cap in tests/fixtures/file-size-baseline.json. + */ +const TEST_DIR = mkdtempSync(join(tmpdir(), "ocx-service-auth-qualified-localhost-")); +const previousOpenCodexHome = process.env.OPENCODEX_HOME; +const previousApiAuthToken = process.env.OPENCODEX_API_AUTH_TOKEN; + +function installConfig(hostname: string): void { + if (existsSync(TEST_DIR)) removeTreeWithRetry(TEST_DIR); + mkdirSync(TEST_DIR, { recursive: true }); + process.env.OPENCODEX_HOME = TEST_DIR; + delete process.env.OPENCODEX_API_AUTH_TOKEN; + saveConfig({ + port: 10100, + hostname, + providers: { openai: { adapter: "openai-chat", baseUrl: "https://api.example.test/v1" } }, + defaultProvider: "openai", + } as OcxConfig); +} + +afterEach(() => { + if (previousOpenCodexHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousOpenCodexHome; + if (previousApiAuthToken === undefined) delete process.env.OPENCODEX_API_AUTH_TOKEN; + else process.env.OPENCODEX_API_AUTH_TOKEN = previousApiAuthToken; + if (existsSync(TEST_DIR)) removeTreeWithRetry(TEST_DIR); +}); + +describe("service install auth preflight", () => { + test("a fully-qualified loopback bind needs no data-plane token and provisions none", () => { + for (const hostname of ["localhost", "localhost."]) { + installConfig(hostname); + + expect(() => assertServiceAuthEnvironment()).not.toThrow(); + // Loopback installs create no credential: admission is not required, and on a + // hub-connected machine this file holds the hub's issued client key instead. + expect(writeServiceApiTokenFile()).toBeNull(); + expect(existsSync(serviceApiTokenFilePath())).toBe(false); + } + }); + + test("an unusable token file on a fully-qualified loopback bind does not block install", () => { + for (const hostname of ["localhost", "localhost."]) { + installConfig(hostname); + // Empty-after-trim reads as "unsafe" β€” the state a remote bind refuses at preflight. + writeFileSync(serviceApiTokenFilePath(), "\n", "utf8"); + + expect(() => assertServiceAuthEnvironment()).not.toThrow(); + // The writer leaves the file for the operator rather than throwing or replacing it. + expect(writeServiceApiTokenFile()).toBeNull(); + expect(readFileSync(serviceApiTokenFilePath(), "utf8")).toBe("\n"); + } + }); +}); diff --git a/tests/service/service-secrets.test.ts b/tests/service/service-secrets.test.ts index ef43c39aca2..acb04a051c4 100644 --- a/tests/service/service-secrets.test.ts +++ b/tests/service/service-secrets.test.ts @@ -1,17 +1,23 @@ import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +import { execFileSync } from "node:child_process"; import * as nodeFs from "node:fs"; import { + chmodSync, existsSync, lstatSync, mkdtempSync, + readFileSync, + renameSync, readdirSync, rmSync, + statSync, symlinkSync, writeFileSync, } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { + hardenReusedServiceApiToken, readServiceApiTokenState, readTokenBackupState, removeOrphanTokenBackup, @@ -76,6 +82,46 @@ describe("startup data-plane token resolution", () => { }); describe("service API token ownership", () => { + test("hardens the validated token descriptor rather than a replacement pathname", () => { + if (process.platform === "win32") return; + const path = serviceApiTokenFilePath(); + const openedToken = join(home, "opened-token"); + const victim = join(home, "victim"); + writeFileSync(path, "ocx_data_original\n", { mode: 0o644 }); + writeFileSync(victim, "executable\n", { mode: 0o755 }); + chmodSync(path, 0o644); + chmodSync(victim, 0o755); + + const state = hardenReusedServiceApiToken(token => { + expect(token).toBe("ocx_data_original"); + renameSync(path, openedToken); + symlinkSync(victim, path); + }); + + expect(state).toMatchObject({ kind: "present", token: "ocx_data_original" }); + expect(statSync(openedToken).mode & 0o777).toBe(0o600); + expect(statSync(victim).mode & 0o777).toBe(0o755); + // Identity at return: the swap during validation is replaced, so the path + // the caller reports names a hardened file holding the validated token β€” + // never the substituted symlink. + expect(lstatSync(path).isSymbolicLink()).toBe(false); + expect(lstatSync(path).mode & 0o777).toBe(0o600); + expect(readFileSync(path, "utf8").trim()).toBe("ocx_data_original"); + }); + + test("a non-regular token path is unsafe rather than blocking the open", () => { + if (process.platform === "win32") return; + const path = serviceApiTokenFilePath(); + execFileSync("mkfifo", [path]); + + const state = hardenReusedServiceApiToken(() => { + throw new Error("validation must not run for a non-regular token path"); + }); + + expect(state.kind).toBe("unsafe"); + if (state.kind === "unsafe") expect(state.reason).toContain("regular file"); + }); + test("writes only the exact owner path through an atomic owner-only replacement", () => { const token = "ocx_data_0123456789abcdef0123456789abcdef01234567"; const persisted = writeServiceApiTokenFile(token); diff --git a/tests/windows/windows-deploy-close-regressions.test.ts b/tests/windows/windows-deploy-close-regressions.test.ts index b65925b29be..91e9088d8a5 100644 --- a/tests/windows/windows-deploy-close-regressions.test.ts +++ b/tests/windows/windows-deploy-close-regressions.test.ts @@ -81,7 +81,7 @@ describe("server bind canonicalizes explicit localhost but preserves wildcards ( const src = read("src/server/index.ts"); test("literal localhost binds to 127.0.0.1; 0.0.0.0/:: exposure is untouched", () => { expect(src).toContain("const configuredHost = config.hostname?.trim();"); - expect(src).toContain('!configuredHost || /^localhost$/i.test(configuredHost) ? "127.0.0.1"'); + expect(src).toContain('!configuredHost || /^localhost\\.?$/i.test(configuredHost) ? "127.0.0.1"'); // Must not blanket-rewrite the PUBLIC bind host β€” that would break intentional 0.0.0.0 // exposure, which is the regression this guards. // From 07f5285ba9e508f55b0adc210d58ecf6b9361122 Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Tue, 22 Sep 2026 08:13:50 +0900 Subject: [PATCH 2/2] docs(service): record the unsafe-file contract on reused-token hardening --- src/lib/service-secrets.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/lib/service-secrets.ts b/src/lib/service-secrets.ts index 926bd21cc73..97ffe111dc1 100644 --- a/src/lib/service-secrets.ts +++ b/src/lib/service-secrets.ts @@ -63,6 +63,12 @@ export function readServiceApiTokenState(): ServiceApiTokenState { * opened descriptor is also fchmod'd first, so a token-bearing inode a race * moved aside is still tightened wherever its entry ended up. * + * + * Return contract vs `readServiceApiTokenState`: an empty or malformed token file + * reports `unsafe` here and is never written β€” the path-based pre-check may still + * pass the install on loopback while this writer deliberately leaves the file + * untouched. Only `absent` permits a fresh write; anything unreadable stays as-is. + * * Callers must run this under `withConfigMutationLockSync`: client-key rotation * replaces the token under that lock, and a republish outside it could rename a * stale token back over a committed rotation.