fix: prevent API key plaintext leak into models.json state file#38889
fix: prevent API key plaintext leak into models.json state file#38889gambletan wants to merge 1 commit intoopenclaw:mainfrom
Conversation
…claw#38757) When apiKey values are resolved from env var substitution by loadConfig(), the plaintext secret was being persisted to agents/*/agent/models.json. Add a reverse-lookup in normalizeProviders() that detects when an apiKey matches a known provider env var's value and replaces it with the env var name, preventing secrets from being written to disk.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: fecf2b2733
ℹ️ About Codex in GitHub
Codex has been enabled to automatically 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 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| const envVarName = resolveEnvApiKeyVarName(normalizedKey); | ||
| if (envVarName && process.env[envVarName] === currentApiKey) { |
There was a problem hiding this comment.
Match all provider env aliases when de-redacting apiKey
This reverse lookup only checks the single variable returned by resolveEnvApiKeyVarName(normalizedKey), but resolveEnvApiKey prefers one alias over others (for example ANTHROPIC_OAUTH_TOKEN before ANTHROPIC_API_KEY). If a config used ${ANTHROPIC_API_KEY} and both env vars are set, the comparison is made against the OAuth token value, fails, and the resolved API key plaintext is still written to models.json, so the leak fix is bypassed for providers with multiple env aliases.
Useful? React with 👍 / 👎.
Greptile SummaryThis PR addresses a real security issue (#38757) where Key changes:
Security gap identified: The reverse-lookup calls Example: User has both The fix works correctly for the majority of providers that map to a single env var (e.g. Confidence Score: 2/5
Last reviewed commit: fecf2b2 |
mdlmarkham
left a comment
There was a problem hiding this comment.
Review: API Key Plaintext Leak Fix ✅
Verdict: Solid security fix. Well-tested.
The Problem
When users configure apiKey: "${OPENAI_API_KEY}" in openclaw.json:
resolveConfigEnvVars()resolves the placeholder to the actual secret value earlyensureOpenClawModelsJson()persists the resolved value tomodels.jsonas plaintext- Secret is now in a state file that could be logged, backed up, or accidentally shared
The Fix
// If apiKey is a resolved secret (not an env var name), reverse-lookup
// and replace with the env var name before persisting
if (!ENV_VAR_NAME_RE.test(currentApiKey.trim())) {
const envVarName = resolveEnvApiKeyVarName(normalizedKey);
if (envVarName && process.env[envVarName] === currentApiKey) {
normalizedProvider = { ...normalizedProvider, apiKey: envVarName };
}
}Why it works:
- Checks if the apiKey value matches a known provider env var's value
- Uses strict equality against
process.env[canonicalVarName]to prevent false positives - Skips values that already look like env var names (e.g.,
OPENAI_API_KEY)
Edge Cases
-
Multiple providers with same API key: If
openaianddeepseekboth use the same key value, only the canonical env var for each provider is checked. ✅ -
Key value happens to match env var value but wasn't env-var-derived: The lookup is provider-specific (
resolveEnvApiKeyVarName), so only the canonical env var for that provider is checked. Unlikely to have false positives. ✅ -
Key is set via auth profile instead of env var: This code path only triggers for resolved env vars. Auth profile resolution happens elsewhere. ✅
Testing
Good coverage:
- Tests that resolved env var value is replaced with env var name
- Tests that
models.jsoncontains the env var name, not plaintext - Tests that existing
models-configtests pass (no regressions)
Minor Question
The regex ENV_VAR_NAME_RE = /^[A-Z_][A-Z0-9_]*$/ requires uppercase. What about lowercase env vars like openai_api_key?
Assessment: This is fine — resolveEnvApiKeyVarName returns canonical uppercase names (e.g., OPENAI_API_KEY), and the check is against canonical names. The regex is correctly strict.
Recommendation: Approve. Security fix, well-tested, minimal change.
| const envVarName = resolveEnvApiKeyVarName(normalizedKey); | ||
| if (envVarName && process.env[envVarName] === currentApiKey) { | ||
| mutated = true; | ||
| normalizedProvider = { ...normalizedProvider, apiKey: envVarName }; | ||
| } |
There was a problem hiding this comment.
Reverse-lookup silently fails for multi-env-var providers
resolveEnvApiKeyVarName returns the first env var that is currently set in the environment (via resolveEnvApiKey's priority chain). For providers like anthropic, github-copilot, chutes, zai, opencode, qwen-portal, minimax-portal, kimi-coding, and huggingface, multiple env vars are accepted in priority order (e.g. ANTHROPIC_OAUTH_TOKEN ?? ANTHROPIC_API_KEY).
If a user has both env vars set but their config was resolved from the lower-priority one (e.g. the config contains "${ANTHROPIC_API_KEY}", but ANTHROPIC_OAUTH_TOKEN is also present), resolveEnvApiKeyVarName("anthropic") returns "ANTHROPIC_OAUTH_TOKEN". The strict equality check process.env["ANTHROPIC_OAUTH_TOKEN"] === currentApiKey then fails (since currentApiKey holds the value of ANTHROPIC_API_KEY), and the plaintext secret is still persisted to models.json — silently bypassing the fix.
To close this gap, the check should iterate over all candidate env vars for the provider (not just the one that happens to resolve first) and replace the key with whichever matching var name is found.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/agents/models-config.providers.ts
Line: 533-537
Comment:
**Reverse-lookup silently fails for multi-env-var providers**
`resolveEnvApiKeyVarName` returns the *first* env var that is currently set in the environment (via `resolveEnvApiKey`'s priority chain). For providers like `anthropic`, `github-copilot`, `chutes`, `zai`, `opencode`, `qwen-portal`, `minimax-portal`, `kimi-coding`, and `huggingface`, multiple env vars are accepted in priority order (e.g. `ANTHROPIC_OAUTH_TOKEN ?? ANTHROPIC_API_KEY`).
If a user has **both** env vars set but their config was resolved from the *lower-priority* one (e.g. the config contains `"${ANTHROPIC_API_KEY}"`, but `ANTHROPIC_OAUTH_TOKEN` is also present), `resolveEnvApiKeyVarName("anthropic")` returns `"ANTHROPIC_OAUTH_TOKEN"`. The strict equality check `process.env["ANTHROPIC_OAUTH_TOKEN"] === currentApiKey` then fails (since `currentApiKey` holds the value of `ANTHROPIC_API_KEY`), and the plaintext secret is still persisted to `models.json` — silently bypassing the fix.
To close this gap, the check should iterate over **all** candidate env vars for the provider (not just the one that happens to resolve first) and replace the key with whichever matching var name is found.
How can I resolve this? If you propose a fix, please make it concise.Land #38889 by @gambletan. Co-authored-by: gambletan <ethanchang32@gmail.com>
|
Landed via maintainer flow. What I did:
SHAs:
Thanks @gambletan for the contribution. |
…w#38889) Land openclaw#38889 by @gambletan. Co-authored-by: gambletan <ethanchang32@gmail.com>
…w#38889) Land openclaw#38889 by @gambletan. Co-authored-by: gambletan <ethanchang32@gmail.com>
…w#38889) Land openclaw#38889 by @gambletan. Co-authored-by: gambletan <ethanchang32@gmail.com>
…w#38889) Land openclaw#38889 by @gambletan. Co-authored-by: gambletan <ethanchang32@gmail.com>
…w#38889) Land openclaw#38889 by @gambletan. Co-authored-by: gambletan <ethanchang32@gmail.com>
…w#38889) Land openclaw#38889 by @gambletan. Co-authored-by: gambletan <ethanchang32@gmail.com>
…w#38889) Land openclaw#38889 by @gambletan. Co-authored-by: gambletan <ethanchang32@gmail.com>
…w#38889) Land openclaw#38889 by @gambletan. Co-authored-by: gambletan <ethanchang32@gmail.com>
…w#38889) Land openclaw#38889 by @gambletan. Co-authored-by: gambletan <ethanchang32@gmail.com>
Summary
Fixes #38757
When users configure API keys with env var substitution (
"apiKey": "${LLM_API_KEY}") inopenclaw.json,resolveConfigEnvVars()resolves the placeholder to the actual secret value beforeensureOpenClawModelsJson()runs. The plaintext secret was then persisted toagents/*/agent/models.json.normalizeProviders()that detects when anapiKeyvalue matches a known provider env var's value and replaces it with the env var name insteadprocess.env[canonicalVarName]to prevent false positivesOPENAI_API_KEY)Test plan
normalizeProviders()replaces resolved secret with env var nameensureOpenClawModelsJson()writes env var name (not plaintext) tomodels.jsonmodels-configtests pass (no regressions)🤖 Generated with Claude Code