Security: harden gateway timeouts and auth store sealing#39059
Security: harden gateway timeouts and auth store sealing#39059vincentkoc wants to merge 5 commits intomainfrom
Conversation
🔒 Aisle Security AnalysisWe found 4 potential security issue(s) in this PR:
1. 🟡 Encrypted auth store can be overwritten in plaintext when OPENCLAW_PASSPHRASE is missing
Description
Concrete reproduction (one example):
Vulnerable code: const plaintext = `${JSON.stringify(data, null, 2)}\n`;
const passphrase = resolvePassphrase(env);
fs.writeFileSync(pathname, passphrase ? sealUtf8(plaintext, passphrase) : plaintext, "utf8");RecommendationPrevent silent downgrade/overwrite when an encrypted file exists but the passphrase is missing. Recommended hardening in
Example fix: export function saveSealedJsonFile(pathname: string, data: unknown, env: NodeJS.ProcessEnv = process.env): void {
const dir = path.dirname(pathname);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
const passphrase = resolvePassphrase(env);
if (!passphrase && fs.existsSync(pathname)) {
const existingRaw = fs.readFileSync(pathname, "utf8");
if (isSealedJsonText(existingRaw)) {
// Do not overwrite encrypted content with plaintext.
throw new SealedJsonPassphraseRequiredError(pathname);
}
}
const plaintext = `${JSON.stringify(data, null, 2)}\n`;
fs.writeFileSync(pathname, passphrase ? sealUtf8(plaintext, passphrase) : plaintext, "utf8");
fs.chmodSync(pathname, 0o600);
}Additionally (defense in depth): in 2. 🟡 Sealed JSON encryption uses implicit scrypt defaults (weak/unstable KDF parameters) and omits KDF metadata
DescriptionThe sealed JSON file format derives the AES-256-GCM key from Impacts:
Vulnerable code: function deriveKey(passphrase: string, salt: Buffer): Buffer {
return scryptSync(passphrase, salt, 32);
}RecommendationMake KDF parameters explicit and store them in the envelope so encryption strength and decryptability are stable across releases. Example: type SealedJsonEnvelopeV1 = {
v: 1;
alg: "aes-256-gcm";
kdf: { name: "scrypt"; N: number; r: number; p: number };
salt: string;
iv: string;
tag: string;
ciphertext: string;
};
const SCRYPT_PARAMS = { N: 1 << 18, r: 8, p: 1 } as const; // tune for your perf budget
function deriveKey(passphrase: string, salt: Buffer, kdf = SCRYPT_PARAMS): Buffer {
return scryptSync(passphrase, salt, 32, kdf);
}
// when sealing:
// kdf: { name: "scrypt", ...SCRYPT_PARAMS }
// when unsealing:
// validate envelope.kdf and pass into deriveKey(...)Additionally:
3. 🟡 Auth store written with insecure permissions window (chmod after write)
Description
On POSIX systems, newly-created files are typically created with mode Vulnerable code: fs.writeFileSync(pathname, passphrase ? sealUtf8(plaintext, passphrase) : plaintext, "utf8");
fs.chmodSync(pathname, 0o600);RecommendationWrite with secure permissions at creation time and do an atomic temp-file + rename update. You already have an atomic helper ( import { writeTextAtomic } from "./json-files.js";
export function saveSealedJsonFile(pathname: string, data: unknown, env: NodeJS.ProcessEnv = process.env): void {
const plaintext = `${JSON.stringify(data, null, 2)}\n`;
const passphrase = resolvePassphrase(env);
const payload = passphrase ? sealUtf8(plaintext, passphrase) : plaintext;
// write with mode=0o600 and atomic rename
// (ensureDirMode should be 0o700)
await writeTextAtomic(pathname, payload, { mode: 0o600, ensureDirMode: 0o700, appendTrailingNewline: false });
}If you must keep sync APIs, use 4. 🟡 Symlink/hardlink TOCTOU when writing sealed JSON files (arbitrary file clobber/chmod)
Description
If an attacker can influence either:
then they can cause OpenClaw to:
This is particularly risky if OpenClaw is run with elevated privileges or in shared directories, and it is realistic in multi-user systems when the state dir location is overridden to a world-writable location (e.g., Vulnerable code: if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
}
fs.writeFileSync(pathname, payload, "utf8");
fs.chmodSync(pathname, 0o600);
RecommendationHarden against symlink/hardlink attacks and TOCTOU:
Example (conceptual; platform-conditional): import { constants as c } from "node:fs";
const fd = fs.openSync(pathname, c.O_WRONLY | c.O_CREAT | c.O_TRUNC | (c.O_NOFOLLOW ?? 0), 0o600);
try {
fs.writeFileSync(fd, payload, "utf8");
fs.fsyncSync(fd);
} finally {
fs.closeSync(fd);
}Even better, reuse the existing atomic writer ( Analyzed PR: #39059 at commit Last updated on: 2026-03-07T18:01:35Z |
Greptile SummaryThis PR adds three security hardening measures: bounded The gateway timeout and transcript permission changes are well-implemented. The sealed-JSON infrastructure is cryptographically sound, but two correctness issues were found in how the new sealed reader is wired into the auth-store load paths:
Confidence Score: 2/5
|
| const forceReadOnly = process.env.OPENCLAW_AUTH_STORE_READONLY === "1"; | ||
| const shouldWrite = !readOnly && !forceReadOnly && (legacy !== null || mergedOAuth || syncedCli); | ||
| if (shouldWrite) { | ||
| saveJsonFile(authPath, store); | ||
| saveSealedJsonFile(authPath, store); | ||
| } |
There was a problem hiding this comment.
Sealed store can be silently downgraded to plaintext
When OPENCLAW_PASSPHRASE is not set but a sealed auth-profiles.json already exists, loadCoercedStore returns null (the SealedJsonPassphraseRequiredError is caught and undefined is returned). The code then falls through to the legacy/oauth/external-CLI migration path. If any of legacy !== null, mergedOAuth, or syncedCli is true, shouldWrite becomes true and saveSealedJsonFile is called without a passphrase — writing plaintext over the sealed file.
Concrete scenario:
- Operator sets
OPENCLAW_PASSPHRASE, runs once — credentials are sealed. - Operator later starts OpenClaw without
OPENCLAW_PASSPHRASE(e.g., a deploy that forgot the env var). oauth.jsonor external CLI credentials exist →mergedOAuth/syncedCliistrue.- The sealed
auth-profiles.jsonis overwritten with plaintext, with no warning.
The PR description states "reads fail closed with a warning if the passphrase env var is missing" but this write path silently removes the sealing. A guard is needed before writing: if the file already contains sealed content and no passphrase is available, the write should be skipped (or at least an explicit warning logged).
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/agents/auth-profiles/store.ts
Line: 435-439
Comment:
**Sealed store can be silently downgraded to plaintext**
When `OPENCLAW_PASSPHRASE` is not set but a sealed `auth-profiles.json` already exists, `loadCoercedStore` returns `null` (the `SealedJsonPassphraseRequiredError` is caught and `undefined` is returned). The code then falls through to the legacy/oauth/external-CLI migration path. If any of `legacy !== null`, `mergedOAuth`, or `syncedCli` is true, `shouldWrite` becomes `true` and `saveSealedJsonFile` is called without a passphrase — writing **plaintext** over the sealed file.
Concrete scenario:
1. Operator sets `OPENCLAW_PASSPHRASE`, runs once — credentials are sealed.
2. Operator later starts OpenClaw without `OPENCLAW_PASSPHRASE` (e.g., a deploy that forgot the env var).
3. `oauth.json` or external CLI credentials exist → `mergedOAuth`/`syncedCli` is `true`.
4. The sealed `auth-profiles.json` is overwritten with plaintext, with no warning.
The PR description states *"reads fail closed with a warning if the passphrase env var is missing"* but this write path silently removes the sealing. A guard is needed before writing: if the file already contains sealed content and no passphrase is available, the write should be skipped (or at least an explicit warning logged).
How can I resolve this? If you propose a fix, please make it concise.| } | ||
|
|
||
| function deriveKey(passphrase: string, salt: Buffer): Buffer { | ||
| return scryptSync(passphrase, salt, 32); |
There was a problem hiding this comment.
scrypt cost parameters not explicitly set
scryptSync is called with only the key length — no options object is passed. Node.js defaults to N=16384, r=8, p=1 which is modest. Explicitly documenting (and potentially raising) the cost parameters would make the security posture clearer and easier to audit or tune later:
| return scryptSync(passphrase, salt, 32); | |
| return scryptSync(passphrase, salt, 32, { N: 65536, r: 8, p: 1 }); |
N=65536 is a more conservative baseline for at-rest credential encryption, and having the values in code makes future tuning (and a version bump) straightforward.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/infra/sealed-json-file.ts
Line: 39
Comment:
**scrypt cost parameters not explicitly set**
`scryptSync` is called with only the key length — no `options` object is passed. Node.js defaults to `N=16384, r=8, p=1` which is modest. Explicitly documenting (and potentially raising) the cost parameters would make the security posture clearer and easier to audit or tune later:
```suggestion
return scryptSync(passphrase, salt, 32, { N: 65536, r: 8, p: 1 });
```
`N=65536` is a more conservative baseline for at-rest credential encryption, and having the values in code makes future tuning (and a version bump) straightforward.
How can I resolve this? If you propose a fix, please make it concise.There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 9761a71a5a
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| const shouldWrite = !readOnly && !forceReadOnly && (legacy !== null || mergedOAuth || syncedCli); | ||
| if (shouldWrite) { | ||
| saveJsonFile(authPath, store); | ||
| saveSealedJsonFile(authPath, store); |
There was a problem hiding this comment.
Prevent overwrite when encrypted store cannot be decrypted
When loadCoercedStore returns null because auth-profiles.json is sealed and OPENCLAW_PASSPHRASE is missing/invalid, this path can still set shouldWrite via mergeOAuthFileIntoStore/syncExternalCliCredentials and immediately persist store back to authPath; with no passphrase set, saveSealedJsonFile writes plaintext, so a valid encrypted store can be silently replaced with partial plaintext credentials. This breaks the intended fail-closed behavior and can cause credential loss/leakage in environments that still have oauth.json or external CLI creds.
Useful? React with 👍 / 👎.
Summary
GatewayClient.request()could hang indefinitely, auth stores were always plaintext at rest, and mirrored transcript writes did not re-assert restrictive permissions after append.OPENCLAW_PASSPHRASEsealing forauth-profiles.jsonand legacyoauth.json, added regression coverage, and re-applied0600perms after mirrored transcript writes.@mariozechner/pi-coding-agentand multiple local readers still depend on raw JSONL session files.Change Type (select all)
Scope (select all touched areas)
Linked Issue/PR
User-visible / Behavior Changes
GatewayClientrequests now fail withgateway request timeout for <method>instead of hanging forever.OPENCLAW_PASSPHRASEis set, OpenClaw-owned auth stores are written in sealed form and require that same env var to be read.0600after append.Security Impact (required)
Yes/No) NoYes/No) YesYes/No) NoYes/No) NoYes/No) NoYes, explain risk + mitigation:Optional auth-store sealing adds a new env-based operator hardening path. The format is backward compatible because plaintext reads still work when sealing is not enabled, and encrypted reads fail closed with a warning if the passphrase env var is missing.
Repro + Verification
Environment
OPENCLAW_PASSPHRASESteps
pnpm check.pnpm build.Expected
OPENCLAW_PASSPHRASEis set.Actual
Evidence
Human Verification (required)
What you personally verified (not just CI), and how:
Compatibility / Migration
Yes/No) YesYes/No) YesYes/No) NoOperators who want auth-store sealing can set
OPENCLAW_PASSPHRASEbefore writing credentials and keep it set for future reads.Failure Recovery (if this breaks)
OPENCLAW_PASSPHRASEbefore writing new auth store files, or revert the branch.auth-profiles.json/oauth.jsonfrom backup if an operator loses the passphrase.OPENCLAW_PASSPHRASEwhen the env var is missing.Risks and Mitigations
OPENCLAW_PASSPHRASE.