fix(tools): allowlist command/args/env in manage_mcp 'add' to prevent RCE #438
Closed
ErnestHysa wants to merge 2 commits from
ErnestHysa/fix/manage-mcp-rce-allowlist into main
pull from: ErnestHysa/fix/manage-mcp-rce-allowlist
merge into: sleepy:main
sleepy:main
sleepy:dev
sleepy:fix/effective-mode-undefined
sleepy:fix/async-generator-500
sleepy:refactor/split-small-routes-779
sleepy:refactor/split-session-routes-778
sleepy:refactor/split-llm-core-700
sleepy:refactor/split-chat-routes-724
sleepy:refactor/split-model-routes-701
sleepy:refactor/split-skills-routes-777
sleepy:refactor/split-document-routes-769
sleepy:refactor/split-ai-interaction-722
sleepy:refactor/split-cookbook-routes-775
sleepy:refactor/split-builtin-actions-723
sleepy:refactor/split-task-scheduler-774
sleepy:refactor/tool-execution-split-667
sleepy:fix/rag-health-coordinator-756
sleepy:fix/agent-core-missing-round-loop
sleepy:fix/rag-health-check-756
sleepy:fix/missing-readmes-782
sleepy:fix/search-files-split-772
sleepy:fix/dedup-search-impl-771
sleepy:fix/mcp-integrations-split-781
sleepy:fix/cookbook-helpers-split-785
sleepy:fix/search-cross-feature-776
sleepy:fix/graceful-llm-degradation-770
sleepy:fix/dedup-research-handler-773
sleepy:fix/cross-route-coupling-780
sleepy:fix/skills-typed-request-762
sleepy:fix/chat-stream-pydantic-731
sleepy:fix/chat-handler-imports-737
sleepy:fix/public-llm-exports-730
sleepy:fix/dedup-search-content-784
sleepy:fix/dedup-stream-parse-735
sleepy:fix/stream-error-boundary-734
sleepy:fix/typed-event-names-786
sleepy:fix/dedup-js-utilities-792
sleepy:fix/skills-index-lookup-761
sleepy:fix/xss-charname-innerhtml-788
sleepy:fix/coderunner-document-write-789
sleepy:fix/ai-interaction-thread-safety-729
sleepy:fix/client-side-api-key-regex-793
sleepy:fix/dead-loadingtext-796
sleepy:fix/active-streams-eviction-736
sleepy:fix/builtin-actions-sqlite-orm-732
sleepy:fix/llm-core-thread-safety-709
sleepy:fix/singleton-thread-safety-768
sleepy:fix/video-upload-multimodal-826
sleepy:fix/mic-button-audio-825
sleepy:fix/multimodal-capabilities-827
sleepy:fix/dedup-system-msg-717
sleepy:fix/service-dead-calls-750
sleepy:fix/db-session-context-manager-757
sleepy:fix/dedup-llmconfig-710
sleepy:fix/keyword-lists-764-v2
sleepy:fix/keyword-lists-764
sleepy:fix/llm-cache-ttl-708
sleepy:fix/validate-config-711
sleepy:fix/totp-constant-time-689
sleepy:fix/admin-tools-constant-681
sleepy:fix/dedup-truncate-670
sleepy:fix/threadsafe-extractions-audit-754
sleepy:fix/dedup-json-io-767
sleepy:fix/695-deduplicate-admin-check
sleepy:fix/upload-auth-dedup-765
sleepy:fix/705-deduplicate-network-constants
sleepy:fix/deduplicate-chunking-749
sleepy:fix/760-deduplicate-embed
sleepy:fix/dedup-validation-759
sleepy:fix/748-deduplicate-tokenization
sleepy:fix/add-readmes-728-742-782
sleepy:fix/key-file-permissions-706
sleepy:fix/embedding-retry-766
sleepy:fix/deterministic-doc-ids-753
sleepy:fix/search-facade-783
sleepy:fix/remove-dead-memory-746
sleepy:fix/remove-rag-manager-747
sleepy:fix/dead-docstring-763
sleepy:refactor/split-tool-schemas-666
sleepy:refactor/split-agent-loop-664
sleepy:refactor/split-tool-implementations-665
sleepy:fix/mark-stopped-500-663
sleepy:fix/streamingtts-scope-662
sleepy:fix/code-block-tool-parsing-661
No reviewers
Labels
Clear labels
area:chat
area:core
area:llm
area:routes
area:tools
bug
Something isn't working
documentation
Improvements or additions to documentation
duplicate
This issue or pull request already exists
enhancement
New feature or request
good first issue
Good for newcomers
help wanted
Extra attention is needed
invalid
This doesn't seem right
question
Further information is requested
refactor
wontfix
This will not be worked on
No labels
area:chat
area:core
area:llm
area:routes
area:tools
bug
documentation
duplicate
enhancement
good first issue
help wanted
invalid
question
refactor
wontfix
Milestone
Clear milestone
No items
No milestone
Projects
Clear projects
No items
No project
Assignees
Clear assignees
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".
No due date set.
Dependencies
No dependencies set.
Reference
sleepy/odysseus!438
Loading…
Reference in a new issue
No description provided.
Delete branch "ErnestHysa/fix/manage-mcp-rce-allowlist"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
do_manage_mcp(content, owner)accepted a user-controllablecommand,args, andenvfrom the LLM/UI and passed them verbatim tomcp_manager.connect_server, which spawned the subprocess with no allowlist. A model — or a prompt-injection payload smuggled into a skill description, a memory entry, a fetched web page, or an email body — could register a stdio MCP server that ran arbitrary commands as the app's UID.The minimal attack:
This would spawn
sh -c "id>/tmp/pwn", persist the server in the DB, and re-fire on every reconnect — and the chat tool layer would happily return{"exit_code": 0}because the call was a successful tool invocation. The DB write happens before the connect attempt, so even a connect failure leaves the malicious row around.This is the same bug class as the previous
fix(cookbook): avoid pip --user in venvwork (PR #363) — a missing input-validation gate at a tool entry point. The MCP gate is a tighter version of the same idea, scoped to a single tool surface.Why it matters
manage_mcpis reachable from:manage_mcptool);McpServerrows persist across restarts and the spawned subprocess gets reconnected on every server boot, so a one-time successfuladdis a permanent backdoor. There is no per-call sandboxing inmcp_manager._connect_stdio(it just buildsStdioServerParameters(command=…, args=…, env={**os.environ, **env})and hands it tostdio_client).os.environis also merged in verbatim, so a permissiveenvcould ship aPATHoverride pointing the child at an attacker-controlled directory or aLD_PRELOADlibrary — turning the spawned interpreter into a loader-side hijack regardless of which interpreter is used.What I changed
Two changes, both in
src/tool_implementations.py, plus a regression test.1. New validator:
_validate_mcp_command(command, cmd_args, env) -> Optional[str]Returns
Noneif the spawn target is safe, else a string error message. Enforces:commandis a non-empty string.commandis either an allowlisted basename (python3,python,node,deno,bun,uv,uvx,pipx,npm,npx,pnpm,yarn) OR an absolute path that resolves to a regular file inside the project root. Absolute paths are checked bypathlib.Path.resolve().relative_to(project_root)—is_relative_to-style containment, not juststartswith, so/tmp/evil.shand/usr/local/bin/whateverare both rejected. The project root is the parent of thesrc/directory (i.e. the odysseus repo), derived from__file__at import time.argsis a flat list/tuple of strings.-c,-e,--eval,-r,/c, plus their=…forms). These turn the interpreter into a one-shot shell — exactly the RCE we are closing. The check is only enforced on interpreter-style commands; for project-relative scripts (e.g.mcp_servers/memory_server.py) the caller already controls the path so the script itself is the trust boundary.envis a flatdict[str, str](no nesting, no non-string values).envdoes not contain any of a frozen-set of forbidden keys:LD_PRELOAD,LD_LIBRARY_PATH,LD_AUDIT,PATH,PYTHONPATH,PYTHONSTARTUP,PYTHONHOME,NODE_PATH,NODE_OPTIONS,NPM_CONFIG_GLOBALCONFIG,DYLD_INSERT_LIBRARIES,DYLD_LIBRARY_PATH. Allowing any of these would let a poisoned env entry redirect the interpreter's module search, preload a shared library, or change which executablebashresolves to.2. Wire the validator into the
addbranchThe
addbranch now calls_validate_mcp_command(command, cmd_args, env)immediately after the field extraction, before the DB write and beforemcp_manager.connect_server. On failure: returns{"error": validation_err, "exit_code": 1}and logs the rejection at WARNING with the offendingname. No row is written, no subprocess is spawned.The
delete,enable,disable,reconnect, andlistbranches are unchanged. None of them spawn a new subprocess; they only operate on existingMcpServerrows byserver_id, which is admin-only.Verification
python -c "import ast; ast.parse(open('src/tool_implementations.py').read())"→OK.pytest tests/test_manage_mcp_allowlist.py -v→ 18/18 pass.sh,bash -c,python3 -c,node --eval,/tmp/evil.sh,/usr/local/bin/some-binary,LD_PRELOADin env,PATHin env,PYTHONPATHin env,Nonearg, integer env value, empty command, whitespace-only command.python3 mcp_servers/memory_server.py,uv run mcp_servers/rag_server.py,npx -y @modelcontextprotocol/server-filesystem, absolute path inside project root, env with safe keys (DEBUG,LOG_LEVEL,MCP_NAME).Out of scope / things I deliberately did not change
do_manage_mcpboundary, not inmcp_manager._connect_stdio. Adding it at both layers would be defense-in-depth, but the boundary is the right place for a trust decision (the tool surface) — the manager is a thin wrapper and should stay that way. I considered moving the check into_connect_stdioas well, but a malformed call fromconnect_all_enabled(the built-in MCP list at startup, which reads from the DB) would be a different bug class: the DB write is the issue, and that's already gated above.python3 mcp_servers/memory_server.pybecausemcp_servers/memory_server.pyresolves inside the project root. It does not constrain what the spawned script does — that's the same level of trust as today'smanage_mcp 'add', but now the path is on the allowlist. A user-suppliedcommandof an absolute path to a script they own is fine;command="sh"is not.manage_skills(LLM-skill loader) path. That's a separate surface and was covered by the prior PR-#354 work; if it needs an allowlist it should be a separate change.python3,node,deno,bun,uv,uvx,pipx,npm,npx,pnpm,yarn). If someone wants to addrubyorgothey extend the frozenset; the change is one line. Better than the alternative, which is a regex allowlist that misses obscure-but-real bypasses.env={**os.environ, **env}merge inmcp_manager._connect_stdio. It is still permissive — a legitimate child needs the inherited env (PATH, HOME, LANG, etc.). The new validator blocks the env keys that override the loaders, not the wholesale inheritance.Notes for the reviewer
src/tool_implementations.py(+119 lines, all in one region: the new constant block + new validator + the one-call insertion at theaddbranch). One new test file:tests/test_manage_mcp_allowlist.py(+126 lines).do_manage_mcpreturn {"error": ..., "exit_code": 1}contract is preserved. The LLM sees a clear "args contain a shell-escape flag" message and can self-correct.HTTPException(400, ...)instead, butdo_manage_mcpis a tool handler, not a route — the surrounding framework expects the dict return shape. Stick with the existing convention.python3+ paths undermcp_servers/), the change is a 3-line edit to_MCP_ALLOWED_COMMANDSand one extrapathlib.relative_tocheck. Happy to follow up.audit loggingfor the allowed cases (only the rejected ones get logged at WARNING). If the maintainer wants every successfuladdto land in the assistant log, that's a one-liner with the existinglog_to_assistanthelper.Closes the "manage_mcp RCE" finding from the recent audit pass on the project.
This is a real issue and the direction is right, but I can't merge this PR as-is. The regression tests hardcode a local path (), so they won't run in CI or other checkouts. I also want the command/package-runner allowlist tightened in a smaller follow-up so we don't accidentally allow a package runner shape that still executes arbitrary code. Leaving open for a cleaned-up revision.
This is a real issue and the direction is right, but I cannot merge this PR as-is. The regression tests hardcode a local /Users/... checkout path, so they will not run in CI or other checkouts. I also want the command/package-runner allowlist tightened in a smaller follow-up so we do not accidentally allow a package runner shape that still executes arbitrary code. Leaving open for a cleaned-up revision.
Good catch on both points, thanks.
Fixed in the latest push:
Hardcoded paths — the test now resolves the project root relative to the test file itself (
os.path.dirname(__file__)), so it works in any checkout and in CI. No more/Users/ernest/...anywhere.Package-runner allowlist — removed npx, uvx, pipx, npm, pnpm, and yarn from the allowlist entirely. They can all download and execute arbitrary remote packages, which is exactly the RCE surface we're trying to close. If a package is needed, it should be installed into the project venv and invoked through the interpreter or a project-local script. Also added a URL-scheme check on args (http://, https://, ftp://, file://) so deno/bun/node can't bypass via remote script URLs.
Added regression tests for all the new rejection cases (npx, pipx, uvx, yarn, remote URLs).
Pull request closed