sec(mcp): structure-validate .mcp.json content (AISEC-C2 Layer 2, #598) - #601
Merged
Conversation
15 tasks
vybe
force-pushed
the
feature/590-files-guardrail-bypass
branch
from
April 30, 2026 12:34
0d3060d to
534f784
Compare
Layer 2 of the AISEC-C2 closure. Layer 1 (#590) closed the RCE-by-config bypass by removing .mcp.json from the inject allowlist; this restores the legitimate post-deploy MCP server editing flow by gating .mcp.json content through structure validation. services/mcp_validator.py (NEW) - Single-file SOLID: McpValidationError + validate_mcp_config() public API - Internal class-per-transport (StdioValidator, HttpValidator, SseValidator) dispatched via _ENTRY_VALIDATORS_BY_TRANSPORT (Open-Closed: add a transport by adding a class + one map entry) - Closed schema: only mcpServers at root; only command/args/env/url/headers/ type per entry; rejects unknown fields - Stdio rules: command in allowlist {npx, uvx, python, python3, node, bun, deno, docker}; no path separators; ASCII-only (defeats Unicode homographs); args without shell metachars or command substitution; per-runtime inline- exec flag block (-c/--eval/-p/eval as first positional) - HTTP/SSE rules: HTTPS only; no userinfo (@); hostname must NOT resolve to private/loopback/link-local (SSRF guard mirroring SEC-179/#179); header name allowlist; bounded header count - Env rules: ${VAR} substring refs allowed (covers Bearer ${TOKEN} pattern); refs must match POSIX shape AND not be in RESERVED_ENV_REFS (PATH, LD_*, PYTHONPATH, TRINITY_MCP_API_KEY, ANTHROPIC_API_KEY, etc.); literal portion scanned for shell metachars + credential patterns from guardrails-baseline - Reserved server names: `trinity` (auto-injected entry) cannot be clobbered - Bounded: 64KB content, 32 servers, 64 args, 4096-char env values routers/credentials.py - Re-add .mcp.json to ALLOWED_CREDENTIAL_PATHS (path layer) - Hook validate_mcp_config() into the inject handler before agent-server proxy. McpValidationError → HTTP 400 with the validator's specific error message in detail (safe to surface — no internal paths) Frontend (CredentialsPanel.vue) - No structural changes — existing raw JSON editor works as-is and surfaces the validator's error message via err.response?.data?.detail - Updated placeholder to use context7 (real allowlisted server) instead of the now-reserved `trinity` server name Tests - tests/unit/test_mcp_validator.py (NEW, 88 tests): AISEC-C2 reproduction (/bin/sh, bash, sh) → all rejected Server name rules (reserved, invalid chars, length, traversal) Stdio: missing command, path separator, Unicode homograph, null byte, inline-exec flags per runtime, shell metachars in args Env: ${VAR} refs, partial refs, reserved names, command substitution, literal secret patterns (anthropic, github, AWS), oversized values HTTP/SSE: schemes, userinfo, IMDS/localhost/RFC1918 SSRF, headers, Unicode hostnames Realistic configs (context7, playwright, uvx) — all accepted - tests/test_mcp_validator_endpoint.py (NEW, 22 tests against live backend): AISEC-C2 still blocked (now via content validator, not path) Legit configs accepted end-to-end 11 parametrized bypass attempts → all 400 Mixed-batch atomicity (.env + evil .mcp.json rejects whole batch) .mcp.json.template stays blocked at path layer - Updated existing tests: test_credential_inject_allowlist.py: .mcp.json now in path allowlist; .mcp.json.template stays out test_files_guardrail_bypass.py: AISEC-C2 inject test asserts new content-validator error message instead of path-rejection message Mixed-batch test updated for new atomicity semantics 150 unit tests + 56 integration tests pass against live backend. 6 manual end-to-end checks confirm: AISEC-C2 → 400, npx → 200, Bearer ${TOKEN} → 200, SSRF → 400, trinity reserved → 400, .env → 200. Performance: validation is in-memory string ops + 1 DNS lookup per http/sse server (negligible). No DB, no I/O beyond what credentials/inject already does. Bypass surface explicitly guarded: - absolute path commands (/usr/bin/npx) - backslash separators - Unicode homographs in command names - null bytes in command/args - shell metacharacters anywhere - command substitution ($() and backticks) - inline-exec flags per runtime - partial ${VAR} smuggling past reserved-name check - reserved env var references (PATH, LD_PRELOAD, etc.) - IMDS / localhost / RFC1918 SSRF via http/sse url - HTTP downgrade (https-only) - userinfo URL smuggling - Unicode hostnames - header smuggling via non-allowlisted names - closed schema (no future MCP spec field surprises) - 64KB content cap, 32 servers max NOT a complete fix (honest about limits): even with the runtime allowlist, `npx <evil-package>` still runs attacker code via npm. Layer 2 blocks shell-injection patterns and the AISEC-C2 reproduction; Layer 3 (sandbox MCP execution + OAuth token isolation) is a separate threat-model fix. Stacked on PR #599 (#590 Layer 1). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
vybe
force-pushed
the
feature/598-mcp-server-config
branch
from
April 30, 2026 12:36
5dbd921 to
0b37206
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Layer 2 of the AISEC-C2 closure. Layer 1 (#599) closed the RCE-by-config bypass by removing
.mcp.jsonfrom the inject allowlist; this PR restores the legitimate post-deploy MCP server editing flow by gating.mcp.jsoncontent through structure validation.Stacked PR. Base =
feature/590-files-guardrail-bypass(#599). When #599 merges todev, this PR auto-rebases.Architecture (SOLID at appropriate scale)
services/mcp_validator.pyis a single ~600-line module with public API:Internals follow Open-Closed via class-per-transport dispatch:
Adding a new transport = add a class + one map entry. No edits elsewhere. Refactor to a package only if it grows past ~500 lines (currently 600 with comments — borderline; will split if a 4th transport lands).
What's validated
mcpServersat root; onlycommand/args/env/url/headers/typeper entry; rejects unknown fields^[a-zA-Z0-9_-]{1,64}$;trinityreserved (auto-injected entry){npx, uvx, python, python3, node, bun, deno, docker}; no path separators; ASCII-only (defeats Unicode homographs); no null bytes;&|<>\$\n\r\x00); no command substitution ($(…)or backticks); no inline-exec flags as first positional (-c/--eval/-p/eval` per runtime)@); hostname must NOT resolve to private/loopback/link-local (SSRF guard mirroring SEC-179/#179){authorization, x-api-key, user-agent, accept, content-type}${VAR}substring refs allowed (coversBearer ${TOKEN}real-world pattern); refs must match POSIX shape AND not be inRESERVED_ENV_REFS(PATH, LD_PRELOAD, PYTHONPATH, TRINITY_MCP_API_KEY, ANTHROPIC_API_KEY, …); literal portion scanned for shell metachars + credential patterns fromguardrails-baseline.jsonBypass surface explicitly guarded
15 bypass vectors covered by parametrized tests:
/usr/bin/npx)npx\\evil)nрxwith Cyrillic 'р')$()and backticks)python -c,node -e,bun --eval,deno eval)${VAR}smuggling past reserved-name checkHonest about limits
Even with the runtime allowlist,
npx <evil-package>still runs attacker code via npm. Layer 2 blocks shell-injection patterns and the AISEC-C2 reproduction; Layer 3 (sandbox MCP execution + OAuth token isolation) is a separate threat-model fix.Test results
tests/unit/test_mcp_validator.py)tests/test_mcp_validator_endpoint.py)Manual checks against running agent on dev:
/bin/shpayloadInvalid .mcp.json: Server 'e': command must be a name, not a path (got '/bin/sh')npx context7configsuccessBearer ${API_TOKEN}header patternsuccesshttps://localhost:8080/mcp... resolves to a private/loopback/link-local address (SSRF guard)trinityserver nameMCP server name 'trinity' is reserved by Trinity.envQuick InjectsuccessWhat's NOT in this PR
CredentialsPanel.vueworks as-is and surfaces validator errors viaerr.response?.data?.detail). The placeholder was updated to a real allowlisted server pattern./api/credentials/updateflow")..mcp.jsonfile IS the state, validated at the API boundary. (Same pattern as Kubernetes admission, IAM policies, GitHub Actions schemas, systemd unit drop-ins.)Test plan
pytest tests/unit/test_mcp_validator.py tests/unit/test_credential_inject_allowlist.py tests/unit/test_files_protected_paths.py -v)pytest tests/test_files_guardrail_bypass.py tests/test_mcp_validator_endpoint.py -v).env+.mcp.jsonatomic semantics verified.mcp.json→ save valid config (should work)Closes #598
🤖 Generated with Claude Code