|
1 | 1 | """Tests for mcp_server.handlers.open_visualization — unified 3D graph launcher.""" |
2 | 2 |
|
3 | 3 | import asyncio |
| 4 | +import tempfile |
| 5 | +from pathlib import Path |
4 | 6 | from unittest.mock import patch, MagicMock |
5 | 7 |
|
6 | 8 | from mcp_server.handlers import open_visualization |
7 | | -from mcp_server.handlers.open_visualization import handler |
| 9 | +from mcp_server.handlers.open_visualization import _find_dev_source, handler |
8 | 10 |
|
9 | 11 |
|
10 | 12 | class TestOpenVisualizationSchema: |
@@ -115,3 +117,113 @@ def test_opens_browser_at_tilemap_url_unconditionally(self): |
115 | 117 | result = asyncio.run(handler({})) |
116 | 118 | mock_open.assert_called_once_with("http://localhost:5555/?viz=tilemap") |
117 | 119 | assert "tilemap" in result["message"] |
| 120 | + |
| 121 | + |
| 122 | +class TestDevSourceSecurityHardening: |
| 123 | + """Falsification tests for GHSA-gvpp-v77h-5w8g — `_find_dev_source` |
| 124 | + must not be persuadable by attacker-controllable env vars. |
| 125 | +
|
| 126 | + Each test would fail if a regression re-introduced |
| 127 | + ``CLAUDE_PROJECT_DIR`` as a candidate, or removed the explicit |
| 128 | + ``CORTEX_DEV_SOURCE_SYNC=1`` opt-in for ``CORTEX_DEV_ROOT``. The |
| 129 | + threat model is: an attacker tricks the user into opening a |
| 130 | + malicious project in Claude Code (which sets |
| 131 | + ``CLAUDE_PROJECT_DIR``); the malicious project contains the two |
| 132 | + marker files (``mcp_server/`` directory + ``ui/unified-viz.html``) |
| 133 | + that ``_is_cortex_root`` checks, plus a |
| 134 | + ``mcp_server/server/visualize_bootstrap.py`` containing arbitrary |
| 135 | + Python. When the user runs ``/cortex-visualize`` the handler used |
| 136 | + to ``subprocess.run`` that file, giving the attacker local ACE. |
| 137 | + """ |
| 138 | + |
| 139 | + @staticmethod |
| 140 | + def _plant_marker_files(root: Path) -> None: |
| 141 | + (root / "mcp_server" / "server").mkdir(parents=True, exist_ok=True) |
| 142 | + (root / "ui").mkdir(parents=True, exist_ok=True) |
| 143 | + (root / "ui" / "unified-viz.html").write_text( |
| 144 | + "<html>attacker</html>", encoding="utf-8" |
| 145 | + ) |
| 146 | + (root / "mcp_server" / "server" / "visualize_bootstrap.py").write_text( |
| 147 | + "raise RuntimeError('attacker-controlled bootstrap')\n", |
| 148 | + encoding="utf-8", |
| 149 | + ) |
| 150 | + |
| 151 | + def test_claude_project_dir_is_ignored(self, monkeypatch): |
| 152 | + # Falsifies: CLAUDE_PROJECT_DIR can drive _find_dev_source. |
| 153 | + with tempfile.TemporaryDirectory(prefix="cortex-malicious-") as td: |
| 154 | + attacker_root = Path(td) |
| 155 | + self._plant_marker_files(attacker_root) |
| 156 | + # Make sure no other env var or home-fallback satisfies |
| 157 | + # the search — otherwise the test would be vacuous. |
| 158 | + monkeypatch.delenv("CORTEX_DEV_SOURCE_SYNC", raising=False) |
| 159 | + monkeypatch.delenv("CORTEX_DEV_ROOT", raising=False) |
| 160 | + monkeypatch.setenv("CLAUDE_PROJECT_DIR", str(attacker_root)) |
| 161 | + with patch( |
| 162 | + "mcp_server.handlers.open_visualization.Path.home", |
| 163 | + return_value=attacker_root.parent, |
| 164 | + ): |
| 165 | + # parent dir contains the malicious dir but lacks |
| 166 | + # ``Documents/Developments/Cortex`` so home-fallback |
| 167 | + # cannot accidentally satisfy. |
| 168 | + result = _find_dev_source() |
| 169 | + assert result is None, ( |
| 170 | + f"CLAUDE_PROJECT_DIR should be ignored; got {result!r} — " |
| 171 | + "this would re-introduce GHSA-gvpp-v77h-5w8g." |
| 172 | + ) |
| 173 | + |
| 174 | + def test_cortex_dev_root_ignored_without_opt_in(self, monkeypatch): |
| 175 | + # Falsifies: CORTEX_DEV_ROOT is honoured without the |
| 176 | + # CORTEX_DEV_SOURCE_SYNC=1 flag. |
| 177 | + with tempfile.TemporaryDirectory(prefix="cortex-unopted-") as td: |
| 178 | + attacker_root = Path(td) |
| 179 | + self._plant_marker_files(attacker_root) |
| 180 | + monkeypatch.delenv("CORTEX_DEV_SOURCE_SYNC", raising=False) |
| 181 | + monkeypatch.setenv("CORTEX_DEV_ROOT", str(attacker_root)) |
| 182 | + monkeypatch.delenv("CLAUDE_PROJECT_DIR", raising=False) |
| 183 | + with patch( |
| 184 | + "mcp_server.handlers.open_visualization.Path.home", |
| 185 | + return_value=attacker_root.parent, |
| 186 | + ): |
| 187 | + result = _find_dev_source() |
| 188 | + assert result is None, ( |
| 189 | + f"CORTEX_DEV_ROOT honoured without the opt-in flag; got {result!r}." |
| 190 | + ) |
| 191 | + |
| 192 | + def test_cortex_dev_root_honoured_when_explicitly_opted_in(self, monkeypatch): |
| 193 | + # Falsifies: opt-in flag is broken (legitimate dev workflow |
| 194 | + # would also break). This test exists so we don't over-lock |
| 195 | + # the door and lose the intended developer affordance. |
| 196 | + with tempfile.TemporaryDirectory(prefix="cortex-dev-real-") as td: |
| 197 | + dev_root = Path(td) |
| 198 | + self._plant_marker_files(dev_root) |
| 199 | + monkeypatch.setenv("CORTEX_DEV_SOURCE_SYNC", "1") |
| 200 | + monkeypatch.setenv("CORTEX_DEV_ROOT", str(dev_root)) |
| 201 | + monkeypatch.delenv("CLAUDE_PROJECT_DIR", raising=False) |
| 202 | + with patch( |
| 203 | + "mcp_server.handlers.open_visualization.Path.home", |
| 204 | + return_value=dev_root.parent, |
| 205 | + ): |
| 206 | + result = _find_dev_source() |
| 207 | + assert result == dev_root, ( |
| 208 | + f"Opt-in path broken: expected {dev_root!r}, got {result!r}." |
| 209 | + ) |
| 210 | + |
| 211 | + def test_opt_in_flag_value_not_1_is_rejected(self, monkeypatch): |
| 212 | + # Falsifies: ANY non-empty CORTEX_DEV_SOURCE_SYNC value |
| 213 | + # activates the gate (would let an accidental "true"/"yes" |
| 214 | + # in a shell rc file pull in CORTEX_DEV_ROOT). |
| 215 | + with tempfile.TemporaryDirectory(prefix="cortex-truthy-") as td: |
| 216 | + root = Path(td) |
| 217 | + self._plant_marker_files(root) |
| 218 | + monkeypatch.setenv("CORTEX_DEV_SOURCE_SYNC", "true") |
| 219 | + monkeypatch.setenv("CORTEX_DEV_ROOT", str(root)) |
| 220 | + monkeypatch.delenv("CLAUDE_PROJECT_DIR", raising=False) |
| 221 | + with patch( |
| 222 | + "mcp_server.handlers.open_visualization.Path.home", |
| 223 | + return_value=root.parent, |
| 224 | + ): |
| 225 | + result = _find_dev_source() |
| 226 | + assert result is None, ( |
| 227 | + "Gate must require the exact string '1' to avoid " |
| 228 | + "ambiguous truthy values silently re-opening the hole." |
| 229 | + ) |
0 commit comments