How Pi-Web's File-Access Allow-List Mechanism and `isPathWithinRoots()` Secure the File Browser

Pi-Web implements a deny-by-default allow-list that restricts file-browser access to explicitly registered root directories, with isPathWithinRoots() enforcing containment through normalized, case-insensitive path comparison.

Pi-Web's file browser intentionally never exposes the full filesystem. Instead, it constructs a dynamic allow-list of permitted directories and validates every path request against that list. This article explains how lib/file-access.ts builds the allow-list and how lib/path-security.ts implements the isPathWithinRoots() check that blocks directory traversal attacks.

How the Allow-List Is Built

The getAllowedFileRoots() function in lib/file-access.ts aggregates safe directories from three sources, caching the result for 5 seconds to avoid repeated expensive scans.

Sources of Permitted Roots

  • Session directories: Every stored Pi session contributes its cwd and projectRoot
  • Auto-created work directories: All ~/pi-cwd-* directories created by the default-cwd endpoint
  • Runtime additions: Roots injected dynamically via allowFileRoot()
// lib/file-access.ts — lines 20-46
const sessions = await listAllSessions();          // ← sessions → roots
…
roots.add(normalizeSlashes(s.cwd));
roots.add(normalizeSlashes(s.projectRoot));
…
for (const name of readdirSync(homedir())) { … }   // pi-cwd-* dirs
…
for (const root of getAdditionalAllowedRoots()) roots.add(root);

The 5-second TTL (ALLOWED_ROOTS_TTL_MS) ensures that session scanning happens infrequently while still picking up newly created sessions or work directories.

How isPathWithinRoots() Enforces Containment

The core security check lives in lib/path-security.ts. The isPathWithinRoots() function determines whether a target path falls under any allowed root through a carefully normalized comparison.

Cross-Platform Path Resolution

The function detects Windows-style paths and selects the appropriate resolver:

// lib/path-security.ts — lines 10-22
const useWindowsRules = isWindowsAbsolutePath(target) || isWindowsAbsolutePath(root);
const resolver = useWindowsRules ? path.win32 : path;
const sep = useWindowsRules ? "\\" : path.sep;

Normalization and Comparison Steps

  1. Resolve both paths to eliminate .. and . segments
  2. Lower-case on Windows for case-insensitive comparison
  3. Append trailing separator to the root to prevent prefix attacks
  4. Check equality or prefix match
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;

This prevents attacks like /allowed-other matching /allowed through prefix spoofing.

For paths that may exist on disk, isExistingPathWithinRoots() (lines 24-42) first resolves symbolic links using realpathSync, then delegates to the same lexical check. This blocks symlink escape attacks where a malicious link inside an allowed root points outside it.

Public API: Using the Checks in Endpoints

lib/file-access.ts exports two helpers used by all file-serving API routes:

  • isFilePathAllowed(target, allowedRoots) — Fast lexical check, no filesystem access. Use for validation before path existence is confirmed.
  • isExistingFilePathAllowed(target, allowedRoots) — Resolves the path first, then checks containment. Use when the file definitely exists.
// lib/file-access.ts — lines 51-59
export function isFilePathAllowed(target: string, allowedRoots: string[]): boolean {
  return isPathWithinRoots(target, allowedRoots);
}

export function isExistingFilePathAllowed(target: string, allowedRoots: string[]): boolean {
  return isExistingPathWithinRoots(target, allowedRoots);
}

Both forward to the implementations above, ensuring centralized enforcement across /api/files, /api/worktrees, and other endpoints.

Practical Code Examples

Check a User-Provided Path Before Serving

import { getAllowedFileRoots, isFilePathAllowed } from "@/lib/file-access";

async function canReadPath(userPath: string): Promise<boolean> {
  const allowedRoots = await getAllowedFileRoots();
  return isFilePathAllowed(userPath, allowedRoots);
}
import { isExistingFilePathAllowed } from "@/lib/file-access";

async function canReadExistingPath(userPath: string): Promise<boolean> {
  const allowedRoots = await getAllowedFileRoots();
  return isExistingFilePathAllowed(userPath, allowedRoots);
}

Add a Temporary Root at Runtime

import { allowFileRoot } from "@/lib/file-access";

function registerNewWorktreeRoot(root: string) {
  allowFileRoot(root);
}

Security Properties of the Allow-List Design

Property Implementation
Deny-by-default Only explicitly collected roots are accessible; all others rejected
Canonicalization Same resolver and case-handling for target and root blocks .., mixed separators, and case differences
Symlink resolution isExistingPathWithinRoots() prevents escape via malicious symlinks
Single enforcement point All API routes use the same helpers for consistent policy application

Summary

  • Pi-Web's file-access allow-list is built dynamically from session directories, auto-created work directories, and runtime additions
  • isPathWithinRoots() in lib/path-security.ts enforces containment through resolved, normalized, case-corrected path comparison
  • Symlink-aware checking via isExistingPathWithinRoots() prevents filesystem escapes
  • The public API (isFilePathAllowed, isExistingFilePathAllowed) centralizes enforcement across all file-serving endpoints

Frequently Asked Questions

What happens if no allowed roots are configured?

The allow-list returns an empty set. Any path check against empty roots fails deny-by-default, so the file browser refuses all requests until at least one root is registered through session storage or runtime injection.

No—the lexical isPathWithinRoots() does not touch the filesystem. The separate isExistingPathWithinRoots() resolves links first, then checks containment. This separation lets fast validation run without I/O while security-critical checks get full symlink resolution.

How does the caching mechanism affect security?

The 5-second TTL on getAllowedFileRoots() only affects performance, not security. Newly added roots via allowFileRoot() appear in getAdditionalAllowedRoots() immediately. The cache is rebuilt from scratch on expiration, so stale data cannot persist beyond the TTL window.

Why use both path.win32 and path.posix instead of Node's default path?

Explicit resolver selection ensures consistent behavior when the application receives Windows-style paths on a POSIX host (or vice versa through WSL mappings). This prevents platform mismatches from creating bypass opportunities in cross-platform deployments.

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:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →