Skip to content

[security] Remote MCP headers templates read arbitrary process.env — the stdio allowlist from #208 is not applied on the HTTP path #215

Description

@uos1231234

[security] Remote MCP headers templates read arbitrary process.env — the stdio allowlist from #208 is not applied on the HTTP path

Summary

expandHeaderTemplate resolves ${VAR} in a plugin's declared MCP headers by reading process.env[VAR] with no allowlist. The result is attached to the outbound HTTP request as http_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 main at 519e4de with the repository's own runner, using canary values so nothing real is read.

import { test, expect } from "vitest";
import { expandHeaderTemplate } from "../src/step/mcp.ts";
import { DEFAULT_INHERITED_ENV_VARS } from "@modelcontextprotocol/sdk/client/stdio.js";

test("header templates expand any env var", () => {
  const canaries = [
    "AWS_SECRET_ACCESS_KEY", "AWS_SESSION_TOKEN", "GITHUB_TOKEN",
    "OPENAI_API_KEY", "ANTHROPIC_API_KEY", "DB_PASSWORD",
    "SCA_TOTALLY_UNRELATED_VAR",
  ];
  for (const name of canaries) process.env[name] = `CANARY-${name}`;

  for (const name of canaries) {
    console.log(`\${${name}} -> ${JSON.stringify(expandHeaderTemplate(`\${${name}}`))}`);
  }
  console.log("stdio allowlist =", JSON.stringify(DEFAULT_INHERITED_ENV_VARS));

  expect(expandHeaderTemplate("${AWS_SECRET_ACCESS_KEY}")).toBe("CANARY-AWS_SECRET_ACCESS_KEY");
  expect(DEFAULT_INHERITED_ENV_VARS).not.toContain("AWS_SECRET_ACCESS_KEY");
});

Observed output:

${AWS_SECRET_ACCESS_KEY} -> "CANARY-AWS_SECRET_ACCESS_KEY"
${AWS_SESSION_TOKEN} -> "CANARY-AWS_SESSION_TOKEN"
${GITHUB_TOKEN} -> "CANARY-GITHUB_TOKEN"
${OPENAI_API_KEY} -> "CANARY-OPENAI_API_KEY"
${ANTHROPIC_API_KEY} -> "CANARY-ANTHROPIC_API_KEY"
${DB_PASSWORD} -> "CANARY-DB_PASSWORD"
${SCA_TOTALLY_UNRELATED_VAR} -> "CANARY-SCA_TOTALLY_UNRELATED_VAR"
stdio allowlist = ["APPDATA","HOMEDRIVE","HOMEPATH","LOCALAPPDATA","PATH","PROCESSOR_ARCHITECTURE","SYSTEMDRIVE","SYSTEMROOT","TEMP","USERNAME","USERPROFILE","PROGRAMFILES"]

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]; inside expandHeaderTemplate, with no filtering of name.
  • packages/coding-agent/src/step/mcp.ts:476-486 — applyPluginHeaderAliases calls it for every string entry in a manifest's headers, then stores the expanded value on the declaration.
  • packages/coding-agent/src/step/mcp.ts:368-370 — discoverStepMcpServers applies this to every plugin mcpServers entry, so the path is reachable from any installed plugin.
  • Contrast: packages/coding-agent/src/step/mcp-environment.ts restricts the stdio child environment to DEFAULT_INHERITED_ENV_VARS (the change from fix(mcp): restrict stdio server environment #208).

Attribution

expandHeaderTemplate predates #208 — commit 0903f1f only 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 expandHeaderTemplate rather than to the transport, so both paths share one rule. Two reasonable options:

  • restrict ${VAR} interpolation to DEFAULT_INHERITED_ENV_VARS, or
  • require the manifest to declare which variables it may reference and validate name against that declaration.

The first is the smaller change and matches what #208 already established for stdio.

Verification performed

  • Repository: stepfun-ai/Step-Code, main at 519e4de.
  • Reproduction run with the repo's pinned vitest (4.1.9) on Node 24.19.0, Windows.
  • Canary environment variables only; no real secret was read or transmitted, and no network request was made.
  • No repository files were modified.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions