From 585e5308eb02ec1631d5353acf79f0a507b0cb83 Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Mon, 21 Sep 2026 05:17:40 +0900 Subject: [PATCH] fix(diagnostics): bound project config reads Discovery accepted any .codex/config.toml via existsSync and the collector synchronously readFileSync-read project configs with no type or size checks, so a symlinked special device or a huge file could hang or exhaust the process. Open candidates with O_NOFOLLOW, verify a regular file under a 1 MiB cap with fstat, and read through a bounded descriptor; lstat rejects non-regular or oversized candidates before discovery. --- src/codex/project-config-warnings.ts | 50 ++++++++++++++++--- .../project-config-warnings.test.ts | 21 ++++++++ 2 files changed, 64 insertions(+), 7 deletions(-) diff --git a/src/codex/project-config-warnings.ts b/src/codex/project-config-warnings.ts index 50721a689df..331e96dfbca 100644 --- a/src/codex/project-config-warnings.ts +++ b/src/codex/project-config-warnings.ts @@ -1,4 +1,14 @@ -import { existsSync, readFileSync, realpathSync } from "node:fs"; +import { + closeSync, + constants, + existsSync, + fstatSync, + lstatSync, + openSync, + readFileSync, + readSync, + realpathSync, +} from "node:fs"; import path, { dirname, join, resolve } from "node:path"; import { expandUserPath } from "../config"; import { defaultCodexHome } from "./home"; @@ -8,6 +18,30 @@ import { truncateRetainedUtf8 } from "../lib/admission"; const OCX_SECTION_MARKER = "# Auto-injected by opencodex"; const DIAGNOSTICS_CACHE_TTL_MS = 30_000; const MAX_DIAGNOSTIC_VALUE_BYTES = 8 * 1024; +const MAX_PROJECT_CONFIG_BYTES = 1024 * 1024; + +function readBoundedRegularFile(filePath: string): string | null { + let fd: number | undefined; + try { + fd = openSync(filePath, constants.O_RDONLY | constants.O_NOFOLLOW); + const stat = fstatSync(fd); + if (!stat.isFile() || stat.size > MAX_PROJECT_CONFIG_BYTES) return null; + + const buffer = Buffer.allocUnsafe(MAX_PROJECT_CONFIG_BYTES + 1); + let bytesRead = 0; + while (bytesRead < buffer.length) { + const count = readSync(fd, buffer, bytesRead, buffer.length - bytesRead, null); + if (count === 0) break; + bytesRead += count; + } + if (bytesRead > MAX_PROJECT_CONFIG_BYTES) return null; + return buffer.toString("utf-8", 0, bytesRead); + } catch { + return null; + } finally { + if (fd !== undefined) closeSync(fd); + } +} function resolveCodexConfigPath(): string { const raw = process.env.CODEX_HOME?.trim(); @@ -359,6 +393,12 @@ export function discoverProjectCodexConfigPaths(options: { const globalConfigIdentity = normalizeExistingPath(codexConfigPath); const addIfExists = (projectRoot: string) => { const candidate = join(resolve(projectRoot), ".codex", "config.toml"); + try { + const stat = lstatSync(candidate); + if (!stat.isFile() || stat.size > MAX_PROJECT_CONFIG_BYTES) return; + } catch { + return; + } const candidateIdentity = normalizeExistingPath(candidate); if (candidateIdentity && candidateIdentity !== globalConfigIdentity) found.add(candidate); }; @@ -397,12 +437,8 @@ export function collectProjectCodexConfigWarnings(options: { const warnings: ProjectCodexConfigWarning[] = []; for (const path of discoverProjectCodexConfigPaths({ cwd: options.cwd, codexConfigPath })) { - try { - const content = readFileSync(path, "utf-8"); - warnings.push(...analyzeProjectCodexConfig(content, path)); - } catch { - /* skip unreadable project config */ - } + const content = readBoundedRegularFile(path); + if (content !== null) warnings.push(...analyzeProjectCodexConfig(content, path)); } return warnings; } diff --git a/tests/codex-integration/project-config-warnings.test.ts b/tests/codex-integration/project-config-warnings.test.ts index 1767ee655a5..f5e5d39aa6e 100644 --- a/tests/codex-integration/project-config-warnings.test.ts +++ b/tests/codex-integration/project-config-warnings.test.ts @@ -304,6 +304,27 @@ describe("collectProjectCodexConfigWarnings", () => { .not.toContain(candidatePath); }); + test("skips symlinked project configs", () => { + if (process.platform === "win32") return; + const projectDir = join(testDir, "symlink-project"); + const projectConfigPath = join(projectDir, ".codex", "config.toml"); + const targetPath = join(testDir, "target-config.toml"); + mkdirSync(join(projectDir, ".codex"), { recursive: true }); + writeFileSync(targetPath, 'model_provider = "anthropic"'); + symlinkSync(targetPath, projectConfigPath); + + expect(discoverProjectCodexConfigPaths({ cwd: projectDir })).not.toContain(projectConfigPath); + }); + + test("skips project configs larger than the diagnostic limit", () => { + const projectDir = join(testDir, "large-project"); + const projectConfigPath = join(projectDir, ".codex", "config.toml"); + mkdirSync(join(projectDir, ".codex"), { recursive: true }); + writeFileSync(projectConfigPath, Buffer.alloc(1024 * 1024 + 1, 0x20)); + + expect(discoverProjectCodexConfigPaths({ cwd: projectDir })).not.toContain(projectConfigPath); + }); + test("skips untrusted projects even when they define bypass config", () => { const escaped = testDir.replace(/\\/g, "\\\\"); const projectDir = join(testDir, "proj");