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

> Learn how Pi-Web's file-access allow-list and isPathWithinRoots() secure the file browser by restricting access to registered directories and enforcing path containment.

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

---

**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`](https://github.com/agegr/pi-web/blob/main/lib/file-access.ts) builds the allow-list and how [`lib/path-security.ts`](https://github.com/agegr/pi-web/blob/main/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`](https://github.com/agegr/pi-web/blob/main/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()`

```typescript
// 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`](https://github.com/agegr/pi-web/blob/main/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:

```typescript
// 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**

```typescript
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.

## Symlink-Aware Variant: `isExistingPathWithinRoots()`

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`](https://github.com/agegr/pi-web/blob/main/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.

```typescript
// 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

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

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

```

### Resolve Symlinks and Verify

```typescript
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

```typescript
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`](https://github.com/agegr/pi-web/blob/main/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.

### Does `isPathWithinRoots()` follow symbolic links?

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.