Skip to content

sec(mcp): structure-validate .mcp.json content (AISEC-C2 Layer 2, #598) - #601

Merged
vybe merged 1 commit into
devfrom
feature/598-mcp-server-config
Apr 30, 2026
Merged

sec(mcp): structure-validate .mcp.json content (AISEC-C2 Layer 2, #598)#601
vybe merged 1 commit into
devfrom
feature/598-mcp-server-config

Conversation

@pavshulin

Copy link
Copy Markdown
Contributor

Summary

Layer 2 of the AISEC-C2 closure. Layer 1 (#599) closed the RCE-by-config bypass by removing .mcp.json from the inject allowlist; this PR restores the legitimate post-deploy MCP server editing flow by gating .mcp.json content through structure validation.

Stacked PR. Base = feature/590-files-guardrail-bypass (#599). When #599 merges to dev, this PR auto-rebases.

Architecture (SOLID at appropriate scale)

services/mcp_validator.py is a single ~600-line module with public API:

validate_mcp_config(content: str) -> None    # raises McpValidationError on rejection
class McpValidationError(ValueError)           # router translates to HTTP 400

Internals follow Open-Closed via class-per-transport dispatch:

validate_mcp_config()
  └─ _validate_servers_dict()
        └─ _ENTRY_VALIDATORS_BY_TRANSPORT[transport].validate(name, server)
              ├─ _StdioValidator     command + args + env
              ├─ _HttpValidator      url + headers + env (+ SSRF)
              └─ _SseValidator       subclass of _HttpValidator

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

Layer Rule
Schema Closed: only mcpServers at root; only command/args/env/url/headers/type per entry; rejects unknown fields
Bounds 64KB content cap, 32 servers, 64 args, 4096-char env values, 16 headers
Server name ^[a-zA-Z0-9_-]{1,64}$; trinity reserved (auto-injected entry)
Stdio command Allowlist: {npx, uvx, python, python3, node, bun, deno, docker}; no path separators; ASCII-only (defeats Unicode homographs); no null bytes
Stdio args No shell metacharacters (;&|<>\$\n\r\x00); no command substitution ($(…) or backticks); no inline-exec flags as first positional (-c/--eval/-p/eval` per runtime)
HTTP/SSE url HTTPS only; no userinfo (@); hostname must NOT resolve to private/loopback/link-local (SSRF guard mirroring SEC-179/#179)
HTTP/SSE headers Name allowlist: {authorization, x-api-key, user-agent, accept, content-type}
Env values ${VAR} substring refs allowed (covers Bearer ${TOKEN} real-world pattern); refs must match POSIX shape AND not be in RESERVED_ENV_REFS (PATH, LD_PRELOAD, PYTHONPATH, TRINITY_MCP_API_KEY, ANTHROPIC_API_KEY, …); literal portion scanned for shell metachars + credential patterns from guardrails-baseline.json

Bypass surface explicitly guarded

15 bypass vectors covered by parametrized tests:

  1. Path-disguised commands (/usr/bin/npx)
  2. Backslash separators (npx\\evil)
  3. Unicode homographs (nрx with Cyrillic 'р')
  4. Null bytes in command/args
  5. Shell metacharacters anywhere
  6. Command substitution ($() and backticks)
  7. Inline-exec flags per runtime (python -c, node -e, bun --eval, deno eval)
  8. Partial ${VAR} smuggling past reserved-name check
  9. Reserved env var references (PATH, LD_PRELOAD, etc.)
  10. IMDS/localhost/RFC1918 SSRF via http/sse url
  11. HTTP downgrade (https-only enforcement)
  12. Userinfo URL smuggling
  13. Unicode hostnames
  14. Header smuggling via non-allowlisted names
  15. Closed schema (no future MCP spec field surprises)

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.

Test results

Layer Count Status
Unit (tests/unit/test_mcp_validator.py) 88 ✅ all pass
Unit (other refreshed) 62 ✅ all pass
Integration (tests/test_mcp_validator_endpoint.py) 22 ✅ 18 pass, 4 fixture-flake skips
Manual end-to-end 6 ✅ all 6 pass against live backend

Manual checks against running agent on dev:

Test Expected Actual
AISEC-C2 /bin/sh payload 400 Invalid .mcp.json: Server 'e': command must be a name, not a path (got '/bin/sh')
Legit npx context7 config 200 success
Bearer ${API_TOKEN} header pattern 200 success
SSRF https://localhost:8080/mcp 400 ... resolves to a private/loopback/link-local address (SSRF guard)
Reserved trinity server name 400 MCP server name 'trinity' is reserved by Trinity
Regression .env Quick Inject 200 success

What's NOT in this PR

  • Structured form UI (the existing raw JSON editor in CredentialsPanel.vue works as-is and surfaces validator errors via err.response?.data?.detail). The placeholder was updated to a real allowlisted server pattern.
  • A new endpoint (none needed — issue feat: structured MCP-server config endpoint (Layer 2 follow-up to #590) #598 explicitly says "writes via the platform-internal /api/credentials/update flow").
  • New SQLite tables, migrations, or merge logic. The .mcp.json file IS the state, validated at the API boundary. (Same pattern as Kubernetes admission, IAM policies, GitHub Actions schemas, systemd unit drop-ins.)

Test plan

  • 150 unit tests pass (pytest tests/unit/test_mcp_validator.py tests/unit/test_credential_inject_allowlist.py tests/unit/test_files_protected_paths.py -v)
  • 56 integration tests pass against live backend (pytest tests/test_files_guardrail_bypass.py tests/test_mcp_validator_endpoint.py -v)
  • AISEC-C2 exact reproduction returns 400 end-to-end
  • Real-world MCP configs (npx, uvx, Bearer ${TOKEN} headers) accepted end-to-end
  • 11 parametrized bypass attempts return 400 end-to-end
  • Mixed-batch .env + .mcp.json atomic semantics verified
  • Manual: open Agent Detail → Credentials → edit .mcp.json → save valid config (should work)
  • Manual: open same editor → paste AISEC-C2 payload → save (should show validator error in toast)

Closes #598

🤖 Generated with Claude Code

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
vybe changed the base branch from feature/590-files-guardrail-bypass to dev April 30, 2026 12:36
@vybe
vybe force-pushed the feature/598-mcp-server-config branch from 5dbd921 to 0b37206 Compare April 30, 2026 12:36

@vybe vybe left a comment

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.

LGTM — Layer 2 of the AISEC-C2 fix. 88 unit + 22 integration tests, 15 bypass vectors explicitly covered, honest about limits (Layer 3 still needed). Rebased and retargeted to dev after #599 squash-merged.

@vybe
vybe merged commit b474520 into dev Apr 30, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants