# Pi Web File Access Control and Security: A Deep Dive into the Whitelist Architecture

> Discover Pi Web file access control and security. Learn how its whitelist architecture prevents directory traversal with real-path verification and lexical containment. Secure your Pi Web environment.

- Repository: [Alex Yang/pi-web](https://github.com/agegr/pi-web)
- Tags: deep-dive
- Published: 2026-08-14

---

**Pi Web implements file access control through a runtime-built whitelist of allowed directory roots, combining lexical path containment with symlink-aware real-path verification to prevent directory traversal attacks.**

Pi Web is a Next.js-based development environment that deliberately restricts filesystem interactions to explicitly permitted directories. This article examines the security architecture implemented in `agegr/pi-web`, analyzing how the whitelist system works, how it defends against path traversal and symlink attacks, and how API routes enforce these boundaries on every file operation.

## Building the Whitelist with `getAllowedFileRoots()`

The foundation of Pi Web's security model lives in [`lib/file-access.ts`](https://github.com/agegr/pi-web/blob/main/lib/file-access.ts). The `getAllowedFileRoots()` function dynamically constructs a `Set<string>` of permitted directory roots from multiple sources:

```ts
// lib/file-access.ts
export async function getAllowedFileRoots(): Promise<Set<string>> {
  // Cache for 5 s to avoid rescanning all sessions on every request
  const now = Date.now();
  const cached = globalThis.__piAllowedRootsCache;
  if (cached && cached.expiresAt > now) return cached.roots;

  const sessions = await listAllSessions();           // all *.jsonl files
  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 ~/pi-cwd-YYYYMMDD folders (default-cwd endpoint)
  try {
    for (const name of readdirSync(homedir())) {
      if (/^pi-cwd-\d{8}$/.test(name))
        roots.add(normalizeSlashes(path.join(homedir(), name)));
    }
  } catch {}

  // User-added roots (e.g., after a successful worktree creation)
  for (const root of getAdditionalAllowedRoots()) roots.add(root);

  globalThis.__piAllowedRootsCache = { roots, expiresAt: now + 5_000 };
  return roots;
}

```

The whitelist aggregates four distinct sources:

- **Session directories**: Each Pi session's `cwd` and `projectRoot` from stored session files
- **Default working directories**: Auto-created `~/pi-cwd-YYYYMMDD` folders
- **Runtime additions**: Extra roots added via `allowFileRoot()` API calls
- **Manual extensions**: Roots injected through UI or API interactions

The **5-second TTL cache** stored on `globalThis.__piAllowedRootsCache` ensures performance while surviving Next.js hot-reloads during development.

## Path Normalization and Cross-Platform Consistency

Before any security checks execute, all paths undergo **slash normalization** through `normalizeSlashes()` in [`lib/allowed-roots.ts`](https://github.com/agegr/pi-web/blob/main/lib/allowed-roots.ts). This function converts path separators to forward slashes (`/`) regardless of host operating system, ensuring that `Set` membership tests work consistently across Windows, macOS, and Linux.

Normalization eliminates a common source of security bypasses where `/allowed/path` and `\allowed\path` might be treated as different strings even when referring to the same directory.

## Lexical Containment with `isPathWithinRoots()`

The primary security gate lives in [`lib/path-security.ts`](https://github.com/agegr/pi-web/blob/main/lib/path-security.ts). The `isPathWithinRoots()` function performs **fast, zero-filesystem-access containment checks**:

```ts
// lib/path-security.ts
export function isPathWithinRoots(target: string, roots: Set<string>): boolean {
  for (const root of roots) {
    const useWindows = isWindowsAbsolutePath(target) || isWindowsAbsolutePath(root);
    const resolver = useWindows ? path.win32 : path;
    const sep = useWindows ? "\\" : path.sep;
    const normalized = resolver.resolve(target);
    const normalizedRoot = resolver.resolve(root);
    const comparable = useWindows ? normalized.toLowerCase() : normalized;
    const comparableRoot = useWindows ? normalizedRoot.toLowerCase() : normalizedRoot;
    const rootWithSep = comparableRoot.endsWith(sep) ? comparableRoot : comparableRoot + sep;
    if (comparable === comparableRoot || comparable.startsWith(rootWithSep)) return true;
  }
  return false;
}

```

This implementation handles several critical edge cases:

- **Platform detection**: Uses `path.win32` resolver when either path is Windows-absolute
- **Case insensitivity**: Lowercases both paths on Windows before comparison
- **Directory boundary enforcement**: Appends separator to root and verifies `startsWith`, preventing `/allowed/dir-evil` from matching `/allowed/dir`

The lexical check alone stops obvious traversal attacks like `../../../etc/passwd` without touching the filesystem.

## Symlink Defense with Real-Path Verification

Lexical containment is insufficient when **symlinks** enter the picture. A malicious symlink placed inside an allowed root could point anywhere on the filesystem. Pi Web addresses this with `isExistingPathWithinRoots()`:

```ts
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);
}

```

The defense strategy resolves both the target path and all whitelist roots through `realpathSync`, converting symbolic links to their actual filesystem locations. Only then does the containment check apply. This two-phase approach—**lexical first, real-path second**—balances performance with security: paths that fail the cheap lexical test are rejected immediately, while actual file operations pay the cost of symlink resolution.

## Public API Security Surface

[`lib/file-access.ts`](https://github.com/agegr/pi-web/blob/main/lib/file-access.ts) exports two security functions that API routes consume:

```ts
export function isFilePathAllowed(target: string, allowedRoots: Set<string>): boolean {
  // lexical check – no FS hit
  return isPathWithinRoots(target, allowedRoots);
}

export function isExistingFilePathAllowed(target: string, allowedRoots: Set<string>): boolean {
  // resolves symlinks, then lexical check
  return isExistingPathWithinRoots(target, allowedRoots);
}

```

Every filesystem-exposing endpoint—including `/api/files/*`, `/api/git/*`, `/api/worktrees`, and plugin routes—follows this enforcement pattern:

1. Fetch current whitelist: `await getAllowedFileRoots()`
2. Lexical validation: `isFilePathAllowed()` for path-only decisions
3. Real-path validation: `isExistingFilePathAllowed()` before any read/write
4. **HTTP 403 response** with `{ error: "Access denied" }` on any failure

## File Read Security in Practice

The `app/api/files/[...path]/route.ts` endpoint demonstrates the layered validation strategy:

```ts
// app/api/files/[...path]/route.ts – GET handler
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 });
}

// Resolve symlinks for existing files
if (!isExistingFilePathAllowed(existingAuthorizationPath, allowedRoots)) {
  return NextResponse.json({ error: "Access denied" }, { status: 403 });
}

```

This reveals Pi Web's **dual authorization paths**:

- **Root whitelist**: Standard containment within configured directories
- **Session reference**: Explicit attachment references that grant access regardless of location

Both paths ultimately require the real-path check before filesystem operations proceed.

## Secure Upload Handling

File uploads implement additional hardening in the POST handler:

- **Directory validation**: The upload directory passes `isFilePathAllowed()`
- **Symlink re-verification**: Both directory and whitelist roots are resolved with `realpathSync`, then re-checked
- **Exclusive creation**: Files write with `fs.writeFileSync(..., { flag: "wx" })`, preventing accidental overwrites unless replacement is explicitly requested

This prevents race conditions where a directory could be replaced with a symlink between validation and write.

## Runtime Root Extension with `allowFileRoot()`

The security model permits dynamic expansion through `allowFileRoot()`:

```ts
// app/api/default-cwd/route.ts
import { allowFileRoot } from "@/lib/file-access";

allowFileRoot(newCwdPath);  // instantly trusted for subsequent requests

```

This function normalizes the path, adds it to the in-memory additional roots set, and **immediately patches the cached whitelist** via `globalThis.__piAllowedRootsCache`, bypassing the 5-second TTL for instant availability.

## Security Architecture Summary

| Layer | Defense | Implementation Location |
|-------|---------|------------------------|
| **Whitelist construction** | Aggregates session dirs, auto-directories, and runtime additions | [`lib/file-access.ts`](https://github.com/agegr/pi-web/blob/main/lib/file-access.ts) → `getAllowedFileRoots()` |
| **Path normalization** | Slash-consistent, case-folded strings for reliable comparison | [`lib/allowed-roots.ts`](https://github.com/agegr/pi-web/blob/main/lib/allowed-roots.ts) → `normalizeSlashes()` |
| **Lexical containment** | Zero-FS traversal prevention | [`lib/path-security.ts`](https://github.com/agegr/pi-web/blob/main/lib/path-security.ts) → `isPathWithinRoots()` |
| **Real-path containment** | Symlink-to-escape prevention | [`lib/path-security.ts`](https://github.com/agegr/pi-web/blob/main/lib/path-security.ts) → `isExistingPathWithinRoots()` |
| **API enforcement** | Mandatory 403 responses on policy violations | `app/api/*/route.ts` files |
| **Cache resilience** | `globalThis` storage survives Next.js hot-reload | `globalThis.__piAllowedRootsCache` |

## Summary

- **Pi Web file access control** relies on a runtime-constructed whitelist rather than static configuration, enabling dynamic multi-project workflows
- **Two-phase validation**—lexical containment followed by real-path resolution—balances performance with symlink attack defense
- **Cross-platform path handling** uses platform-specific resolvers and case-folding to ensure consistent behavior
- **Cache architecture** on `globalThis` maintains performance across hot-reloads without sacrificing freshness
- **Universal API enforcement** means no file operation bypasses the security model, with consistent HTTP 403 responses

## Frequently Asked Questions

### How does Pi Web prevent directory traversal attacks?

Pi Web uses **lexical path containment** in `isPathWithinRoots()` to verify that any requested path either equals or starts with an allowed root directory plus a separator. This check runs before filesystem access, rejecting patterns like `../../../etc/passwd` that attempt to escape permitted directories.

### Why does Pi Web resolve symlinks separately from path validation?

Symlinks can point outside allowed directories even when placed inside them. The `isExistingPathWithinRoots()` function calls `realpathSync` on both the target and all whitelist roots, converting symbolic links to actual locations before running the containment check. This closes the "symlink escape" attack vector that purely lexical validation cannot detect.

### Can the whitelist be modified at runtime?

Yes. The `allowFileRoot()` function in [`lib/file-access.ts`](https://github.com/agegr/pi-web/blob/main/lib/file-access.ts) permits dynamic addition of new roots. Additions take effect immediately by updating both the in-memory additional roots set and the `globalThis.__piAllowedRootsCache`, ensuring no delay before the new root becomes available for file operations.

### What happens when a file access is denied?

All API routes return **HTTP 403** with a JSON body containing `{ error: "Access denied" }`. This consistent error response applies uniformly across file listing, reading, writing, upload, git operations, and worktree management endpoints.