How Pi Web's File Browser Prevents Path Traversal Attacks
Pi Web neutralizes path traversal attacks by enforcing a strict whitelist of allowed directory roots combined with platform-aware lexical containment checks and real-path resolution for symbolic links.
The agegr/pi-web repository implements a multi-layered security architecture in its file browser. Rather than exposing the entire filesystem to users, every file access request undergoes rigorous validation through specialized modules before any OS-level filesystem operations occur.
How the Allowed Roots Whitelist Works
The foundation of Pi Web's security model is a dynamically constructed whitelist. The getAllowedFileRoots() function in lib/file-access.ts aggregates permitted directories from multiple sources:
- The current working directory of every active session
- The project root directory
- Any
~/pi-cwd-*directories in the user's home - Additional roots configured through the API (
lib/allowed-roots.ts)
This collection process is cached for 5 seconds to prevent performance degradation from repeated filesystem scans. The resulting array of absolute paths forms the security boundary that no user request can escape.
Lexical Containment Checking
Before any file operation, the isPathWithinRoots() function in lib/path-security.ts performs strict lexical validation:
- Platform-specific resolution — Uses POSIX or Windows path resolvers appropriate to the runtime environment
- Case normalization — Handles Windows case-insensitivity correctly
- Separator standardization — Eliminates mixed-separator attack vectors
- Prefix validation — Confirms the target equals a root or starts with a root followed by a path separator
This approach neutralizes ../ sequences, encoded traversal sequences, and separator manipulation attempts purely through string analysis—before any filesystem access occurs.
Symbolic Link Protection
For operations requiring path existence verification, isExistingPathWithinRoots() adds an additional hardening layer. This function in lib/path-security.ts:
- Resolves the real canonical path of both target and roots using
realpathSync - Performs containment checks against these resolved paths
- Blocks symlink-based escapes that lexical checks alone cannot detect
This ensures that even if an attacker places a symbolic link inside an allowed directory pointing outside the whitelist, the resolved target path is still validated against permitted roots.
API-Level Enforcement
Every file-access endpoint in Pi Web's API enforces these checks. Route handlers in files like app/api/files/[...path]/route.ts implement the following pattern:
import { getAllowedFileRoots, isFilePathAllowed } from "../../../lib/file-access";
// Inside API route handler
export async function GET(req: Request) {
const path = extractPath(req);
if (!(await isFilePathAllowed(path, await getAllowedFileRoots()))) {
return new Response(JSON.stringify({ error: "Access denied" }), {
status: 403
});
}
// Only then proceed to filesystem access
// ...
}
For endpoints that verify file existence before operations:
import { getAllowedFileRoots, isExistingFilePathAllowed } from "../../../lib/file-access";
// Validates path AND resolves symlinks before access
const allowed = await isExistingFilePathAllowed(targetPath, await getAllowedFileRoots());
if (!allowed) {
throw new ForbiddenError("Path outside allowed directory");
}
Practical Implementation Examples
Implementing a Secure File Read
import { getAllowedFileRoots, isExistingFilePathAllowed } from "./lib/file-access";
import { readFileSync } from "fs";
async function secureReadFile(userRequestedPath: string): Promise<string> {
// Always use isExistingFilePathAllowed for actual file operations
// to catch symlink attacks
if (!(await isExistingFilePathAllowed(userRequestedPath, await getAllowedFileRoots()))) {
throw new Error("Access denied: path outside allowed directories");
}
// Safe to read—path is whitelisted and symlinks resolved
return readFileSync(userRequestedPath, "utf8");
}
Adding Custom Allowed Roots
import { addAllowedRoot } from "./lib/allowed-roots";
// Register additional directory for plugin access
addAllowedRoot("/secure/plugins/data");
// Now included in all subsequent getAllowedFileRoots() calls
Security Architecture Overview
| Component | File Path | Security Function |
|---|---|---|
| Root aggregation | lib/file-access.ts |
getAllowedFileRoots() — builds dynamic whitelist |
| Lexical containment | lib/path-security.ts |
isPathWithinRoots() — string-level path validation |
| Symlink resolution | lib/path-security.ts |
isExistingPathWithinRoots() — real-path verification |
| API helpers | lib/file-access.ts |
isFilePathAllowed(), isExistingFilePathAllowed() |
| Extension points | lib/allowed-roots.ts |
addAllowedRoot() — programmatic root registration |
Summary
Pi Web's file browser security relies on three core mechanisms working in concert:
- Dynamic root whitelisting limits filesystem exposure to explicitly permitted directories
- Lexical containment validation blocks traversal attempts through path manipulation
- Real-path resolution closes the symbolic link bypass vulnerability
These checks execute at the application layer before any OS filesystem calls, ensuring path traversal attacks are intercepted regardless of operating system or platform-specific path behaviors.
Frequently Asked Questions
How does Pi Web handle case-insensitive filesystems like Windows?
The isPathWithinRoots() function in lib/path-security.ts normalizes case when running on Windows, converting both target paths and allowed roots to consistent casing before comparison. This prevents case-mismatch bypasses while maintaining correct behavior on case-sensitive systems.
Can symbolic links inside allowed directories access outside files?
No. When isExistingFilePathAllowed() is used (required for actual file reads), the function calls realpathSync on both the target and all allowed roots. The containment check operates on these fully resolved canonical paths, so a symlink pointing outside the whitelist resolves to its true destination—which fails the root validation.
What happens if getAllowedFileRoots() fails or returns empty?
The security model defaults to deny-all. Both isFilePathAllowed() and isExistingFilePathAllowed() return false when the roots array is empty or the target path cannot be validated. All API endpoints treat validation failure as an access denial, returning HTTP 403 before attempting any filesystem operation.
How can I add temporary directories to the allowed roots?
Use the addAllowedRoot() function exported from lib/allowed-roots.ts. Roots added through this API are immediately included in subsequent getAllowedFileRoots() calls. For session-scoped temporary access, store the path in a ~/pi-cwd-* directory pattern, which getAllowedFileRoots() automatically discovers.
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 →