Skip to content

Commit d505fa0

Browse files
committed
fix(models-config): security & correctness fixes from PR review
Five issues raised by Aisle / Codex / Greptile review on PR #72869, addressed inline rather than deferred: 1. CWE-59 symlink-following chmod (Aisle high #1) ensureModelsFileModeForModelsJson called fs.chmod on a path that may be replaced by a symlink. If an attacker can write to the agent dir (or OPENCLAW_AGENT_DIR points there), the chmod followed the link and changed perms on an arbitrary owned file. Now lstat first and refuse to chmod symlinks or non-regular files. 2. Prototype pollution via JSON keys (Aisle medium #3 / CWE-1321) stripAuthProfilesVolatileFields() copied untrusted keys into a plain {} object. Special keys '__proto__', 'constructor', 'prototype' could mutate the result's prototype chain. Now uses Object.create(null) for the result and explicitly filters those three keys (belt-and-suspenders). 3. DoS via unbounded auth-profiles fingerprinting (Aisle medium #4) readAuthProfilesStableHash had no size or depth limits. - Added MAX_AUTH_PROFILES_BYTES = 8 MiB. Above the cap we hash raw bytes instead of running JSON.parse + recursive transform + stable-stringify. - Added MAX_AUTH_PROFILES_DEPTH = 64 with a depth-cap marker so the recursive walk can't stack-overflow on pathologically nested input. 4. 'token' incorrectly stripped from fingerprint (Codex P2 / Greptile P2) AUTH_PROFILE_VOLATILE_FIELDS included 'token' to keep OAuth session token rotation from invalidating the cache. But profiles with type: 'token' use the literal 'token' key as a long-lived static credential — stripping it would mask real auth-state changes when a user rotates a static API token. Removed 'token' from the volatile set and documented the boundary inline. OAuth session fields ('access', 'refresh') and timing fields stay stripped. Skipped from this commit (will reply on threads): - Cache short-circuit on stale on-disk credentials (Codex/Greptile P1): separate concern from the security fixes; needs design discussion on whether to validate disk-vs-config or remove the short-circuit. - models.json drift in cache key (Codex P1): same — touches the fingerprint shape and overlaps with the targetProvider short-circuit. - targetProvider short-circuit untested (Greptile P2): test follow-up once the short-circuit semantics are settled. - Aisle medium #5 (raw secrets in fingerprint cache): structurally larger refactor; needs to land separately to keep this commit\'s blast radius clear. Lint: 0 errors. TS: clean.
1 parent 6d309f6 commit d505fa0

1 file changed

Lines changed: 91 additions & 6 deletions

File tree

src/agents/models-config.ts

Lines changed: 91 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,14 @@ export { resetModelsJsonReadyCacheForTest } from "./models-config-state.js";
2828
const AUTH_PROFILE_VOLATILE_FIELDS: ReadonlySet<string> = new Set([
2929
"access",
3030
"refresh",
31-
"token",
31+
// NOTE: "token" was previously stripped as a volatile field, but profiles
32+
// with `type: "token"` use the literal `token` key as a long-lived
33+
// credential identifier (Greptile P2 / Codex P2 on PR #72869). Stripping
34+
// it would mask real auth-state changes — e.g. user rotates a static
35+
// API token but the cached fingerprint stays identical, so the
36+
// implicit-provider-discovery pipeline never re-runs. Keep the OAuth
37+
// session fields above ("access"/"refresh") and the timing fields below,
38+
// but treat "token" as significant content.
3239
"expires",
3340
"expiresAt",
3441
"expiresIn",
@@ -39,6 +46,35 @@ const AUTH_PROFILE_VOLATILE_FIELDS: ReadonlySet<string> = new Set([
3946
"lastValidatedAt",
4047
]);
4148

49+
/**
50+
* Hard cap on the bytes we will read + parse from auth-profiles.json when
51+
* computing the stable fingerprint hash (Aisle medium #4 on PR #72869).
52+
* Without a cap, a crafted/large profile file becomes a CPU + memory
53+
* exhaustion vector via fs.readFile + JSON.parse + recursive walk +
54+
* stableStringify. 8 MiB is far above any plausible legitimate auth-
55+
* profiles size and still bounds the worst-case allocation.
56+
*/
57+
const MAX_AUTH_PROFILES_BYTES = 8 * 1024 * 1024;
58+
59+
/**
60+
* Maximum recursion depth when stripping volatile fields. Bounds the
61+
* recursive walk so deeply-nested JSON cannot stack-overflow the gateway
62+
* during fingerprinting (Aisle medium #4).
63+
*/
64+
const MAX_AUTH_PROFILES_DEPTH = 64;
65+
66+
/**
67+
* Keys that mutate Object prototype when assigned with bracket syntax,
68+
* triggering prototype pollution (CWE-1321). We always skip these when
69+
* building the stripped fingerprint object even though the result is
70+
* immediately stable-stringified — defence in depth (Aisle medium #3).
71+
*/
72+
const DANGEROUS_PROTO_KEYS: ReadonlySet<string> = new Set([
73+
"__proto__",
74+
"prototype",
75+
"constructor",
76+
]);
77+
4278
/**
4379
* Compute a content-based fingerprint for a JSON file whose mtime may
4480
* change without meaningful content change (e.g. auth-profiles.json rewritten
@@ -49,6 +85,23 @@ const AUTH_PROFILE_VOLATILE_FIELDS: ReadonlySet<string> = new Set([
4985
* exists.
5086
*/
5187
async function readAuthProfilesStableHash(pathname: string): Promise<string | null> {
88+
// Aisle medium #4: bound the read by file size before pulling it into
89+
// memory + JSON.parse + recursive walk. Above the cap we hash the raw
90+
// bytes (already-streamed by readFile, but at least we avoid the
91+
// recursive transform / stringify cost) instead of the parsed shape.
92+
const stat = await fs.stat(pathname).catch(() => null);
93+
if (!stat) {
94+
return null;
95+
}
96+
if (stat.size > MAX_AUTH_PROFILES_BYTES) {
97+
let raw: Buffer;
98+
try {
99+
raw = await fs.readFile(pathname);
100+
} catch {
101+
return null;
102+
}
103+
return createHash("sha256").update(raw).digest("hex");
104+
}
52105
let raw: string;
53106
try {
54107
raw = await fs.readFile(pathname, "utf8");
@@ -63,23 +116,37 @@ async function readAuthProfilesStableHash(pathname: string): Promise<string | nu
63116
// changes, but avoid using mtime.
64117
return createHash("sha256").update(raw).digest("hex");
65118
}
66-
const stable = stripAuthProfilesVolatileFields(parsed);
119+
const stable = stripAuthProfilesVolatileFields(parsed, 0);
67120
return createHash("sha256").update(stableStringify(stable)).digest("hex");
68121
}
69122

70-
function stripAuthProfilesVolatileFields(value: unknown): unknown {
123+
function stripAuthProfilesVolatileFields(value: unknown, depth: number): unknown {
124+
// Aisle medium #4: bound recursion to prevent stack overflow on
125+
// pathologically nested JSON. At the cap we serialize the subtree as a
126+
// shallow marker; this still produces a stable hash (any change at or
127+
// below the cap rolls into the parent's stringification).
128+
if (depth >= MAX_AUTH_PROFILES_DEPTH) {
129+
return "[depth-capped]";
130+
}
71131
if (value === null || typeof value !== "object") {
72132
return value;
73133
}
74134
if (Array.isArray(value)) {
75-
return value.map((entry) => stripAuthProfilesVolatileFields(entry));
135+
return value.map((entry) => stripAuthProfilesVolatileFields(entry, depth + 1));
76136
}
77-
const result: Record<string, unknown> = {};
137+
// Aisle medium #3: build with Object.create(null) so prototype-mutating
138+
// keys ("__proto__", "constructor", "prototype") in untrusted input
139+
// can't pollute the resulting object's prototype chain. Filter them
140+
// explicitly too — belt and suspenders.
141+
const result: Record<string, unknown> = Object.create(null);
78142
for (const [key, entry] of Object.entries(value as Record<string, unknown>)) {
79143
if (AUTH_PROFILE_VOLATILE_FIELDS.has(key)) {
80144
continue;
81145
}
82-
result[key] = stripAuthProfilesVolatileFields(entry);
146+
if (DANGEROUS_PROTO_KEYS.has(key)) {
147+
continue;
148+
}
149+
result[key] = stripAuthProfilesVolatileFields(entry, depth + 1);
83150
}
84151
return result;
85152
}
@@ -152,6 +219,24 @@ async function readExistingModelsFile(pathname: string): Promise<{
152219
}
153220

154221
export async function ensureModelsFileModeForModelsJson(pathname: string): Promise<void> {
222+
// Aisle high #1 on PR #72869 (CWE-59 symlink-following chmod): refuse to
223+
// chmod a symlink. fs.chmod follows links, so if an attacker can replace
224+
// ${agentDir}/models.json with a symlink pointing at a sensitive file
225+
// owned by the gateway user, this best-effort chmod would change
226+
// permissions on the link target instead. lstat first; if the path is a
227+
// symlink (or anything other than a regular file), bail.
228+
let stat: Awaited<ReturnType<typeof fs.lstat>>;
229+
try {
230+
stat = await fs.lstat(pathname);
231+
} catch {
232+
return; // best-effort — file may not exist yet
233+
}
234+
if (stat.isSymbolicLink()) {
235+
return;
236+
}
237+
if (!stat.isFile()) {
238+
return;
239+
}
155240
await fs.chmod(pathname, 0o600).catch(() => {
156241
// best-effort
157242
});

0 commit comments

Comments
 (0)