In April 2026, OX Security published a supply-chain advisory showing that the Model Context Protocol's STDIO transport — the default for connecting an AI agent to a local tool — executes whatever operating-system command a downstream config declares. No sanitisation. No execution boundary between configuration and command. The behaviour propagated into every official language SDK (Python, TypeScript, Java, Rust) and every downstream project that trusted the protocol.
Estimated exposure (per OX): 200,000 servers, 150M+ downloads, 10+ high or critical CVEs, with arbitrary command execution confirmed against six platforms including LangFlow, LiteLLM, Flowise, and Upsonic. Cursor, Windsurf, Claude Code, and Gemini-CLI are vulnerable to the broader family. Windsurf received CVE-2026-30615 for the zero-click variant.
Anthropic responded that the STDIO execution model is by design and that command sanitisation is the host's responsibility. They updated SECURITY.md but made no architectural changes.
MCPlexer is exactly the kind of host the disclosure targets — every configured stdio downstream becomes an exec.CommandContext call. We treat it as our responsibility. This post is the receipt.
The four exploitation families OX identified
- Unauthenticated command injection through AI-framework web interfaces. Demonstrated against LangFlow and LiteLLM by submitting a downstream config with
command: bashandargs: ["-c", "<payload>"]. The framework happily spawned it. - Allowlist bypass in tools that restricted commands to known runners. Demonstrated against Flowise and Upsonic by passing
npx -c '<malicious bash>'— npx's-cflag forwards to a shell, so the allowlist of "only npx" was meaningless. - Zero-click prompt injection in AI coding IDEs. Malicious HTML rendered by the agent modifies its own
mcp_config.jsonto register a malicious server. Next launch, RCE. - Argument injection at execution time. Even when a runner's argv is parameterised, several SDKs concatenated the command and args into a single shell-evaluated string.
exec-style spawning closes this vector;system()/shell=Trueopens it.
Where MCPlexer was exposed
MCPlexer's downstream subprocess spawn lives in internal/downstream/instance.go:
cmd := exec.CommandContext(childCtx, cmdPath, inst.args...)
cmd.Env = inst.envGo's exec.Command uses execve rather than a shell, so the single-string concatenation family (#4) doesn't apply directly. But the command field itself (inst.command) was previously taken on trust. Anyone who could write a downstream config — via the dashboard, the YAML loader, the MCP control protocol, or addon imports — could register command: bash and own the host.
The dashboard is the obvious attack surface for #1. With the API auth gate landed in #16, every /api/v1/downstreams write requires the per-install bearer token. That closes #1 against the network. But defence-in-depth still pays:
- The API token can be exfiltrated via prompt-injection of an already-trusted MCP server.
- A YAML typo (
command: rm) shouldn't fail-open into RCE. - Addons and the MCP control protocol can register downstreams without the same human eyeballs as the dashboard.
So the API-auth gate is necessary but not sufficient. The full mitigation set:
Six concrete controls
1. Authenticated control plane
Every /api/v1/* and /api/p2p/* request requires a per-install token, supplied as an HttpOnly mcplexer_session cookie to the dashboard or as an Authorization: Bearer header to scripts. The token is generated at first start and persisted to ~/.mcplexer/api-key with mode 0600 — only the daemon's UID can read it.
/api/v1/health and /api/v1/oauth/callback are the only exceptions (liveness probes, IDP redirects).
2. Downstream command guard
Code at internal/downstream/cmdguard.go. Three layers:
// Reject shells as the command field.
shellCommandBasenames = {sh, bash, dash, zsh, ksh, ash, csh, tcsh, fish,
eval, exec, source, cmd, cmd.exe,
powershell, powershell.exe, pwsh}
// Reject shell-eval flags as arguments.
shellEvalArgs = {-c, -e, --call, --eval, --exec, --execute,
--code, --inline, -Command, -EncodedCommand}
// Reject shell metacharacters in the command string.
shellMetaChars = ";|&`$\n\r"
// Reject parent-directory traversal.
strings.Contains(command, "..")The shell allowlist defeats command: bash directly. The eval-flag block defeats OX's npx -c argument-injection bypass — and the equivalent for node -e, python -c, deno --eval, php --code=, and PowerShell -Command. The metacharacter check defeats config-string injection (command: "npx; curl evil").
Path traversal is rejected because no legitimate runner spec needs ...
3. Validate at registration AND spawn
The same guard runs in two places:
- The API write path:
internal/api/downstream_handler.gocallsValidateCommandon everyPOSTandPUTto/api/v1/downstreamsbefore persisting. - The spawn path:
internal/downstream/instance.gocallsValidateCommandimmediately beforeexec.CommandContext.
A malicious config in your YAML, your DB, your seeded data, or anywhere else can never reach exec.Command.
4. No env passthrough to subprocesses
internal/downstream/env.go strips daemon-private env vars before spawning a downstream. Specifically:
sensitiveDaemonEnvKeys = {AGE_IDENTITY, AGE_KEY, AGE_PASSPHRASE,
MCPLEXER_PROVIDER}
sensitiveDaemonEnvPrefixes = {"MCPLEXER_"}Without this strip, every npx-launched MCP server inherited MCPLEXER_AGE_KEY and could decrypt the SQLite secrets blob — the worst case for a benign-but-buggy downstream and the entire game for a malicious one.
5. Sandboxed code execution
The mcpx__execute_code tool runs JavaScript inside a Goja VM with both a recursion cap and a heap-growth watchdog (internal/codemode/sandbox.go):
const (
defaultMaxCallStack = 256
defaultMaxHeapGrowthMB = 256
defaultWatchdogPeriod = 50 * time.Millisecond
)A while(true) a.push(x) loop interrupts in tens of milliseconds — measured against the previous behaviour, which OOM'd the daemon long before the wall-clock timeout fired.
6. Approval gate without a backdoor
Self-approval (resolver session matching requester) is rejected for every approver type, including dashboard. The previous behaviour short-circuited the check for dashboard resolves, which let any caller in possession of the API token self-approve their own MCP request. That short-circuit is removed in internal/approval/manager.go. The dashboard handler now derives a stable approver-session ID from a hash of the auth token so dashboard resolves are still distinguishable from MCP sessions.
Defence in depth
These six controls don't rely on each other:
- The command guard would block a shell config even if the API token were exfiltrated.
- The env strip holds even if a downstream server is malicious.
- The auth gate keeps random local processes off the API in the first place.
- The sandbox limits keep a compromised Code Mode script from taking the daemon down.
Power users who genuinely need a shell as a downstream can set MCPLEXER_UNSAFE_DOWNSTREAM_COMMANDS=1 with informed consent — the audit trail records which configs are non-allowlisted.
What you can verify
The full hardening landed in PR #16 and the STDIO-specific guard in the same PR's last commit. To audit:
# The command guard — read it.
curl https://raw.githubusercontent.com/RevittCo/mcplexer/main/internal/downstream/cmdguard.go
# The env strip.
curl https://raw.githubusercontent.com/RevittCo/mcplexer/main/internal/downstream/env.go
# Tests covering both.
git clone https://github.com/RevittCo/mcplexer
cd mcplexer
go test ./internal/downstream/... -v -run "TestValidateCommand|TestMergeEnvStripsDaemonSecrets"Live smoke against your local install:
TOKEN=$(cat ~/.mcplexer/api-key)
# Should 400 with "downstream command bash is a shell interpreter"
curl -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"id":"x","name":"x","transport":"stdio","command":"bash","args":["-c","id"],"tool_namespace":"x"}' \
http://127.0.0.1:13333/api/v1/downstreams
# Should 400 with "downstream command argument -e lets the runner evaluate arbitrary code"
curl -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"id":"y","name":"y","transport":"stdio","command":"node","args":["-e","process.exit(0)"],"tool_namespace":"y"}' \
http://127.0.0.1:13333/api/v1/downstreamsReferences
- OX Security · MCP Supply Chain Advisory: RCE Vulnerabilities Across the AI Ecosystem
- OX Security · Critical, Systemic Vulnerability at the Core of Anthropic's MCP
- OX Security · Technical deep dive
- VentureBeat · 200,000 MCP servers expose a command execution flaw that Anthropic calls a feature
- The Hacker News · Anthropic MCP Design Vulnerability Enables RCE, Threatening AI Supply Chain
- The Register · MCP 'design flaw' puts 200k servers at risk
- Cloud Security Alliance · Lab Note: MCP by Design — RCE Across the AI Agent Ecosystem
FAQ
- Is MCPlexer affected by CVE-2026-30615?
- No. CVE-2026-30615 is a Windsurf-specific finding (zero-click via malicious HTML rewriting
mcp_config.json). MCPlexer doesn't load configs from page content; configs are sourced from the YAML file, the dashboard, the MCP control protocol, or addon definitions, all of which now flow through the command guard. - Does
MCPLEXER_UNSAFE_DOWNSTREAM_COMMANDS=1weaken security irreversibly? - It bypasses the command guard at the spawn site only. The API auth gate, env strip, sandbox limits, and approval-gate fixes remain in effect. We recommend the override only for local scripting experiments; production deployments should never set it.
- Why not block all dynamic downstream registration entirely?
- Because the value of MCPlexer comes from being able to register any MCP server you want. The threat model is "untrusted content", not "untrusted user" — the user holds the API token. The command guard lets the user register any legitimate MCP runner while blocking the specific patterns that turn a config into RCE.
- How can I report a vulnerability?
- Open a private security advisory on GitHub or email
security@revitt.co.