# How the File Access Allow-List Security Boundary Works in /api/files

> Discover how the /api/files endpoint uses an allow-list security boundary with canonical path validation and symlink resolution to protect against unauthorized file access.

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

---

**The `/api/files` endpoint enforces a strict file access allow-list security boundary that restricts all filesystem operations to directories derived from active sessions, temporary workspace folders, and explicitly approved roots, using canonical path validation and symlink resolution to prevent unauthorized access.**

The `agegr/pi-web` repository implements a defense-in-depth strategy for its file management API. This **file access allow-list security boundary** ensures that the `/api/files` endpoint can only interact with files residing in pre-approved locations, effectively sandboxing filesystem access against directory traversal attacks.

## How the Allow-List Is Constructed

The security boundary begins with `getAllowedFileRoots()` in [`lib/file-access.ts`](https://github.com/agegr/pi-web/blob/main/lib/file-access.ts), which aggregates trusted directories from three distinct sources into a `Set<string>`.

### Session Working Directories and Project Roots

For every active session retrieved via `listAllSessions()`, the function extracts both the working directory (`cwd`) and the resolved `projectRoot`. These paths are normalized and added to the allow-list set (lines 29-36).

### Temporary pi-cwd Directories

The scanner searches the user's home folder for directories matching the pattern `pi-cwd-\d{8}`. These temporary workspaces are automatically included in the boundary to support ad-hoc scratch folders created by the default-cwd endpoint (lines 38-44).

### Runtime-Added Roots via allowFileRoot()

Any runtime component can extend the boundary by calling `allowFileRoot(path)` from [`lib/allowed-roots.ts`](https://github.com/agegr/pi-web/blob/main/lib/allowed-roots.ts). This function stores normalized paths in a global `__piAdditionalAllowedRoots` set and invalidates the cached roots map, enabling dynamic expansion without server restarts (lines 19-24).

## Five-Second Cache Strategy

To balance security with performance, the aggregated roots are stored in `__piAllowedRootsCache` with a time-to-live (TTL) of 5 seconds. This short cache duration ensures newly created sessions or dynamically added roots are recognized quickly while avoiding expensive filesystem scans on every request (lines 9-13, 17-18).

## Path Validation Logic

When a request hits `app/api/files/[...path]/route.ts`, the handler resolves the requested path and evaluates it against the cached allow-list through a multi-layered validation sequence.

### Canonical Prefix Comparison

The primary check uses `isFilePathAllowed()` (lines 55-66) to perform a case-aware prefix comparison after converting both the target path and each allowed root to absolute, normalized paths (POSIX-style `/` separators). If the requested path starts with any allowed root, the operation proceeds.

### Session Reference Fallback

For read operations that fail the root check but are not directory listings, the system invokes `isFilePathReferencedBySession()` from [`lib/session-file-references.ts`](https://github.com/agegr/pi-web/blob/main/lib/session-file-references.ts). This inspects the session's `.jsonl` file to verify if the path appears as an explicit reference (e.g., an uploaded file), permitting access to session-specific attachments outside the standard roots.

### Symlink Resolution for Write Operations

Before any destructive operation (upload, delete), the handler calls `isExistingFilePathAllowed()`, which delegates to `isExistingPathWithinRoots()` in [`lib/path-security.ts`](https://github.com/agegr/pi-web/blob/main/lib/path-security.ts). This resolves the absolute real path by following symlinks and re-applies the prefix check, preventing symlink-based directory traversal attacks that could escape the allow-list (lines 72-75).

## Practical Implementation Examples

Listing a directory within an allowed session root:

```bash
curl "http://localhost:30141/api/files/home/user/project?type=list&sessionId=12345"

# → { "entries": [...], "path": "/home/user/project" }

```

Reading a file referenced by the session:

```bash
curl "http://localhost:30141/api/files/home/user/project/file.txt?type=read&sessionId=12345"

# → { "content": "...", "language": "text", "size": 428 }

```

Attempting to access a system file outside the boundary:

```bash
curl "http://localhost:30141/api/files/etc/passwd?type=read"

# → { "error": "Access denied" }   # HTTP 403

```

Dynamically adding a custom root at runtime:

```typescript
import { allowFileRoot } from '@/lib/allowed-roots';

allowFileRoot('/mnt/shared');  // Added to __piAdditionalAllowedRoots
// Subsequent uploads to /mnt/shared are now permitted

```

## Summary

- The **file access allow-list security boundary** aggregates allowed directories from session roots, temporary `pi-cwd-*` folders, and explicit runtime additions via `allowFileRoot()`.
- A **5-second TTL cache** (`__piAllowedRootsCache`) stores the aggregated roots in [`lib/file-access.ts`](https://github.com/agegr/pi-web/blob/main/lib/file-access.ts) to minimize filesystem overhead while maintaining freshness.
- All paths undergo **canonical normalization** and prefix matching through `isFilePathAllowed()` before any operation proceeds.
- **Session references** provide a fallback validation for files explicitly attached to a session but located outside standard roots.
- **Symlink resolution** via `isExistingPathWithinRoots()` in [`lib/path-security.ts`](https://github.com/agegr/pi-web/blob/main/lib/path-security.ts) prevents traversal attacks during write operations.

## Frequently Asked Questions

### How does the system handle newly created sessions?

The 5-second cache TTL ensures that sessions created after the server starts are incorporated into the allow-list within a few seconds. When `getAllowedFileRoots()` detects a stale cache, it rebuilds the set by scanning all current sessions and their project roots.

### Can the allow-list be modified without restarting the server?

Yes. The `allowFileRoot()` function in [`lib/allowed-roots.ts`](https://github.com/agegr/pi-web/blob/main/lib/allowed-roots.ts) dynamically adds paths to the global `__piAdditionalAllowedRoots` set and clears the cached roots map, allowing plugins or runtime configuration to extend the security boundary immediately.

### What prevents symbolic links from bypassing the allow-list?

For any operation that modifies the filesystem, the handler calls `isExistingFilePathAllowed()`, which resolves the absolute real path (following symlinks) using `isExistingPathWithinRoots()` from [`lib/path-security.ts`](https://github.com/agegr/pi-web/blob/main/lib/path-security.ts). The prefix check is then applied to the resolved path, ensuring symlinks cannot point outside allowed directories.

### Why is there a session reference fallback for read operations?

The `isFilePathReferencedBySession()` check allows users to read files that were explicitly uploaded or referenced in their session's `.jsonl` file, even if those files reside outside the standard session roots. This supports temporary file attachments while maintaining the security boundary for directory listings and write operations.