Skip to content

CRITICAL: Config secrets exposed in JSON after update/doctor #9627

Description

@MwC-Trexx

BUG REPORT: Config Secrets Exposed in JSON After Update/Doctor

Severity: CRITICAL 🔴 (Security)

OpenClaw Version: 2026.2.3

Affected Commands: openclaw update, openclaw doctor, openclaw configure


Summary

When OpenClaw runs config write operations (during update, doctor, or configure commands), environment variable references in the config file are resolved and replaced with actual credentials, permanently storing secrets in plain text JSON.

Expected behavior: Environment variable syntax ${VAR_NAME} should be preserved in the config file.

Actual behavior: After running openclaw update, credentials are baked into openclaw.json:

{
  "tools": {
    "web": {
      "search": {
        "apiKey": "bsc_1234567890abcdef_example"  // ❌ Should be "${BRAVE_SEARCH_API_KEY}"
      }
    }
  },
  "channels": {
    "telegram": {
      "botToken": "1234567890:ABCDefGhIJKlmNoPqRsTuVwXyZ_example"  // ❌ Should be "${TELEGRAM_BOT_TOKEN}"
    }
  }
}

Root Cause

In src/config/config-io.ts (compiled to dist/config-*.js):

// 1. Config is loaded and resolved
const resolved = resolveConfigEnvVars(obj, env);  // ${VAR} → actual values

// 2. Config is validated with resolved values
const validated = validateConfigObjectWithPlugins(resolved);

// 3. Config is written back to disk - but env vars are already gone!
const json = JSON.stringify(applyModelDefaults(stampConfigVersion(cfg)), null, 2);
await fs.promises.writeFile(configPath, json, ...);

The writeConfigFile() function does not preserve or restore the original ${VAR} syntax after resolution.


Impact

Security:

  • Credentials leak into config files that may be:
    • Backed up
    • Version controlled (git)
    • Exposed via config management systems
    • Copied between machines
  • Violations of secret management best practices

Operational:

  • Users must manually restore ${VAR} references after every update
  • Config files become inconsistent between environments
  • Rotation of secrets becomes risky

Reproduction Steps

  1. Create openclaw.json with env var reference:
{
  "tools": {
    "web": {
      "search": {
        "apiKey": "${BRAVE_SEARCH_API_KEY}"
      }
    }
  }
}
  1. Set environment variable:
export BRAVE_SEARCH_API_KEY="sk-abc123..."
  1. Run:
openclaw doctor
# or
openclaw update
  1. Check result:
grep "apiKey" openclaw.json
# Output: "apiKey": "sk-abc123..."  ❌ Credential exposed!

Solution Options

Option A: Preserve Env Var Syntax (Recommended)

Modify writeConfigFile() to track which fields used ${VAR} syntax during load, then restore that syntax on write:

interface ConfigMetadata {
  envVarFields: Map<string, string>;  // path → original ${VAR} syntax
}

function writeConfigFile(cfg: Config, metadata: ConfigMetadata) {
  const json = JSON.stringify(cfg);
  
  // Restore ${VAR} syntax for fields that had it
  for (const [path, varRef] of metadata.envVarFields) {
    json = restoreEnvVarSyntax(json, path, varRef);
  }
  
  await fs.promises.writeFile(configPath, json);
}

Option B: Skip Config Write During Doctor/Update

Only write config when explicitly requested by user, not during routine operations:

async function doctor(opts: { writeConfig?: boolean } = {}) {
  // ... diagnostics ...
  if (opts.writeConfig) {
    await writeConfigFile(cfg);  // Only if user explicitly asks
  }
}

Option C: Redact Credentials on Write

Document best practice: "Never commit openclaw.json to git; use .env or systemd env vars instead"

  • Add .gitignore template
  • Provide migration guide

Workaround (Current)

Until this is fixed, store all secrets in systemd environment variables:

~/.config/systemd/user/openclaw-gateway.service:

[Service]
Environment="BRAVE_SEARCH_API_KEY=your-key-here"
Environment="TELEGRAM_BOT_TOKEN=your-token-here"
Environment="OPENROUTER_API_KEY=your-key-here"

~/.openclaw/openclaw.json:

{
  "tools": {
    "web": {
      "search": {
        "apiKey": "${BRAVE_SEARCH_API_KEY}"
      }
    }
  },
  "channels": {
    "telegram": {
      "botToken": "${TELEGRAM_BOT_TOKEN}"
    }
  }
}

Then reload:

systemctl --user daemon-reload
systemctl --user restart openclaw-gateway

Additional Notes

  • Config resolution happens in resolveConfigEnvVars() and substituteAny()
  • These functions correctly handle env var expansion at load time
  • Problem is that write path doesn't have reverse mapping
  • Affects all fields with apiKey, botToken, token that use ${VAR} syntax
  • Multi-environment setups (dev/prod) are particularly affected

Files to Review

  • src/config/config-io.tswriteConfigFile() function
  • src/config/env-vars.tsresolveConfigEnvVars(), substituteAny()
  • src/config/index.ts — Config loading pipeline

Reporter: Cosmo (via security audit)
Date: 2026-02-05
Status: OPEN

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Fields

    Priority

    None yet

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions