ext-apps/examples/pdf-server/server.test.ts
Olivier Chafik 30f79b9e3f
ignore client roots by default in pdf-server (#510)
* security: ignore client roots by default in pdf-server

When the pdf-server is started with --stdio, MCP clients may advertise
roots that refer to directories on the *client's* file system. Because
the server resolves those paths locally, accepting them by default would
give the remote client access to arbitrary directories on the server's
machine.

This commit makes client roots opt-in via the --use-client-roots flag.
Without the flag, the server logs a notice and skips roots setup
entirely. The createServer() function now accepts a CreateServerOptions
object with a `useClientRoots` boolean (defaults to false).

https://claude.ai/code/session_014ohk5NMEPe8TBKpqp4ZRSw

* Enable client roots by default for HTTP, keep off for stdio

HTTP mode serves a local client so roots are safe. Stdio mode may
have a remote client whose roots would resolve against the server's
filesystem, so roots stay off unless --use-client-roots is passed.

https://claude.ai/code/session_014ohk5NMEPe8TBKpqp4ZRSw

* fix: correct transport-aware defaults for useClientRoots

stdio = local client (e.g. Claude Desktop) → auto-enable roots
HTTP = remote client → ignore roots unless --use-client-roots passed

The previous commit had the logic inverted.

https://claude.ai/code/session_014ohk5NMEPe8TBKpqp4ZRSw

* refactor: inline useClientRoots per transport branch

Remove the intermediate effectiveUseClientRoots variable — just pass
`true` in the stdio branch and the flag value in the HTTP branch.

https://claude.ai/code/session_014ohk5NMEPe8TBKpqp4ZRSw

* docs: update README to match transport-aware client roots defaults

Stdio always enables roots (client is local); HTTP ignores them by
default. The previous README text had these reversed.

https://claude.ai/code/session_014ohk5NMEPe8TBKpqp4ZRSw

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-02-28 00:42:11 +00:00

448 lines
14 KiB
TypeScript

import { describe, it, expect, beforeEach, afterEach, spyOn } from "bun:test";
import path from "node:path";
import {
createPdfCache,
createServer,
validateUrl,
isAncestorDir,
allowedLocalFiles,
allowedLocalDirs,
pathToFileUrl,
CACHE_INACTIVITY_TIMEOUT_MS,
CACHE_MAX_LIFETIME_MS,
CACHE_MAX_PDF_SIZE_BYTES,
type PdfCache,
} from "./server";
describe("PDF Cache with Timeouts", () => {
let pdfCache: PdfCache;
beforeEach(() => {
// Each test gets its own session-local cache
pdfCache = createPdfCache();
});
afterEach(() => {
pdfCache.clearCache();
});
describe("cache configuration", () => {
it("should have 10 second inactivity timeout", () => {
expect(CACHE_INACTIVITY_TIMEOUT_MS).toBe(10_000);
});
it("should have 60 second max lifetime timeout", () => {
expect(CACHE_MAX_LIFETIME_MS).toBe(60_000);
});
it("should have 50MB max PDF size limit", () => {
expect(CACHE_MAX_PDF_SIZE_BYTES).toBe(50 * 1024 * 1024);
});
});
describe("cache management", () => {
it("should start with empty cache", () => {
expect(pdfCache.getCacheSize()).toBe(0);
});
it("should clear all entries", () => {
pdfCache.clearCache();
expect(pdfCache.getCacheSize()).toBe(0);
});
it("should isolate caches between sessions", () => {
// Create two independent cache instances
const cache1 = createPdfCache();
const cache2 = createPdfCache();
// They should be independent (both start empty)
expect(cache1.getCacheSize()).toBe(0);
expect(cache2.getCacheSize()).toBe(0);
});
});
describe("readPdfRange caching behavior", () => {
const testUrl = "https://arxiv.org/pdf/test-pdf";
const testData = new Uint8Array([0x25, 0x50, 0x44, 0x46]); // %PDF header
it("should cache full body when server returns HTTP 200", async () => {
// Mock fetch to return HTTP 200 (full body, no range support)
const mockFetch = spyOn(globalThis, "fetch").mockResolvedValueOnce(
new Response(testData, {
status: 200,
headers: { "Content-Type": "application/pdf" },
}),
);
try {
// First request - should fetch and cache
const result1 = await pdfCache.readPdfRange(testUrl, 0, 1024);
expect(result1.data).toEqual(testData);
expect(result1.totalBytes).toBe(testData.length);
expect(pdfCache.getCacheSize()).toBe(1);
// Second request - should serve from cache (no new fetch)
const result2 = await pdfCache.readPdfRange(testUrl, 0, 1024);
expect(result2.data).toEqual(testData);
expect(mockFetch).toHaveBeenCalledTimes(1); // Only one fetch call
} finally {
mockFetch.mockRestore();
}
});
it("should not cache when server returns HTTP 206 (range supported)", async () => {
const chunkData = new Uint8Array([0x25, 0x50]); // First 2 bytes
const mockFetch = spyOn(globalThis, "fetch").mockResolvedValue(
new Response(chunkData, {
status: 206,
headers: {
"Content-Type": "application/pdf",
"Content-Range": "bytes 0-1/100",
},
}),
);
try {
await pdfCache.readPdfRange(testUrl, 0, 2);
expect(pdfCache.getCacheSize()).toBe(0); // Not cached when 206
} finally {
mockFetch.mockRestore();
}
});
it("should slice cached data for subsequent range requests", async () => {
const fullData = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);
const mockFetch = spyOn(globalThis, "fetch").mockResolvedValueOnce(
new Response(fullData, { status: 200 }),
);
try {
// First request caches full body
await pdfCache.readPdfRange(testUrl, 0, 1024);
expect(pdfCache.getCacheSize()).toBe(1);
// Subsequent request gets slice from cache
const result = await pdfCache.readPdfRange(testUrl, 2, 3);
expect(result.data).toEqual(new Uint8Array([3, 4, 5]));
expect(result.totalBytes).toBe(10);
expect(mockFetch).toHaveBeenCalledTimes(1);
} finally {
mockFetch.mockRestore();
}
});
it("should fall back to GET when server returns 501 for Range request", async () => {
const fullData = new Uint8Array([0x25, 0x50, 0x44, 0x46, 0x2d]); // %PDF-
const mockFetch = spyOn(globalThis, "fetch")
// First call: Range request returns 501
.mockResolvedValueOnce(
new Response("Unsupported client Range", { status: 501 }),
)
// Second call: plain GET returns full body
.mockResolvedValueOnce(
new Response(fullData, {
status: 200,
headers: { "Content-Type": "application/pdf" },
}),
);
try {
const result = await pdfCache.readPdfRange(testUrl, 0, 1024);
expect(result.data).toEqual(fullData);
expect(result.totalBytes).toBe(fullData.length);
expect(pdfCache.getCacheSize()).toBe(1);
expect(mockFetch).toHaveBeenCalledTimes(2);
} finally {
mockFetch.mockRestore();
}
});
it("should reject PDFs larger than max size limit", async () => {
const hugeUrl = "https://arxiv.org/pdf/huge-pdf";
// Create data larger than the limit
const hugeData = new Uint8Array(CACHE_MAX_PDF_SIZE_BYTES + 1);
const mockFetch = spyOn(globalThis, "fetch").mockResolvedValueOnce(
new Response(hugeData, {
status: 200,
headers: { "Content-Type": "application/pdf" },
}),
);
try {
await expect(pdfCache.readPdfRange(hugeUrl, 0, 1024)).rejects.toThrow(
/PDF too large to cache/,
);
expect(pdfCache.getCacheSize()).toBe(0); // Should not be cached
} finally {
mockFetch.mockRestore();
}
});
it("should reject when Content-Length header exceeds limit", async () => {
const headerUrl = "https://arxiv.org/pdf/huge-pdf-header";
const smallData = new Uint8Array([1, 2, 3, 4]);
const mockFetch = spyOn(globalThis, "fetch").mockResolvedValueOnce(
new Response(smallData, {
status: 200,
headers: {
"Content-Type": "application/pdf",
"Content-Length": String(CACHE_MAX_PDF_SIZE_BYTES + 1),
},
}),
);
try {
await expect(pdfCache.readPdfRange(headerUrl, 0, 1024)).rejects.toThrow(
/PDF too large to cache/,
);
expect(pdfCache.getCacheSize()).toBe(0);
} finally {
mockFetch.mockRestore();
}
});
});
// Note: Timer-based tests (inactivity/max lifetime) would require
// using fake timers which can be complex with async code.
// The timeout behavior is straightforward and can be verified
// through manual testing or E2E tests.
});
describe("validateUrl with MCP roots (allowedLocalDirs)", () => {
const savedFiles = new Set(allowedLocalFiles);
const savedDirs = new Set(allowedLocalDirs);
beforeEach(() => {
allowedLocalFiles.clear();
allowedLocalDirs.clear();
});
afterEach(() => {
allowedLocalFiles.clear();
allowedLocalDirs.clear();
for (const f of savedFiles) allowedLocalFiles.add(f);
for (const d of savedDirs) allowedLocalDirs.add(d);
});
it("should allow a file under an allowed directory", () => {
// Use a real existing directory+file for the existsSync check
const dir = path.resolve(import.meta.dirname);
allowedLocalDirs.add(dir);
const filePath = path.join(dir, "server.ts");
const result = validateUrl(pathToFileUrl(filePath));
expect(result.valid).toBe(true);
});
it("should reject a file outside allowed directories", () => {
allowedLocalDirs.add("/some/allowed/dir");
const result = validateUrl("file:///other/dir/test.pdf");
expect(result.valid).toBe(false);
expect(result.error).toContain("not in allowed list");
});
it("should prevent prefix-based directory traversal", () => {
// /tmp/safe should NOT allow /tmp/safevil/file.pdf
allowedLocalDirs.add("/tmp/safe");
const result = validateUrl("file:///tmp/safevil/file.pdf");
expect(result.valid).toBe(false);
});
it("should still allow exact file matches from allowedLocalFiles", () => {
const filePath = path.resolve(import.meta.dirname, "server.ts");
allowedLocalFiles.add(filePath);
const result = validateUrl(pathToFileUrl(filePath));
expect(result.valid).toBe(true);
});
it("should reject non-existent file even if under allowed dir", () => {
const dir = path.resolve(import.meta.dirname);
allowedLocalDirs.add(dir);
const result = validateUrl(
pathToFileUrl(path.join(dir, "nonexistent-file.pdf")),
);
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,
);
});
});
describe("createServer useClientRoots option", () => {
it("should not set up roots handlers by default", () => {
const server = createServer();
// When useClientRoots is false (default), oninitialized should NOT
// be overridden by our roots logic.
expect(server.server.oninitialized).toBeUndefined();
server.close();
});
it("should not set up roots handlers when useClientRoots is false", () => {
const server = createServer({ useClientRoots: false });
expect(server.server.oninitialized).toBeUndefined();
server.close();
});
it("should set up roots handlers when useClientRoots is true", () => {
const server = createServer({ useClientRoots: true });
// When useClientRoots is true, oninitialized should be set to
// the roots refresh handler.
expect(server.server.oninitialized).toBeFunction();
server.close();
});
});