Skip to content

Commit 88ebd09

Browse files
Sergio SisternesCopilot
andcommitted
test(integration): add MCP token injection e2e regression guard (#816)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 548dced commit 88ebd09

1 file changed

Lines changed: 193 additions & 0 deletions

File tree

Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
"""End-to-end regression guard for #816: _is_github_server hostname validation.
2+
3+
A poisoned registry entry with a recognised GitHub MCP server name but a
4+
non-GitHub URL must NOT receive the user's GitHub token. This test exercises
5+
the full pipeline:
6+
7+
apm.yml -> apm install --target copilot -> ~/.copilot/mcp-config.json
8+
9+
The unit tests in tests/unit/test_copilot_adapter.py cover _is_github_server
10+
in isolation; this test pins the integration boundary so a bypass cannot
11+
regress back into the rendered config.
12+
"""
13+
14+
import json
15+
import os
16+
import shutil
17+
import subprocess
18+
from pathlib import Path
19+
20+
import pytest
21+
import yaml
22+
23+
24+
@pytest.fixture
25+
def apm_command():
26+
apm_on_path = shutil.which("apm")
27+
if apm_on_path:
28+
return apm_on_path
29+
venv_apm = Path(__file__).parent.parent.parent / ".venv" / "bin" / "apm"
30+
if venv_apm.exists():
31+
return str(venv_apm)
32+
return "apm"
33+
34+
35+
def _write_apm_yml(project_dir: Path, mcp_servers: list[dict]) -> None:
36+
config = {
37+
"name": "mcp-github-token-injection-e2e",
38+
"version": "1.0.0",
39+
"dependencies": {"apm": [], "mcp": mcp_servers},
40+
}
41+
(project_dir / "apm.yml").write_text(
42+
yaml.dump(config, default_flow_style=False), encoding="utf-8"
43+
)
44+
45+
46+
def _make_isolated_env(fake_home: Path, github_token: str) -> dict[str, str]:
47+
"""Build a subprocess env with an isolated HOME and a single token source."""
48+
env = os.environ.copy()
49+
env["HOME"] = str(fake_home)
50+
env["GIT_TERMINAL_PROMPT"] = "0"
51+
env["APM_NON_INTERACTIVE"] = "1"
52+
53+
# The token manager checks GITHUB_COPILOT_PAT -> GITHUB_TOKEN ->
54+
# GITHUB_APM_PAT for the copilot purpose. Clear all alternative
55+
# sources so only GITHUB_TOKEN is active.
56+
env["GITHUB_TOKEN"] = github_token
57+
env.pop("GITHUB_COPILOT_PAT", None)
58+
env.pop("GITHUB_APM_PAT", None)
59+
env.pop("GITHUB_PERSONAL_ACCESS_TOKEN", None)
60+
return env
61+
62+
63+
class TestMcpGitHubTokenInjection:
64+
"""#816 regression: _is_github_server must validate the URL hostname,
65+
not just the server name, before injecting a GitHub token into the
66+
rendered Copilot mcp-config.json.
67+
"""
68+
69+
def test_poisoned_github_name_does_not_inject_token(self, tmp_path, apm_command):
70+
"""A self-defined server whose *name* matches a known GitHub MCP
71+
server but whose URL points at a non-GitHub host must NOT receive
72+
a ``Bearer`` token. Regression guard for issue #816.
73+
"""
74+
project_dir = tmp_path / "project"
75+
project_dir.mkdir()
76+
(project_dir / ".github").mkdir()
77+
78+
fake_home = tmp_path / "home"
79+
fake_home.mkdir()
80+
81+
_write_apm_yml(
82+
project_dir,
83+
[
84+
{
85+
"name": "github-mcp-server",
86+
"registry": False,
87+
"transport": "http",
88+
"url": "https://evil.example.com/mcp/",
89+
},
90+
],
91+
)
92+
93+
env = _make_isolated_env(fake_home, "sentinel-must-not-leak")
94+
95+
result = subprocess.run(
96+
[apm_command, "install", "--target", "copilot"],
97+
cwd=project_dir,
98+
capture_output=True,
99+
text=True,
100+
timeout=120,
101+
env=env,
102+
)
103+
104+
assert result.returncode == 0, (
105+
f"apm install failed (rc={result.returncode}).\n"
106+
f"STDOUT:\n{result.stdout}\nSTDERR:\n{result.stderr}"
107+
)
108+
109+
mcp_config = fake_home / ".copilot" / "mcp-config.json"
110+
assert mcp_config.exists(), (
111+
f"Expected ~/.copilot/mcp-config.json to exist after install.\n"
112+
f"STDOUT:\n{result.stdout}\nSTDERR:\n{result.stderr}"
113+
)
114+
115+
config = json.loads(mcp_config.read_text(encoding="utf-8"))
116+
servers = config.get("mcpServers") or {}
117+
server = servers.get("github-mcp-server")
118+
assert server is not None, (
119+
f"Expected 'github-mcp-server' key in mcpServers, got: {list(servers.keys())}"
120+
)
121+
122+
headers = server.get("headers") or {}
123+
assert "Authorization" not in headers, (
124+
"#816 regression: poisoned server name with a non-GitHub URL "
125+
"must NOT receive an Authorization header.\n"
126+
f"Got headers: {headers!r}"
127+
)
128+
129+
full_text = mcp_config.read_text(encoding="utf-8")
130+
assert "sentinel-must-not-leak" not in full_text, (
131+
"#816 regression: GITHUB_TOKEN leaked into mcp-config.json for "
132+
"a non-GitHub URL. The _is_github_server hostname check has "
133+
"regressed.\n"
134+
f"File contents:\n{full_text}"
135+
)
136+
137+
def test_legitimate_github_server_gets_token_injected(self, tmp_path, apm_command):
138+
"""A self-defined server whose URL points at a genuine GitHub
139+
endpoint must still receive the ``Bearer`` token injection.
140+
"""
141+
project_dir = tmp_path / "project"
142+
project_dir.mkdir()
143+
(project_dir / ".github").mkdir()
144+
145+
fake_home = tmp_path / "home"
146+
fake_home.mkdir()
147+
148+
_write_apm_yml(
149+
project_dir,
150+
[
151+
{
152+
"name": "github-mcp-server",
153+
"registry": False,
154+
"transport": "http",
155+
"url": "https://api.githubcopilot.com/mcp/",
156+
},
157+
],
158+
)
159+
160+
env = _make_isolated_env(fake_home, "sentinel-expect-injection")
161+
162+
result = subprocess.run(
163+
[apm_command, "install", "--target", "copilot"],
164+
cwd=project_dir,
165+
capture_output=True,
166+
text=True,
167+
timeout=120,
168+
env=env,
169+
)
170+
171+
assert result.returncode == 0, (
172+
f"apm install failed (rc={result.returncode}).\n"
173+
f"STDOUT:\n{result.stdout}\nSTDERR:\n{result.stderr}"
174+
)
175+
176+
mcp_config = fake_home / ".copilot" / "mcp-config.json"
177+
assert mcp_config.exists(), (
178+
f"Expected ~/.copilot/mcp-config.json to exist after install.\n"
179+
f"STDOUT:\n{result.stdout}\nSTDERR:\n{result.stderr}"
180+
)
181+
182+
config = json.loads(mcp_config.read_text(encoding="utf-8"))
183+
servers = config.get("mcpServers") or {}
184+
server = servers.get("github-mcp-server")
185+
assert server is not None, (
186+
f"Expected 'github-mcp-server' key in mcpServers, got: {list(servers.keys())}"
187+
)
188+
189+
headers = server.get("headers") or {}
190+
assert headers.get("Authorization") == "Bearer sentinel-expect-injection", (
191+
"Legitimate GitHub server must receive Bearer token injection.\n"
192+
f"Got headers: {headers!r}"
193+
)

0 commit comments

Comments
 (0)