Skip to content

[P1][SECURITY] Dotenv injection via unescaped newlines in saveKey(): arbitrary env var injection #6

Description

@pratik-saptarshi

Summary

src/env.tssaveKey() writes user-provided API keys directly to ~/.probus/.env by string concatenation without stripping newline characters. An attacker or malicious input that embeds a literal \n or \r\n in the key value can inject arbitrary additional KEY=VALUE lines into the .env file. On next startup, loadDotenv() parses and loads those injected variables into process.env, potentially overwriting ANTHROPIC_API_KEY, PATH, or other sensitive environment variables.

Panel verdict: P1, VERIFIED, EXISTING_DEFECT. Elevated from P2 after Judge upheld Correctness Hawk's analysis.


Affected Files

File Lines Issue
src/env.ts L49–L59 saveKey() writes raw value with no newline sanitisation
src/env.ts L21–L36 parse() splits on \n — treats injected lines as real entries
src/server/routes.ts L141–L155 POST /api/keys only calls .trim(), does not strip embedded newlines

Root Cause — Code Evidence

saveKey() writes raw value (src/env.ts, lines 49–59):

export function saveKey(key: string, value: string): void {
  const dir = envDir();
  const file = envFile();
  mkdirSync(dir, { recursive: true });
  const existing = existsSync(file) ? parse(readFileSync(file, 'utf8')) : {};
  existing[key] = value;         // ← value may contain '\n'
  const body = Object.entries(existing)
    .map(([k, v]) => `${k}=${v}`) // ← newlines in v break the format
    .join('\n') + '\n';
  writeFileSync(file, body);
  // ...
}

The route only trims whitespace (src/server/routes.ts, lines 146–150):

const trimmed = (key ?? '').trim();  // trim() removes leading/trailing whitespace
// but does NOT remove embedded \n or \r\n
if (!trimmed) return res.status(400).json({ error: 'Key cannot be empty' });
try {
  const envVar = envVarForProvider(provider);
  saveKey(envVar, trimmed);   // ← trimmed may still contain internal newlines

Injection payload example:

POST /api/keys
{ "provider": "anthropic", "key": "sk-real-key\nPATH=/tmp/evil:/Users/neo/.gemini/antigravity/bin:/Users/neo/Library/Application Support/Antigravity/bin:/Users/neo/.bun/bin:/Users/neo/.antigravity/antigravity/bin:/Users/neo/.opencode/bin:/opt/zerobrew/bin:/Users/neo/.zerobrew/bin:/usr/local/bin:/System/Cryptexes/App/usr/bin:/usr/bin:/bin:/usr/sbin:/sbin:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/local/bin:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/bin:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/appleinternal/bin:/Users/neo/.local/bin:/Users/neo/.local/bin\nANTHROPIC_API_KEY=stolen" }

Resulting ~/.probus/.env file:

ANTHROPIC_API_KEY=sk-real-key
PATH=/tmp/evil:/Users/neo/.gemini/antigravity/bin:/Users/neo/Library/Application Support/Antigravity/bin:/Users/neo/.bun/bin:/Users/neo/.antigravity/antigravity/bin:/Users/neo/.opencode/bin:/opt/zerobrew/bin:/Users/neo/.zerobrew/bin:/usr/local/bin:/System/Cryptexes/App/usr/bin:/usr/bin:/bin:/usr/sbin:/sbin:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/local/bin:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/bin:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/appleinternal/bin:/Users/neo/.local/bin:/Users/neo/.local/bin
ANTHROPIC_API_KEY=stolen

On next loadDotenv() call, PATH and ANTHROPIC_API_KEY are overridden with attacker-controlled values.


Impact

  • Credential theft: Overwrite ANTHROPIC_API_KEY or OPENAI_API_KEY with a stolen key, redirecting all API calls to an attacker-controlled server.
  • PATH hijacking: Insert an attacker-controlled directory at the front of PATH, causing Probus to run malicious binaries (e.g. a fake gh CLI).
  • Environment poisoning: Any env var can be injected/overwritten, affecting child processes including Claude Code agents.

Steps to Reproduce

  1. Start Probus: npm run dev
  2. Send a malicious key:
curl -X POST http://127.0.0.1:9090/api/keys \
  -H 'Content-Type: application/json' \
  -d '{"provider": "anthropic", "key": "sk-test\nEVIL_VAR=injected"}'
  1. Inspect ~/.probus/.env — observe the injected line.
  2. Restart Probus and check process.env.EVIL_VAR — it will be injected.

Remediation

Step 1 — Strip all newline and carriage return characters in saveKey()

export function saveKey(key: string, value: string): void {
  // Sanitize: remove all newline, CR, and null characters from both key and value
  const safeKey = key.replace(/[\r\n\0]/g, '');
  const safeValue = value.replace(/[\r\n\0]/g, '');

  if (!safeKey) throw new Error('Key name cannot be empty after sanitization');

  const dir = envDir();
  const file = envFile();
  mkdirSync(dir, { recursive: true });
  const existing = existsSync(file) ? parse(readFileSync(file, 'utf8')) : {};
  existing[safeKey] = safeValue;
  const body = Object.entries(existing).map(([k, v]) => `${k}=${v}`).join('\n') + '\n';
  writeFileSync(file, body);
  try { chmodSync(file, 0o600); } catch { /* ignore */ }
  process.env[safeKey] = safeValue;
}

Step 2 — Quote values in the .env file

// Wrap values in double quotes and escape internal quotes:
const body = Object.entries(existing)
  .map(([k, v]) => `${k}="${v.replace(/"/g, '\\"')}"`)
  .join('\n') + '\n';

Step 3 — Add API key format validation in the route

// src/server/routes.ts — after trimming
if (!/^[\x20-\x7E]+$/.test(trimmed)) {   // printable ASCII only
  return res.status(400).json({ error: 'Key contains invalid characters' });
}

References

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions