Skip to content

Commit 5d22091

Browse files
cdeustclaude
andauthored
fix(security): GHSA-gvpp-v77h-5w8g — untrusted dev-source ACE (#47)
* fix(security): GHSA-gvpp-v77h-5w8g — untrusted dev-source ACE The open_visualization tool resolved candidate Cortex dev-source roots via the CLAUDE_PROJECT_DIR env var (set automatically by Claude Code to whichever project the user has open). A candidate qualified iff it contained a trivial two-marker pair — an mcp_server/ subdirectory and a ui/unified-viz.html file — that any attacker can replicate in a malicious repository. The handler then subprocess.run([sys.executable, str(bootstrap_path)]) against mcp_server/server/visualize_bootstrap.py inside the qualifying directory, yielding local arbitrary code execution with the privileges of the victim's user process. A secondary code-execution path went through http_launcher._detect_dev_ source which mirrored the same env-var ordering; the launcher rsyncs the returned dev source over the package path and respawns the visualization server from the synced copy. Reported by EQSTLab (SK Shieldus security research) on 2026-05-27. CVSS v3.1 base 7.8 (HIGH). Vulnerable range: ≤ 3.17.0. Hardening (per advisory recommendation): * CLAUDE_PROJECT_DIR is no longer consulted by either function. * CORTEX_DEV_ROOT is consulted only when the user has also set CORTEX_DEV_SOURCE_SYNC=1 — an explicit opt-in that signals "I deliberately want my CORTEX_DEV_ROOT to be used as a code-execution dev source." * The exact string "1" is required to activate the gate (so an accidental "true"/"yes" in a shell rc file cannot silently re-open the hole). * Launcher's filesystem-position auto-detect ("walk up from the launcher module's own location") is preserved — an attacker would need write access to the user's site-packages to abuse it, which is a strictly higher-privilege precondition than the exploit it would yield. * Conventional ~/Documents/Developments/Cortex fallback is preserved — user-controlled filesystem; attacker would already need write access under $HOME to abuse it. Falsification tests added in tests_py/handlers/test_open_visualization .py::TestDevSourceSecurityHardening and tests_py/server/test_http_ launcher_security.py::TestDetectDevSourceSecurityHardening — 7 tests, all green. Each test plants the two marker files plus the bootstrap PoC into a tempdir, points the relevant env var at it, and asserts the function refuses to return it. Version bumped to 3.17.1. source: GHSA-gvpp-v77h-5w8g EQSTLab, "Untrusted Project Bootstrap Code Execution via CLAUDE_PROJECT_DIR" (2026-05-27). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(lint): drop unused os/pytest imports from security tests CI lint job flagged F401 on the added security regression tests in PR #47. Both imports were leftovers from an earlier iteration that used monkeypatch-as-fixture imperatively; the final tests use the pytest-injected monkeypatch fixture and tempfile, neither of which requires the os or pytest top-level imports. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 46b42d7 commit 5d22091

6 files changed

Lines changed: 362 additions & 20 deletions

File tree

mcp_server/handlers/open_visualization.py

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,34 @@ def _find_dev_source() -> Path | None:
6060
duplicated here so this handler stays usable even when it's loaded
6161
from an older plugin-cache snapshot whose launcher lacks the
6262
auto-detect extension.
63+
64+
Security gating (GHSA-gvpp-v77h-5w8g, EQSTLab 2026-05-27): the
65+
return value of this function is consumed by ``handler()`` to
66+
locate a ``visualize_bootstrap.py`` that is then ``subprocess.run``
67+
against the local Python interpreter. Any directory we return is
68+
therefore a code-execution surface, so candidate sources must NOT
69+
be attacker-controllable.
70+
71+
Previous implementation accepted ``CLAUDE_PROJECT_DIR`` (set
72+
automatically by Claude Code to whatever project the user opens)
73+
as a candidate, validated by a two-marker-file check
74+
(``mcp_server/`` directory + ``ui/unified-viz.html``) that any
75+
attacker can trivially replicate. That allowed local arbitrary
76+
code execution by enticing the user to open an attacker-crafted
77+
project and run ``/cortex-visualize``.
78+
79+
Hardening:
80+
* ``CLAUDE_PROJECT_DIR`` is no longer consulted.
81+
* ``CORTEX_DEV_ROOT`` is consulted only when the user has also
82+
set ``CORTEX_DEV_SOURCE_SYNC=1`` — an explicit opt-in flag
83+
that signals "I deliberately want my CORTEX_DEV_ROOT to be
84+
used as a code-execution dev source." Without the flag,
85+
``CORTEX_DEV_ROOT`` (which an attacker could in principle
86+
plant in a shell rc file) is ignored.
87+
* The conventional ``~/Documents/Developments/Cortex`` fallback
88+
remains — that path is controlled by the user's own filesystem
89+
and an attacker who can already write under ``$HOME`` has
90+
higher-privilege code execution by other means.
6391
"""
6492

6593
def _is_cortex_root(p: Path) -> bool:
@@ -70,10 +98,12 @@ def _is_cortex_root(p: Path) -> bool:
7098
)
7199

72100
candidates: list[Path] = []
73-
for env in ("CORTEX_DEV_ROOT", "CLAUDE_PROJECT_DIR"):
74-
v = os.environ.get(env)
101+
# Explicit dev-source opt-in (see security gating in docstring).
102+
if os.environ.get("CORTEX_DEV_SOURCE_SYNC") == "1":
103+
v = os.environ.get("CORTEX_DEV_ROOT")
75104
if v:
76105
candidates.append(Path(v))
106+
# Conventional home-directory checkout — user-controlled, safe.
77107
candidates.append(Path.home() / "Documents" / "Developments" / "Cortex")
78108
for c in candidates:
79109
if _is_cortex_root(c):

mcp_server/server/http_launcher.py

Lines changed: 31 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -51,16 +51,31 @@ def _kill_port(port: int) -> None:
5151
def _detect_dev_source() -> Path | None:
5252
"""Return a dev-checkout source root if one is visible.
5353
54-
Detection order:
55-
1. ``CORTEX_DEV_ROOT`` env var — explicit override.
56-
2. ``CLAUDE_PROJECT_DIR`` env var — Claude Code sets this when
57-
the user is working inside a project directory.
58-
3. The file the launcher module was loaded from, if it's inside
59-
a Cortex source tree (auto-detect for dev mode).
60-
4. The conventional checkout location
61-
``$HOME/Documents/Developments/Cortex`` — falls back here so
62-
the MCP itself syncs on every ``cortex-visualize`` call with
63-
no env-var configuration.
54+
Detection order (after the GHSA-gvpp-v77h-5w8g hardening):
55+
1. ``CORTEX_DEV_ROOT`` env var, **only when** the user has also
56+
set ``CORTEX_DEV_SOURCE_SYNC=1`` as an explicit opt-in. The
57+
flag signals "I deliberately want my CORTEX_DEV_ROOT to be
58+
used as a code-execution dev source"; without it the env
59+
var is ignored.
60+
2. The file the launcher module was loaded from, if it's inside
61+
a Cortex source tree (auto-detect for ``pip install -e .``
62+
/ ``uv run`` dev mode). This is filesystem-position-based:
63+
the attacker would have to place the launcher module itself
64+
inside their malicious project to influence it, which
65+
requires write access to the user's site-packages and is
66+
therefore higher-privileged than the exploit it would yield.
67+
3. The conventional checkout location
68+
``$HOME/Documents/Developments/Cortex`` — controlled by the
69+
user's own filesystem.
70+
71+
``CLAUDE_PROJECT_DIR`` (set automatically by Claude Code to
72+
whatever project the user has open) is **NOT** consulted: per
73+
EQSTLab's 2026-05-27 advisory, that path is attacker-controllable
74+
via social-engineering ("open this repo to reproduce the bug")
75+
and combined with the two-marker ``_is_cortex_root`` check it
76+
constituted a local arbitrary-code-execution surface (the
77+
returned dev source is passed to ``rsync`` and then the
78+
visualization server respawns from the synced copy).
6479
6580
A directory qualifies only if it contains both ``mcp_server/`` and
6681
``ui/unified-viz.html``. When a dev source is returned
@@ -77,17 +92,19 @@ def _is_cortex_root(p: Path) -> bool:
7792
)
7893

7994
candidates: list[Path] = []
80-
for env in ("CORTEX_DEV_ROOT", "CLAUDE_PROJECT_DIR"):
81-
v = os.environ.get(env)
95+
# Explicit dev-source opt-in (see security gating in docstring).
96+
if os.environ.get("CORTEX_DEV_SOURCE_SYNC") == "1":
97+
v = os.environ.get("CORTEX_DEV_ROOT")
8298
if v:
8399
candidates.append(Path(v))
84100
# Walk up from this module to see if we're loaded out of a source
85-
# checkout (for ``uv run`` / ``pip install -e`` dev mode).
101+
# checkout (for ``uv run`` / ``pip install -e`` dev mode). Position
102+
# of the module on disk; not attacker-controllable through env.
86103
here = Path(__file__).resolve()
87104
for ancestor in list(here.parents)[:6]:
88105
candidates.append(ancestor)
89106
# Conventional location — the MCP plugin auto-syncs from here even
90-
# when no env var is set.
107+
# when no env var is set. User-controlled filesystem path.
91108
candidates.append(Path.home() / "Documents" / "Developments" / "Cortex")
92109

93110
for c in candidates:

mcp_server/server/visualize_bootstrap.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,8 +43,19 @@ def _is_cortex_root(p: Path) -> bool:
4343

4444

4545
def _find_dev_source() -> Path | None:
46-
for env in ("CORTEX_DEV_ROOT", "CLAUDE_PROJECT_DIR"):
47-
v = os.environ.get(env)
46+
"""Locate the dev source. See GHSA-gvpp-v77h-5w8g gating in
47+
``mcp_server/handlers/open_visualization._find_dev_source`` — the
48+
bootstrap script inherits the parent process environment, so any
49+
untrusted env var consulted here would re-open the same hole the
50+
handler closes.
51+
52+
``CLAUDE_PROJECT_DIR`` is therefore NOT consulted. ``CORTEX_DEV_ROOT``
53+
requires the explicit ``CORTEX_DEV_SOURCE_SYNC=1`` opt-in flag
54+
(exact value ``"1"``). The ``~/Documents/Developments/Cortex``
55+
fallback is preserved (user-controlled filesystem).
56+
"""
57+
if os.environ.get("CORTEX_DEV_SOURCE_SYNC") == "1":
58+
v = os.environ.get("CORTEX_DEV_ROOT")
4859
if v and _is_cortex_root(Path(v)):
4960
return Path(v)
5061
default = Path.home() / "Documents" / "Developments" / "Cortex"

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "neuro-cortex-memory"
7-
version = "3.17.0"
7+
version = "3.17.1"
88
description = "Scientifically-grounded memory system based on computational neuroscience research"
99
readme = "README.md"
1010
license = "MIT"

tests_py/handlers/test_open_visualization.py

Lines changed: 113 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
"""Tests for mcp_server.handlers.open_visualization — unified 3D graph launcher."""
22

33
import asyncio
4+
import tempfile
5+
from pathlib import Path
46
from unittest.mock import patch, MagicMock
57

68
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
810

911

1012
class TestOpenVisualizationSchema:
@@ -115,3 +117,113 @@ def test_opens_browser_at_tilemap_url_unconditionally(self):
115117
result = asyncio.run(handler({}))
116118
mock_open.assert_called_once_with("http://localhost:5555/?viz=tilemap")
117119
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

Comments
 (0)