Skip to content

fix: prevent API key plaintext leak into models.json state file#38889

Closed
gambletan wants to merge 1 commit intoopenclaw:mainfrom
gambletan:fix/38757-api-key-plaintext-leak
Closed

fix: prevent API key plaintext leak into models.json state file#38889
gambletan wants to merge 1 commit intoopenclaw:mainfrom
gambletan:fix/38757-api-key-plaintext-leak

Conversation

@gambletan
Copy link
Copy Markdown
Contributor

Summary

Fixes #38757

When users configure API keys with env var substitution ("apiKey": "${LLM_API_KEY}") in openclaw.json, resolveConfigEnvVars() resolves the placeholder to the actual secret value before ensureOpenClawModelsJson() runs. The plaintext secret was then persisted to agents/*/agent/models.json.

  • Add a reverse-lookup step in normalizeProviders() that detects when an apiKey value matches a known provider env var's value and replaces it with the env var name instead
  • Uses strict equality check against process.env[canonicalVarName] to prevent false positives
  • Skips values that already look like env var names (e.g. OPENAI_API_KEY)

Test plan

  • Unit test: normalizeProviders() replaces resolved secret with env var name
  • E2E test: ensureOpenClawModelsJson() writes env var name (not plaintext) to models.json
  • All existing models-config tests pass (no regressions)

🤖 Generated with Claude Code

…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.
@openclaw-barnacle openclaw-barnacle Bot added agents Agent runtime and tooling size: S labels Mar 7, 2026
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment on lines +533 to +534
const envVarName = resolveEnvApiKeyVarName(normalizedKey);
if (envVarName && process.env[envVarName] === currentApiKey) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented Mar 7, 2026

Greptile Summary

This PR addresses a real security issue (#38757) where resolveConfigEnvVars() was resolving ${ENV_VAR} placeholders to their plaintext values before ensureOpenClawModelsJson() ran, causing API keys to be written in plaintext to agents/*/agent/models.json. The fix adds a reverse-lookup step in normalizeProviders() that detects when an apiKey value matches a known provider env var's current value and replaces it with the env var name.

Key changes:

  • src/agents/models-config.providers.ts: New ENV_VAR_NAME_RE regex and a reverse-lookup block in normalizeProviders() that checks process.env[envVarName] === currentApiKey and substitutes the env var name back in.
  • Two new test cases: one unit test for normalizeProviders() and one E2E test for ensureOpenClawModelsJson().

Security gap identified:

The reverse-lookup calls resolveEnvApiKeyVarName(), which returns the env var name from the first env var that is currently set in the environment. For providers with multiple env vars in a priority chain (anthropicANTHROPIC_OAUTH_TOKEN ?? ANTHROPIC_API_KEY; github-copilotCOPILOT_GITHUB_TOKEN ?? GH_TOKEN ?? GITHUB_TOKEN; and others), if the user's config was resolved from a lower-priority env var but a higher-priority one is also set, the strict equality check fails, and the plaintext secret is still written to models.json.

Example: User has both ANTHROPIC_OAUTH_TOKEN and ANTHROPIC_API_KEY set in environment, but their config explicitly referenced ${ANTHROPIC_API_KEY}. The reverse-lookup will return "ANTHROPIC_OAUTH_TOKEN" as the canonical env var, but process.env["ANTHROPIC_OAUTH_TOKEN"] contains a different value than the resolved currentApiKey (which is the value of ANTHROPIC_API_KEY), so the equality check fails and the plaintext secret persists.

The fix works correctly for the majority of providers that map to a single env var (e.g. openai, google, groq, mistral, etc.), which covers most users.

Confidence Score: 2/5

  • The fix solves the core plaintext leak issue for most providers (those with single env vars), but has a documented security gap for multi-env-var providers when both high and low priority env vars are set.
  • The fix works correctly for the majority of providers (openai, google, groq, mistral, etc.) which map to a single canonical env var. However, for ~9 providers with multiple env vars in a priority chain (anthropic, github-copilot, chutes, zai, opencode, qwen-portal, minimax-portal, kimi-coding, huggingface), the reverse-lookup silently fails if a user has multiple env vars set and their config references a lower-priority one. This causes a plaintext API key to persist to models.json, partially defeating the security fix. A score of 2/5 reflects that the fix addresses the primary issue but leaves a meaningful security gap that should be addressed before merging.
  • src/agents/models-config.providers.ts — specifically the reverse-lookup block at lines 533-537. The logic needs to check all candidate env vars for each provider, not just the first currently-set one.

Last reviewed commit: fecf2b2

Copy link
Copy Markdown

@mdlmarkham mdlmarkham left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: API Key Plaintext Leak Fix ✅

Verdict: Solid security fix. Well-tested.

The Problem

When users configure apiKey: "${OPENAI_API_KEY}" in openclaw.json:

  1. resolveConfigEnvVars() resolves the placeholder to the actual secret value early
  2. ensureOpenClawModelsJson() persists the resolved value to models.json as plaintext
  3. 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

  1. Multiple providers with same API key: If openai and deepseek both use the same key value, only the canonical env var for each provider is checked. ✅

  2. 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. ✅

  3. 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.json contains the env var name, not plaintext
  • Tests that existing models-config tests 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.

Comment on lines +533 to +537
const envVarName = resolveEnvApiKeyVarName(normalizedKey);
if (envVarName && process.env[envVarName] === currentApiKey) {
mutated = true;
normalizedProvider = { ...normalizedProvider, apiKey: envVarName };
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@steipete
Copy link
Copy Markdown
Contributor

steipete commented Mar 7, 2026

Landed via maintainer flow.

What I did:

  • Rebased onto latest main via temp branch land-pr-38889.
  • Resolved one test-file merge conflict against current main by keeping both the existing header-normalization coverage and the new env-var reverse-lookup coverage.
  • Added changelog entry crediting @gambletan and fix: prevent API key plaintext leak into models.json state file #38889.
  • Ran full gate before commit:
    • pnpm lint
    • pnpm build
    • pnpm test
  • Committed with Conventional Commit + co-author trailer.

SHAs:

  • Original PR head: fecf2b273331ecde9851c87a617ef126787d7e35
  • Landed on main: 17ab46aedd689ae73541f07365422b717e9e1d74

Thanks @gambletan for the contribution.

@steipete steipete closed this Mar 7, 2026
vincentkoc pushed a commit to BryanTegomoh/openclaw-upstream that referenced this pull request Mar 8, 2026
openperf pushed a commit to openperf/moltbot that referenced this pull request Mar 8, 2026
mcaxtr pushed a commit to mcaxtr/openclaw that referenced this pull request Mar 8, 2026
GordonSH-oss pushed a commit to GordonSH-oss/openclaw that referenced this pull request Mar 9, 2026
jenawant pushed a commit to jenawant/openclaw that referenced this pull request Mar 10, 2026
V-Gutierrez pushed a commit to V-Gutierrez/openclaw-vendor that referenced this pull request Mar 17, 2026
lovewanwan pushed a commit to lovewanwan/openclaw that referenced this pull request Apr 28, 2026
ogt-redknie pushed a commit to ogt-redknie/OPENX that referenced this pull request May 2, 2026
github-actions Bot pushed a commit to Desicool/openclaw that referenced this pull request May 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

agents Agent runtime and tooling size: S

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: v2026.3.2: custom provider apiKey from env/SecretRef is persisted as plaintext into state agents/*/agent/models.json

3 participants