Skip to content

Commit 3b085b8

Browse files
committed
fix(mcp): normalize env names before secret-pattern matching
isSensitiveEnvVar substring-matched patterns like API_KEY against the raw variable name, so MCP server env overrides named API-KEY, APIKEY, or PRIVATE-KEY - all legal spellings in environment variable names - were forwarded to potentially untrusted MCP subprocesses. Only the override path was affected; the inherited-environment path is allowlist-based. Names are now normalised (uppercased, "-" and "_" stripped) before matching, and the patterns are stored in normalised form, so API-KEY/APIKEY/Api-Key/PRIVATE-KEY/SECRET-KEY/AUTH-TOKEN and similar spellings are all stripped. Regression tests extend TestBuildEnv_OverridesCannotInjectSecrets with non-underscore spellings and add TestIsSensitiveEnvVar_NormalizesSeparators covering blocked spellings and benign names. Docs updated (SECURITY.md section 19, AGENTS.md).
1 parent fc69bbe commit 3b085b8

4 files changed

Lines changed: 41 additions & 8 deletions

File tree

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -163,7 +163,7 @@ Layered prompt-injection / approval-fatigue defenses. Full reference: [docs/SECU
163163
- **Sandbox volume confinement** (`internal/sandbox/sandbox.go`) — extra `--sandbox-volume` host paths must resolve to a location under the working directory, cannot contain `..` or symlink escapes, and cannot match sensitive prefixes such as `/etc`, `/proc`, `/sys`, `/dev`, `/root`, `/home`, `/var`, `/run`, or `/var/run/docker.sock`.
164164
- **Sandbox read-only enforcement** (`cmd/odek/sandbox_file.go` + `cmd/odek/file_tool.go` + `cmd/odek/perf_tools.go`) — when a sandbox container is active, `write_file`, `patch`, and `batch_patch` translate host paths to `/workspace/...` and copy data into the container with `docker cp`, so a read-only workspace mount (`--sandbox-readonly`) is enforced for the agent's own file tools.
165165
- **Project config sensitive-field rejection** (`internal/config/loader.go`) — `./odek.json` is untrusted, so `base_url`, `api_key`, `system`, the `dangerous` section, `embedding`, `memory`, `sessions`, `skills.dirs`/`skills.embedding`, `telegram`, and `web_search` set there are ignored (with stderr warnings). Project-level sandbox knobs (`sandbox_env`, `sandbox_image`, `sandbox_network`, `sandbox_volumes`) are not silently ignored either; they are gated by the project-level sandbox approval flow before application. These can only be configured from operator-controlled sources: `~/.odek/config.json`, `ODEK_*` env vars, or CLI flags.
166-
- **MCP subprocess environment sanitisation** (`internal/mcpclient/client.go`) — MCP server children receive only a minimal allowlist of safe environment variables plus explicit `env` overrides. Keys matching secret patterns (`*_API_KEY`, `*_TOKEN`, `*_SECRET`, `*_PASSWORD`, etc.) are stripped, preventing a compromised or malicious MCP server from reading parent secrets.
166+
- **MCP subprocess environment sanitisation** (`internal/mcpclient/client.go`) — MCP server children receive only a minimal allowlist of safe environment variables plus explicit `env` overrides. Keys matching secret patterns (`*_API_KEY`, `*_TOKEN`, `*_SECRET`, `*_PASSWORD`, etc.) are stripped, preventing a compromised or malicious MCP server from reading parent secrets. Names are normalised (uppercased, `-`/`_` stripped) before matching, so non-underscore spellings such as `API-KEY` or `APIKEY` cannot slip a secret through the override path.
167167
- **MCP `tools/list` metadata hardening** (`internal/mcpclient/client.go`, `cmd/odek/mcp_approval.go`, `cmd/odek/main.go`) — tool names from MCP servers are validated (ASCII letters/digits/`-`/`_`, ≤ 64 chars) and descriptions are scanned for injection patterns. Tools whose raw names collide with odek built-ins are rejected. Each tool from a project-level server requires per-tool approval (interactive, `ODEK_APPROVE_MCP=1`, or persisted in `~/.odek/mcp_tool_approvals.json`); global servers from `~/.odek/config.json` are operator-trusted. Approval keys hash the server's `env` map (sorted key/value pairs), and the interactive prompt prints each env variable with its value, so a later `env` change cannot silently reuse an old approval.
168168
- **Read-only perf-tool file-size cap** (`cmd/odek/perf_tools.go`) — `count_lines`, `checksum`, `head_tail`, and `word_count` reject files larger than 10 MiB before scanning/hashing, consistent with other perf tools.
169169
- **Inline content size cap** (`cmd/odek/perf_tools.go`) — `base64` and `tr` reject inline `string`/`content` arguments larger than 10 MiB, preventing prompt-injected multi-hundred-megabyte payloads from OOMing the process.

docs/SECURITY.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -423,7 +423,7 @@ Go's `net.IP.IsPrivate()` covers RFC1918 and RFC4193 private ranges, but it does
423423

424424
### 19. MCP server environment sanitisation
425425

426-
MCP server subprocesses no longer inherit the full odek process environment. They receive only a minimal allowlist of safe variables (e.g. `PATH`, `HOME`, `LANG`, `TMPDIR`) plus any explicit `env` overrides from the server config. Keys matching secret patterns — `*_API_KEY`, `*_TOKEN`, `*_SECRET`, `*_PASSWORD`, `*_CREDENTIAL`, `*_PRIVATE_KEY`, etc. — are stripped even when listed in `env`. This prevents a compromised or malicious MCP server from reading secrets loaded from `~/.odek/secrets.env` or other provider keys that were present in the parent environment.
426+
MCP server subprocesses no longer inherit the full odek process environment. They receive only a minimal allowlist of safe variables (e.g. `PATH`, `HOME`, `LANG`, `TMPDIR`) plus any explicit `env` overrides from the server config. Keys matching secret patterns — `*_API_KEY`, `*_TOKEN`, `*_SECRET`, `*_PASSWORD`, `*_CREDENTIAL`, `*_PRIVATE_KEY`, etc. — are stripped even when listed in `env`. Matching normalises each name first (uppercased, `-` and `_` removed), so non-underscore spellings like `API-KEY` or `APIKEY` — both legal in environment variable names — cannot bypass the filter through the override path. This prevents a compromised or malicious MCP server from reading secrets loaded from `~/.odek/secrets.env` or other provider keys that were present in the parent environment.
427427

428428
### 20. Schedule file atomic-write hardening
429429

internal/mcpclient/client.go

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -308,13 +308,18 @@ var allowedEnvVars = map[string]bool{
308308
// isSensitiveEnvVar reports whether a key looks like a secret. These patterns
309309
// are blocked from being forwarded to MCP children even if they are present in
310310
// the parent environment or explicitly supplied as overrides.
311+
//
312+
// The name is normalised (uppercased, "-" and "_" stripped) before matching so
313+
// non-underscore spellings — API-KEY, APIKEY, Private-Key — cannot slip a
314+
// secret past the filter; environment variable names legally contain hyphens
315+
// or no separator at all.
311316
func isSensitiveEnvVar(key string) bool {
312-
upper := strings.ToUpper(key)
317+
norm := strings.NewReplacer("-", "", "_", "").Replace(strings.ToUpper(key))
313318
for _, pat := range []string{
314-
"API_KEY", "TOKEN", "SECRET", "PASSWORD", "CREDENTIAL", "CREDS",
315-
"PRIVATE_KEY", "ACCESS_KEY",
319+
"APIKEY", "TOKEN", "SECRET", "PASSWORD", "CREDENTIAL", "CREDS",
320+
"PRIVATEKEY", "ACCESSKEY",
316321
} {
317-
if strings.Contains(upper, pat) {
322+
if strings.Contains(norm, pat) {
318323
return true
319324
}
320325
}

internal/mcpclient/client_test.go

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -417,22 +417,50 @@ func TestBuildEnv_OverridesCannotInjectSecrets(t *testing.T) {
417417
defer func() { osEnviron = orig }()
418418

419419
result := buildEnv(map[string]string{
420-
"LEGIT_VAR": "ok",
420+
"LEGIT_VAR": "ok",
421421
"EVIL_API_KEY": "sk-stolen",
422422
"BOT_TOKEN": "tok-stolen",
423+
// Non-underscore spellings: env var names legally contain hyphens or
424+
// no separator, and must be caught by the normalised matching.
425+
"API-KEY": "sk-stolen",
426+
"APIKEY": "sk-stolen",
427+
"PRIVATE-KEY": "pem-stolen",
428+
"SECRET-KEY": "shh",
423429
})
424430
m := envToMap(result)
425431

426432
if m["LEGIT_VAR"] != "ok" {
427433
t.Errorf("LEGIT_VAR = %q, want ok", m["LEGIT_VAR"])
428434
}
429-
for _, k := range []string{"EVIL_API_KEY", "BOT_TOKEN"} {
435+
for _, k := range []string{"EVIL_API_KEY", "BOT_TOKEN", "API-KEY", "APIKEY", "PRIVATE-KEY", "SECRET-KEY"} {
430436
if _, ok := m[k]; ok {
431437
t.Errorf("sensitive override %q should be dropped", k)
432438
}
433439
}
434440
}
435441

442+
func TestIsSensitiveEnvVar_NormalizesSeparators(t *testing.T) {
443+
blocked := []string{
444+
"MY_API_KEY", "API-KEY", "APIKEY", "Api-Key",
445+
"PRIVATE-KEY", "PRIVATEKEY", "ACCESS-KEY", "ACCESSKEY",
446+
"SECRET-KEY", "SECRETKEY", "AUTH-TOKEN", "AUTHTOKEN",
447+
"DB-PASSWORD", "PASSWORD", "AWS-CREDS", "CREDENTIALS",
448+
}
449+
for _, k := range blocked {
450+
if !isSensitiveEnvVar(k) {
451+
t.Errorf("isSensitiveEnvVar(%q) = false, want true", k)
452+
}
453+
}
454+
allowed := []string{
455+
"PATH", "HOME", "EDITOR", "NODE_ENV", "MCP_SERVER_NAME", "KEYSTONE",
456+
}
457+
for _, k := range allowed {
458+
if isSensitiveEnvVar(k) {
459+
t.Errorf("isSensitiveEnvVar(%q) = true, want false", k)
460+
}
461+
}
462+
}
463+
436464
func envToMap(env []string) map[string]string {
437465
m := make(map[string]string, len(env))
438466
for _, e := range env {

0 commit comments

Comments
 (0)