Repository navigation
[security] Remote MCP headers templates read arbitrary process.env — the stdio allowlist from #208 is not applied on the HTTP path #215
Description
Activity
I tried to break this report and could not falsify the core claim. Three things in it are wrong or too weak, and one materially understates the exposure. Correcting all four.
1. Correction: the allowlist has 12 entries, not 11
DEFAULT_INHERITED_ENV_VARSon win32 is 12 entries (SDKstdio.js:8-22):APPDATA, HOMEDRIVE, HOMEPATH, LOCALAPPDATA, PATH, PROCESSOR_ARCHITECTURE, SYSTEMDRIVE, SYSTEMROOT, TEMP, USERNAME, USERPROFILE, PROGRAMFILES. The substantive point — none of them are credential names — is unaffected.2. Correction: this is a documented, tested feature, not a leftover from #208
I framed the asymmetry as "#208 tightened stdio and forgot the header path". The mechanics were right, the characterization was not. The interpolation is an intentional Claude-compat feature, documented in the code:
mcp.ts:466-475— "A plugin's.mcp.jsonmay writeheadersand interpolate the environment ("Bearer ${API_KEY:-}"), which is Claude's format."and covered by the repository's own end-to-end test:
mcp-startup.test.ts:241-305— "carries Claude plugin headers, including environment interpolation", assertingexpect(seen).toContain("from-env")against a real HTTP server.docs/step-unified-config-and-mcp.md§1.5 also listsbearer_token_env_var/http_headers/env_http_headersas intended env-driven header features. PR #208 = mergeec1cd22, whose stat touches onlymcp-environment.ts,mcp.test.tsandplugins.ts— notmcp.ts.The accurate framing is therefore: the HTTP header path has no allowlist of its own, while #208 established one for stdio, and the two paths were never aligned. That is a real gap, but it is a missing-allowlist gap rather than an accidental omission, and the fix should be scoped that way — tightening the shared rule, not removing the feature.
3. The exposure is larger than I reported:
${}is not requiredThere are two additional template-free paths in the same file, both reading
process.envdirectly with no allowlist. I missed them, which makes my report an undercount:env_http_headers— accepted atmcp.ts:513-518, expanded atmcp.ts:621-628viaprocess.env[envName]?.trim(). A manifest can write{"X-Plain":"AWS_SECRET_ACCESS_KEY"}and needs no$syntax at all.bearer_token_env_var—mcp.ts:629-634turns any variable name directly intoAuthorization: Bearer $VAR.
plugins.ts:310copiesmcpServerswithstructuredCloneand no per-server field validation, andreadPluginManifestAtPath(plugins.ts:350-382) also auto-adopts a sibling.mcp.json, so all three are reachable from a plain manifest.4. Closing the gap in my reproduction: wire-level evidence
My report only exercised
expandHeaderTemplateas a pure function, which was the weakest part. An independent pass built the full unmocked chain — realaddMarketplaceSource→ realinstallMarketplacePlugin→ realdiscoverStepMcpServers→ realStreamableHTTPClientTransport→ a localnode:httpserver capturing the bytes:x-aws: CANARY-AWS-LEAK-0001 x-gh: CANARY-GH-LEAK-0002 x-oai: CANARY-OAI-LEAK-0003 x-unrelated: CANARY-UNRELATED-LEAK-0004 x-plain: CANARY-PLAIN-LEAK-0005 (via env_http_headers, no ${} needed)The path from
http_headersto the wire is a straight line with no branch:mcp.ts:553resolveHttpHeaders→:554-555requestInit: { headers }→ SDKstreamableHttp.js:58-77_commonHeaders()merges them into theHeadersused on GET/SSE, POST and DELETE.One property worth stating precisely, because it is not a mitigation: an unset variable causes the header to be omitted entirely (the literal template never reaches the server), so only variables that are actually set can be exfiltrated. That is not a mitigation — the variables worth stealing are exactly the ones that are set.
Threat model, stated explicitly
This requires the user to install a plugin (or have one preinstalled via #214) and the URL is chosen by the plugin author, so it is not an unauthenticated remote issue. The property that makes it worth fixing anyway: a plugin that declares only a remote server executes no local code, so the user sees no local execution signal, while their environment is still exfiltrated. That is a different trust decision from "a stdio plugin can run arbitrary code anyway".
Closing rationale, recorded so this doesn't need re-investigating.
SECURITY.mdcovers this report in two places::70-71— "...prompt injection, or a malicious trusted extension/skill are not
security vulnerabilities under this model.":74-77— "...malicious contents written to a trusted StepCode configuration file
cause StepCode to ... send credentials to an attacker-controlled endpoint ...
is out of scope."
The reported path is a plugin manifest naming an environment variable and sending it
to the endpoint that same plugin declared, so both clauses apply.SECURITY.mdis
unchanged since this was filed.The mechanism is still present on
main(3d70bae):env_http_headers
(mcp.ts:650) andbearer_token_env_var(mcp.ts:655) readprocess.envwith no
allowlist, and the HTTP path never received the allowlist #208 added for stdio. I
filed that as an implementation-consistency gap rather than a vulnerability, since
both fields are documented and a user setting one for their own server is not a
boundary crossing.Correction
One claim above has the wrong field name. The report says
http_headerssupports
${VAR:-fallback}interpolation. It does not —bda152emadehttp_headersa
verbatim literal, with a regression test asserting exactly that. Interpolation lives
on the pluginheadersalias, which reachesexpandHeaderTemplate(mcp.ts:441,
no allowlist) before landing inhttp_headers. The mechanism is real; the field I
named for it was not the right one.No action requested.
[security] Remote MCP
headerstemplates read arbitraryprocess.env— the stdio allowlist from #208 is not applied on the HTTP pathSummary
expandHeaderTemplateresolves${VAR}in a plugin's declared MCPheadersby readingprocess.env[VAR]with no allowlist. The result is attached to the outbound HTTP request ashttp_headers.PR #208 ("fix(mcp): restrict stdio server environment") deliberately narrowed the environment handed to stdio subprocesses to
DEFAULT_INHERITED_ENV_VARS. That boundary is not applied on the HTTP path, so the same third-party manifest can read any environment variable the StepCode process holds and send it to an arbitrary remote URL.A url-type MCP server executes nothing on the user's machine, so it is reasonable for a user to assume it cannot read their environment.
Impact
A plugin that declares a
url-type MCP server can exfiltrate API keys, CI tokens and any other environment variable of the StepCode process to a server the plugin author controls. The declared tool surface looks harmless.Reproduction
Real run against
mainat519e4dewith the repository's own runner, using canary values so nothing real is read.Observed output:
Every variable expands. None of the credential-shaped names are in the stdio allowlist, which is the asymmetry this report is about.
Root cause
packages/coding-agent/src/step/mcp.ts:424—const value = name === undefined ? undefined : process.env[name];insideexpandHeaderTemplate, with no filtering ofname.packages/coding-agent/src/step/mcp.ts:476-486—applyPluginHeaderAliasescalls it for every string entry in a manifest'sheaders, then stores the expanded value on the declaration.packages/coding-agent/src/step/mcp.ts:368-370—discoverStepMcpServersapplies this to every pluginmcpServersentry, so the path is reachable from any installed plugin.packages/coding-agent/src/step/mcp-environment.tsrestricts the stdio child environment toDEFAULT_INHERITED_ENV_VARS(the change from fix(mcp): restrict stdio server environment #208).Attribution
expandHeaderTemplatepredates #208 — commit0903f1fonly moved the line. The asymmetry is that #208 tightened the stdio side without applying the same constraint to headers. This reads as an omission in #208 rather than an intentional design choice, which is why I am reporting it against the current boundary rather than asking for a behaviour change.Suggested fix
Apply an explicit allowlist to
expandHeaderTemplaterather than to the transport, so both paths share one rule. Two reasonable options:${VAR}interpolation toDEFAULT_INHERITED_ENV_VARS, ornameagainst that declaration.The first is the smaller change and matches what #208 already established for stdio.
Verification performed
stepfun-ai/Step-Code,mainat519e4de.vitest(4.1.9) on Node 24.19.0, Windows.