How pi-web Manages File Access Security with the Allowed-Roots System
pi-web enforces file access security through a dynamic allowed-roots system that maintains a cached whitelist of safe directories derived from session data and runtime extensions, validating every file operation against both lexical and real-path containment checks to prevent directory traversal and symlink attacks.
The agegr/pi-web repository implements a strict read-only file-browser API that never accesses the file system without explicit authorization. By combining dynamic root discovery, a short-lived global cache, and Windows-aware path validation, the allowed-roots system ensures that list, preview, download, and upload operations remain confined to designated safe directories.
Building the Dynamic Allowed-Roots Whitelist
The foundation of pi-web's security model lies in lib/file-access.ts, where the getAllowedFileRoots() function assembles a comprehensive set of permitted directories. This function scans active sessions, project roots, temporary work directories, and any runtime additions to construct the whitelist.
// lib/file-access.ts – getAllowedFileRoots()
export async function getAllowedFileRoots(): Promise<Set<string>> {
const now = Date.now();
const cached = globalThis.__piAllowedRootsCache;
if (cached && cached.expiresAt > now) return cached.roots; // ↪️ cached result
const sessions = await listAllSessions(); // ↪️ session CWDs
const roots = new Set<string>();
for (const s of sessions) {
if (s.cwd) roots.add(normalizeSlashes(s.cwd));
if (s.projectRoot) roots.add(normalizeSlashes(s.projectRoot));
}
// Add temporary “~/pi‑cwd‑YYYYMMDD” directories
try {
for (const name of readdirSync(homedir())) {
if (/^pi-cwd-\d{8}$/.test(name)) {
roots.add(normalizeSlashes(path.join(homedir(), name)));
}
}
} catch {} // ignore unreadable home
// Merge any runtime‑added roots
for (const root of getAdditionalAllowedRoots()) roots.add(root);
globalThis.__piAllowedRootsCache = { roots, expiresAt: now + ALLOWED_ROOTS_TTL_MS };
return roots;
}
Source: file-access.ts lines 20-48
To prevent performance degradation from repeated session scans, the implementation stores results in globalThis.__piAllowedRootsCache with a 5-second TTL. This cache ensures that high-frequency API requests reuse the computed root set without rescanning the session store or file system.
Extending Allowed Roots at Runtime
Plugins and dynamic components can expand the whitelist without modifying core configuration through the allowFileRoot() function exported from lib/allowed-roots.ts. This helper maintains a separate global set (__piAdditionalAllowedRoots) and immediately updates the cache when invoked.
// lib/allowed-roots.ts
export function allowFileRoot(root: string): void {
if (!root) return;
const normalizedRoot = normalizeSlashes(root);
getAdditionalAllowedRoots().add(normalizedRoot);
globalThis.__piAllowedRootsCache?.roots.add(normalizedRoot);
}
Source: allowed-roots.ts lines 26-31
When integrating third-party extensions or temporary workspaces, call allowFileRoot("/your/custom/path") before file operations commence. The normalization ensures consistent path comparison regardless of trailing slashes or operating system differences.
Path Containment Validation
The core security logic resides in lib/path-security.ts, which provides two distinct validation strategies. The lexical check (isPathWithinRoots) performs fast string comparison on normalized paths, while the real-path check (isExistingPathWithinRoots) resolves symbolic links to prevent symlink escape attacks.
Lexical Containment
// lib/path-security.ts – lexical check
export function isPathWithinRoots(target: string, roots: Set<string>): boolean {
for (const root of roots) {
const useWindowsRules = isWindowsAbsolutePath(target) || isWindowsAbsolutePath(root);
const resolver = useWindowsRules ? path.win32 : path;
const sep = useWindowsRules ? "\\" : path.sep;
const normalized = resolver.resolve(target);
const normalizedRoot = resolver.resolve(root);
const comparable = useWindowsRules ? normalized.toLowerCase() : normalized;
const comparableRoot = useWindowsRules ? normalizedRoot.toLowerCase() : normalizedRoot;
const rootWithSep = comparableRoot.endsWith(sep) ? comparableRoot : comparableRoot + sep;
if (comparable === comparableRoot || comparable.startsWith(rootWithSep)) return true;
}
return false;
}
Source: path-security.ts lines 10-22
This implementation handles Windows path semantics automatically, converting to lowercase for comparison on Windows systems while respecting case sensitivity on Unix platforms. The function ensures that a path like /allowed/subdir passes validation against root /allowed, while /allowed-malicious fails.
Real-Path Validation for Symlink Protection
When handling existing files or directories that might contain symbolic links, use isExistingPathWithinRoots (exposed as isExistingFilePathAllowed in the public API):
export function isExistingPathWithinRoots(target: string, roots: Set<string>): boolean {
let realTarget: string;
try { realTarget = realpathSync(target); } catch { return false; }
const realRoots = new Set<string>();
for (const root of roots) {
try { realRoots.add(realpathSync(root)); } catch {} // ignore stale roots
}
return isPathWithinRoots(realTarget, realRoots);
}
Source: path-security.ts lines 25-42
By resolving both the target path and all allowed roots through realpathSync, this function prevents symlink traversal attacks where a malicious link inside an allowed directory points to sensitive system files outside the whitelist.
Enforcing Security in API Routes
Every file-related endpoint in app/api/files/[...path]/route.ts imports the guard functions and implements a consistent validation pattern. Routes first retrieve the cached allowed roots, then validate paths before any fs operation.
Upload Protection with Dual Validation
The upload handler demonstrates defense-in-depth by checking both the requested directory and its resolved real path:
const allowedRoots = await getAllowedFileRoots();
if (!isFilePathAllowed(directory, allowedRoots)) {
return NextResponse.json({ error: "Access denied" }, { status: 403 });
}
// ...
const realDirectory = fs.realpathSync(directory);
if (!isFilePathAllowed(realDirectory, allowedRoots)) {
return NextResponse.json({ error: "Access denied" }, { status: 403 });
}
Source: upload validation lines 88-98 & 103-115
This pattern ensures that even if an attacker creates a symlink in place of the upload directory between the initial check and the write operation, the resolved path validation blocks the request.
Read and Download Guards
The GET handler combines root-based validation with session-specific references for temporary file access:
const allowedRoots = await getAllowedFileRoots();
const allowedByRoot = isFilePathAllowed(filePath, allowedRoots);
const allowedBySessionReference =
!allowedByRoot && type !== "list" && await isFilePathReferencedBySession(filePath, sessionId);
if (!allowedByRoot && !allowedBySessionReference) {
return NextResponse.json({ error: "Access denied" }, { status: 403 });
}
// ...
if (!isExistingFilePathAllowed(existingAuthorizationPath, allowedRoots)) {
return NextResponse.json({ error: "Access denied" }, { status: 403 });
}
Source: GET handler lines 29-38 & 48-53
The same validation appears in app/api/worktrees/route.ts, maintaining a consistent security boundary across the entire file API surface.
Practical Implementation Examples
Adding a Custom Root for Plugins
When exposing directories created by plugins, invoke the runtime extension helper:
import { allowFileRoot } from "@/lib/file-access";
export function initPlugin() {
// Expose plugin-specific data directory
allowFileRoot("/var/pi-plugins/shared-data");
}
Manual Path Validation
For custom file operations outside the standard API routes, explicitly check containment:
import { getAllowedFileRoots, isFilePathAllowed } from "@/lib/file-access";
import { readFileSync } from "fs";
async function readConfigFile(filePath: string) {
const roots = await getAllowedFileRoots();
if (!isFilePathAllowed(filePath, roots)) {
throw new Error("Access denied: path is outside allowed roots");
}
return readFileSync(filePath, "utf-8");
}
Symlink-Safe File Operations
When writing files that user input could influence, always validate the resolved real path:
import { getAllowedFileRoots, isExistingFilePathAllowed } from "@/lib/file-access";
import { realpathSync, writeFileSync } from "fs";
async function safeWriteFile(requestedPath: string, data: string) {
const roots = await getAllowedFileRoots();
// Resolve symlinks before final validation
const realPath = realpathSync(requestedPath);
if (!isExistingFilePathAllowed(realPath, roots)) {
throw new Error("Access denied: symlink targets outside allowed roots");
}
writeFileSync(realPath, data);
}
Summary
- Dynamic whitelist construction: The system aggregates allowed directories from session CWDs, project roots, temporary
~/pi-cwd-*directories, and runtime extensions viagetAllowedFileRoots()inlib/file-access.ts. - Performance optimization: A 5-second TTL cache stored in
globalThis.__piAllowedRootsCacheeliminates redundant session scans during high-frequency requests. - Dual validation strategy: Lexical checks (
isFilePathAllowed) provide fast validation for non-existent paths, while real-path checks (isExistingFilePathAllowed) resolve symlinks to prevent directory traversal. - Cross-platform compatibility: Path normalization utilities in
lib/paths.tshandle Windows case-insensitivity and mixed separator formats automatically. - Consistent enforcement: All API routes in
app/api/files/[...path]/route.tsreturn 403 Access denied immediately whenisFilePathAllowedorisExistingFilePathAllowedreturns false.
Frequently Asked Questions
How does pi-web prevent symlink attacks against the allowed-roots system?
The system uses isExistingFilePathAllowed (backed by isExistingPathWithinRoots in lib/path-security.ts), which calls realpathSync on both the target path and all allowed roots before validation. This resolves symbolic links to their actual disk locations, ensuring that a symlink pointing outside the whitelist fails containment checks even if the link itself resides inside an allowed directory.
What is the performance impact of validating every file request?
pi-web mitigates performance overhead through a 5-second TTL cache (ALLOWED_ROOTS_TTL_MS) stored in globalThis.__piAllowedRootsCache. This cache persists the computed root set across requests, eliminating the need to rescan sessions or readdir the home directory on every API call. Lexical path validation operates purely on strings without disk I/O, adding minimal latency.
Can I add allowed directories after the server starts?
Yes. The allowFileRoot() function in lib/allowed-roots.ts dynamically extends the whitelist at runtime. This function updates both the global __piAdditionalAllowedRoots set and the active cache, making new roots immediately available without server restart. This capability supports plugin architectures that create directories on-demand.
Does the allowed-roots system support Windows paths?
Yes. The containment logic in lib/path-security.ts automatically detects Windows absolute paths and switches to path.win32 for normalization. It handles case-insensitive comparison by converting paths to lowercase when Windows rules apply, and properly manages backslash separators to prevent bypasses involving mixed path formats.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →