pdf-server: robust path validation + folder/file root support (#497)

* pdf-server: support folders in cli + files in roots

* fix(pdf-server): use path.relative for robust ancestor dir matching

- Replace string prefix matching (startsWith) with path.relative-based
  isAncestorDir() which correctly handles trailing slashes, path
  normalization, and prevents both .. traversal and prefix attacks.
- Use path.resolve consistently when adding paths to allowedLocalFiles
  and allowedLocalDirs (main.ts was missing this).
- Use resolved path for exact file match check in validateUrl.
- Improve error message formatting for easier debugging.
- Add tests for trailing slashes, grandparent dirs, and isAncestorDir.

* chore(pdf-server): remove JSONL interaction logging

Remove the withLogging transport wrapper, logInteraction helper,
InteractionLogger type, and all log parameter threading that were
added for debugging.

* fix(pdf-server): support computer:// URL scheme for local files

Some clients (e.g. Claude Code) use computer:// instead of file://
for local file references. Handle both schemes in isFileUrl and
fileUrlToPath.

* fix(pdf-server): add debug logging for rejected files, ignore .jsonl logs

* fix(pdf-server): accept bare file paths in addition to file:// URLs

Clients like Claude Desktop may pass raw absolute paths (e.g.
/Users/.../file.pdf) instead of file:// URLs. Handle these in
validateUrl and readPdfRange.

* debug(pdf-server): add hex char diagnostics for path mismatch

* fix(pdf-server): resolve symlinks to handle sandbox path remapping

Use fs.realpathSync to resolve symlinks/bind mounts when comparing
file paths against allowed directories. This fixes the path namespace
mismatch where Claude Desktop sends container paths (e.g. /sessions/...)
while MCP roots use host paths (e.g. /Users/...).

Both the file path and directory paths are resolved through symlinks
before comparison, so either side can be a symlink.

Also stores realpath of roots in allowedLocalDirs/Files so matching
works in both directions.

Removes verbose hex diagnostics (no longer needed).

* fix(pdf-server): decode percent-encoded bare paths (e.g. %20 for spaces)

Clients may send bare file paths with percent-encoded characters
(e.g., Application%20Support). Apply decodeURIComponent to bare paths
before resolving, matching the behavior already used for file:// URLs.

* fix(pdf-server): add full diagnostics to client-facing error message

Include url, resolved path, realpath, allowedFiles, and per-directory
path.relative results in the error returned to the client, so we can
diagnose why isAncestorDir fails when paths look identical.

* debug(pdf-server): add hex dump to diagnose invisible char differences

path.relative returns 10 levels of .. despite paths looking identical,
meaning they differ at the byte level. Add hex dump of first 30 chars
of both paths to catch invisible Unicode or encoding differences.

* fix(pdf-server): use inode comparison for bind-mount path matching

The cowork VM remaps paths via bind mounts: the tool input uses
VM-internal paths (/sessions/...) while MCP roots use host paths
(/Users/...). Since path.relative can't match across bind mounts,
compare directory inodes instead (dev+ino from fs.statSync).

Walks up the file's parent directories checking each against allowed
dirs by inode, which works regardless of path namespace differences.

* fix(pdf-server): resolve VM-internal paths via directory basename matching

When the MCP server runs on the host but receives unrewritten VM paths
(e.g., /sessions/name/mnt/uploads/file.pdf), the tool call url argument
isn't rewritten by the cowork VM proxy because it doesn't know which
arguments are file paths.

Fix: when path validation fails, try to match the file by finding the
directory basename (e.g., 'uploads') in the VM path and looking for
the relative path suffix under each allowed directory on the host.

Also returns the resolved host path so readPdfRange reads the correct
file on the host filesystem.

* revert(pdf-server): remove resolveVmPath hack

The VM path mismatch (VM-internal paths not rewritten in tool call
arguments) should be fixed in the cowork VM proxy layer, not worked
around in the MCP server.

Removes:
- resolveVmPath() function
- resolvedPath field from validateUrl return type
- effectiveUrl remapping in tool handlers
- VM path resolution test

* nit
This commit is contained in:
Olivier Chafik 2026-02-24 18:23:57 +00:00 • committed by GitHub
commit 281a26b021
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 264 additions and 30 deletions

View file

@ -1 +1,2 @@
dist/
*.jsonl

View file

@ -7,6 +7,9 @@ src/
# Tests
*.test.*
# Debug logs
*.jsonl
# Development assets
screenshot.png
grid-cell.png

View file

@ -5,6 +5,7 @@
*/
import fs from "node:fs";
import path from "node:path";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js";
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
@ -20,6 +21,7 @@ import {
fileUrlToPath,
allowedLocalFiles,
DEFAULT_PDF,
allowedLocalDirs,
} from "./server.js";
/**
@ -120,10 +122,16 @@ async function main() {
// Register local files in whitelist
for (const url of urls) {
if (isFileUrl(url)) {
const filePath = fileUrlToPath(url);
const filePath = path.resolve(fileUrlToPath(url));
if (fs.existsSync(filePath)) {
allowedLocalFiles.add(filePath);
console.error(`[pdf-server] Registered local file: ${filePath}`);
const s = fs.statSync(filePath);
if (s.isFile()) {
allowedLocalFiles.add(filePath);
console.error(`[pdf-server] Registered local file: ${filePath}`);
} else if (s.isDirectory()) {
allowedLocalDirs.add(filePath);
console.error(`[pdf-server] Registered local directory: ${filePath}`);
}
} else {
console.error(`[pdf-server] Warning: File not found: ${filePath}`);
}

View file

@ -3,6 +3,7 @@ import path from "node:path";
import {
createPdfCache,
validateUrl,
isAncestorDir,
allowedLocalFiles,
allowedLocalDirs,
pathToFileUrl,
@ -271,4 +272,152 @@ describe("validateUrl with MCP roots (allowedLocalDirs)", () => {
expect(result.valid).toBe(false);
expect(result.error).toContain("File not found");
});
it("should allow a file under an allowed dir with trailing slash", () => {
const dir = path.resolve(import.meta.dirname);
// Simulate a dir stored with a trailing slash (e.g. from CLI path)
allowedLocalDirs.add(dir + "/");
const filePath = path.join(dir, "server.ts");
const result = validateUrl(pathToFileUrl(filePath));
expect(result.valid).toBe(true);
});
it("should allow a file under a grandparent allowed dir", () => {
// Allow a directory two levels up from the file
const grandparent = path.resolve(path.join(import.meta.dirname, ".."));
allowedLocalDirs.add(grandparent);
const filePath = path.join(import.meta.dirname, "server.ts");
const result = validateUrl(pathToFileUrl(filePath));
expect(result.valid).toBe(true);
});
it("should accept computer:// URLs as local files", () => {
const dir = path.resolve(import.meta.dirname);
allowedLocalDirs.add(dir);
const filePath = path.join(dir, "server.ts");
const encoded = encodeURIComponent(filePath).replace(/%2F/g, "/");
const result = validateUrl(`computer://${encoded}`);
expect(result.valid).toBe(true);
});
it("should accept bare absolute paths as local files", () => {
const dir = path.resolve(import.meta.dirname);
allowedLocalDirs.add(dir);
const filePath = path.join(dir, "server.ts");
const result = validateUrl(filePath);
expect(result.valid).toBe(true);
});
it("should decode percent-encoded bare paths (e.g. %20 for spaces)", () => {
const fs = require("node:fs");
const os = require("node:os");
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "pdf test "));
const testFile = path.join(tmpDir, "file.txt");
try {
fs.writeFileSync(testFile, "hello");
allowedLocalDirs.add(tmpDir);
// Encode spaces as %20 in the path (as some clients do)
const encoded = testFile.replace(/ /g, "%20");
const result = validateUrl(encoded);
expect(result.valid).toBe(true);
} finally {
fs.rmSync(tmpDir, { recursive: true });
}
});
it("should allow file accessed via symlink when real dir is allowed", () => {
const fs = require("node:fs");
const os = require("node:os");
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "pdf-test-"));
const realDir = path.join(tmpDir, "real");
const linkDir = path.join(tmpDir, "link");
const testFile = path.join(realDir, "test.txt");
try {
fs.mkdirSync(realDir);
fs.writeFileSync(testFile, "hello");
fs.symlinkSync(realDir, linkDir);
// Allow the REAL directory
allowedLocalDirs.add(realDir);
// Access via the SYMLINK path — should still be allowed
const symlinkPath = path.join(linkDir, "test.txt");
const result = validateUrl(symlinkPath);
expect(result.valid).toBe(true);
} finally {
fs.rmSync(tmpDir, { recursive: true });
}
});
it("should allow file when allowed dir is a symlink to real parent", () => {
const fs = require("node:fs");
const os = require("node:os");
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "pdf-test-"));
const realDir = path.join(tmpDir, "real");
const linkDir = path.join(tmpDir, "link");
const testFile = path.join(realDir, "test.txt");
try {
fs.mkdirSync(realDir);
fs.writeFileSync(testFile, "hello");
fs.symlinkSync(realDir, linkDir);
// Allow the SYMLINK directory
allowedLocalDirs.add(linkDir);
// Access via the REAL path — should still be allowed
const result = validateUrl(testFile);
expect(result.valid).toBe(true);
} finally {
fs.rmSync(tmpDir, { recursive: true });
}
});
});
describe("isAncestorDir", () => {
it("should return true for a direct child", () => {
expect(isAncestorDir("/Users/test/dir", "/Users/test/dir/file.pdf")).toBe(
true,
);
});
it("should return true for a nested child", () => {
expect(isAncestorDir("/Users/test", "/Users/test/sub/dir/file.pdf")).toBe(
true,
);
});
it("should return false for a file outside the dir", () => {
expect(isAncestorDir("/Users/test/dir", "/Users/test/other/file.pdf")).toBe(
false,
);
});
it("should return false for the dir itself", () => {
expect(isAncestorDir("/Users/test/dir", "/Users/test/dir")).toBe(false);
});
it("should prevent .. traversal", () => {
expect(
isAncestorDir("/Users/test/dir", "/Users/test/dir/../other/file.pdf"),
).toBe(false);
});
it("should prevent prefix-based traversal", () => {
// /tmp/safe should NOT match /tmp/safevil/file.pdf
expect(isAncestorDir("/tmp/safe", "/tmp/safevil/file.pdf")).toBe(false);
});
it("should handle dirs with trailing slash", () => {
expect(isAncestorDir("/Users/test/dir/", "/Users/test/dir/file.pdf")).toBe(
true,
);
});
});

View file

@ -60,7 +60,7 @@ const DIST_DIR = import.meta.filename.endsWith(".ts")
// =============================================================================
export function isFileUrl(url: string): boolean {
return url.startsWith("file://");
return url.startsWith("file://") || url.startsWith("computer://");
}
export function isArxivUrl(url: string): boolean {
@ -81,7 +81,8 @@ export function normalizeArxivUrl(url: string): string {
}
export function fileUrlToPath(fileUrl: string): string {
return decodeURIComponent(fileUrl.replace("file://", ""));
// Support both file:// and computer:// (used by some clients for local files)
return decodeURIComponent(fileUrl.replace(/^(?:file|computer):\/\//, ""));
}
export function pathToFileUrl(filePath: string): string {
@ -89,29 +90,86 @@ export function pathToFileUrl(filePath: string): string {
return `file://${encodeURIComponent(absolutePath).replace(/%2F/g, "/")}`;
}
export function validateUrl(url: string): { valid: boolean; error?: string } {
if (isFileUrl(url)) {
const filePath = fileUrlToPath(url);
/**
* Check if `dir` is an ancestor of `filePath` using path.relative,
* which is more robust than string prefix matching (handles normalization).
*/
export function isAncestorDir(dir: string, filePath: string): boolean {
const rel = path.relative(dir, filePath);
// Must be non-empty (not the dir itself when checking files),
// must not start with ".." (escaping), and must not be absolute (different root).
return rel !== "" && !rel.startsWith("..") && !path.isAbsolute(rel);
}
/**
* Check if `url` looks like an absolute local file path (not a URL scheme).
* Handles Unix paths (/...), home-relative (~), and Windows drive letters (C:\...).
*/
function isLocalPath(url: string): boolean {
return (
url.startsWith("/") || url.startsWith("~") || /^[A-Za-z]:[/\\]/.test(url)
);
}
export function validateUrl(url: string): {
valid: boolean;
error?: string;
} {
if (isFileUrl(url) || isLocalPath(url)) {
// fileUrlToPath already decodes percent-encoding; for bare paths,
// decode here in case the client sends %20 for spaces etc.
const filePath = isFileUrl(url)
? fileUrlToPath(url)
: decodeURIComponent(url);
const resolved = path.resolve(filePath);
// Check exact match (CLI args)
const exactMatch = allowedLocalFiles.has(filePath);
// Check exact match (CLI args / roots)
if (allowedLocalFiles.has(resolved)) {
if (!fs.existsSync(resolved)) {
return { valid: false, error: `File not found: ${resolved}` };
}
return { valid: true };
}
// Check directory match (MCP roots)
const dirMatch = [...allowedLocalDirs].some(
(dir) => resolved === dir || resolved.startsWith(dir + path.sep),
// Check directory match (MCP roots / CLI dirs).
// Try both the raw path and its realpath (resolves symlinks).
let realResolved: string | undefined;
try {
realResolved = fs.realpathSync(resolved);
} catch {
// File may not exist yet at this path
}
if (
[...allowedLocalDirs].some((dir) => {
let realDir: string | undefined;
try {
realDir = fs.realpathSync(dir);
} catch {
// Dir may not exist
}
return (
isAncestorDir(dir, resolved) ||
(realResolved != null && isAncestorDir(dir, realResolved)) ||
(realDir != null && isAncestorDir(realDir, resolved)) ||
(realDir != null &&
realResolved != null &&
isAncestorDir(realDir, realResolved))
);
})
) {
if (!fs.existsSync(resolved)) {
return { valid: false, error: `File not found: ${resolved}` };
}
return { valid: true };
}
console.error(
`[pdf-server] Local file not in allowed list: ${resolved}\n Allowed dirs: ${[...allowedLocalDirs].join(", ")}`,
);
if (!exactMatch && !dirMatch) {
return {
valid: false,
error: `Local file not in allowed list: ${filePath}`,
};
}
if (!fs.existsSync(filePath)) {
return { valid: false, error: `File not found: ${filePath}` };
}
return { valid: true };
return {
valid: false,
error: `Local file not in allowed list: ${resolved}\nAllowed directories: ${[...allowedLocalDirs].join(", ")}`,
};
}
// Remote URL - require HTTPS
@ -238,8 +296,10 @@ export function createPdfCache(): PdfCache {
const normalized = isArxivUrl(url) ? normalizeArxivUrl(url) : url;
const clampedByteCount = Math.min(byteCount, MAX_CHUNK_BYTES);
if (isFileUrl(normalized)) {
const filePath = fileUrlToPath(normalized);
if (isFileUrl(normalized) || isLocalPath(normalized)) {
const filePath = isFileUrl(normalized)
? fileUrlToPath(normalized)
: decodeURIComponent(normalized);
const stats = await fs.promises.stat(filePath);
const totalBytes = stats.size;
@ -353,11 +413,17 @@ async function refreshRoots(server: Server): Promise<void> {
const { roots } = await server.listRoots();
allowedLocalDirs.clear();
for (const root of roots) {
if (root.uri.startsWith("file://")) {
if (isFileUrl(root.uri)) {
const dir = fileUrlToPath(root.uri);
const resolved = path.resolve(dir);
try {
if (fs.statSync(resolved).isDirectory()) {
const s = fs.statSync(resolved);
if (s.isFile()) {
console.error(
`[pdf-server] Root is a file, not a directory (skipped): ${resolved}`,
);
allowedLocalFiles.add(resolved);
} else if (s.isDirectory()) {
allowedLocalDirs.add(resolved);
console.error(`[pdf-server] Root directory allowed: ${resolved}`);
}
@ -441,7 +507,7 @@ export function createServer(): McpServer {
title: "Read PDF Bytes",
description: "Read a range of bytes from a PDF (max 512KB per request)",
inputSchema: {
url: z.string().describe("PDF URL"),
url: z.string().describe("PDF URL or local file path"),
offset: z.number().min(0).default(0).describe("Byte offset"),
byteCount: z
.number()
@ -471,7 +537,11 @@ export function createServer(): McpServer {
try {
const normalized = isArxivUrl(url) ? normalizeArxivUrl(url) : url;
const { data, totalBytes } = await readPdfRange(url, offset, byteCount);
const { data, totalBytes } = await readPdfRange(
normalized,
offset,
byteCount,
);
// Base64 encode for JSON transport
const bytes = Buffer.from(data).toString("base64");
@ -520,7 +590,10 @@ Accepts:
- Local files under directories provided by the client as MCP roots
- Any remote PDF accessible via HTTPS`,
inputSchema: {
url: z.string().default(DEFAULT_PDF).describe("PDF URL"),
url: z
.string()
.default(DEFAULT_PDF)
.describe("PDF URL or local file path"),
page: z.number().min(1).default(1).describe("Initial page"),
},
outputSchema: z.object({