How Pi-Web's `samePath()` Handles Windows vs POSIX Path Security
samePath() in Pi-Web performs case-insensitive comparisons on Windows and case-sensitive comparisons on POSIX systems, using normalizeForComparison() to create stable path representations before checking equality.
Pi-Web's filesystem security relies on cross-platform path comparison logic implemented in lib/paths.ts and lib/path-security.ts. These modules ensure that path validation behaves correctly whether the runtime is Windows (with drive letters, backslashes, and case-insensitive semantics) or POSIX (forward slashes, case-sensitive). This article examines how samePath() and related functions handle platform differences to prevent directory traversal attacks.
Detecting Windows-Style Paths
Before any comparison occurs, Pi-Web identifies which platform rules apply. The isWindowsAbsolutePath() function in lib/paths.ts uses regex detection to flag Windows-specific paths.
// lib/paths.ts#L24-L28
function isWindowsAbsolutePath(p: string): boolean {
return /^[a-zA-Z]:[\\\/]/.test(p) || p.startsWith('\\\\') || p.startsWith('//');
}
This check triggers Windows handling when:
- A drive letter prefix exists (
C:\orD:/) - A UNC path begins with
\\or//
The detection is used by both samePath() and isPathWithinRoots() to choose appropriate normalization strategies.
Canonical Path Forms and Internal Representation
Pi-Web maintains two path representations to prevent string mismatches:
| Form | Function | Purpose |
|---|---|---|
| Native | toNativePath() |
Filesystem operations, UI display |
| Slash | toSlashPath() |
Internal storage, allowed-roots sets |
// lib/paths.ts#L30-L45
export function toSlashPath(p: string): string {
return p.replace(/\\/g, '/');
}
export function toNativePath(p: string): string {
return process.platform === 'win32'
? p.replace(/\//g, '\\')
: p;
}
Allowed roots are always stored in slash form. This design lets Git's POSIX-style outputs (D:/repo) match against Windows runtime paths without ambiguity.
The samePath() Equality Function
The samePath() function implements the core path comparison logic in lib/paths.ts (lines 56-71):
export function samePath(a: string, b: string): boolean {
// 1. Fast-path identity check
if (a === b) return true;
// 2. Empty path guard
if (!a || !b) return false;
// 3. Normalize both paths
const na = normalizeForComparison(a);
const nb = normalizeForComparison(b);
// 4. Platform-sensitive comparison
if (process.platform === 'win32') {
return na.toLowerCase() === nb.toLowerCase();
}
// 5. POSIX: verbatim comparison
return na === nb;
}
The comparison proceeds through five stages:
- Identity shortcut — Identical strings short-circuit immediately
- Validity check — Empty paths are rejected as unequal
- Normalization —
normalizeForComparison()creates stable forms - Windows case folding —
toLowerCase()enablesC:\Repo==c:\repo - POSIX exact match — Case and encoding must match exactly
Path Normalization for Stable Comparison
The private normalizeForComparison() helper (lines 47-53) prepares paths for reliable comparison:
function normalizeForComparison(p: string): string {
// Use native path module for platform-correct normalization
const normalized = path.normalize(p);
// Remove trailing separators
const trimmed = normalized.replace(/[\\\/]+$/, '');
// Strip root for relative comparison stability
return trimmed;
}
This removes the trailing slash differences that would otherwise cause D:\repo and D:\repo\ to be treated as different locations.
Containment Testing with isPathWithinRoots()
For security boundary checks, lib/path-security.ts provides isPathWithinRoots() (lines 10-22). This extends samePath() logic to multi-root containment tests:
export function isPathWithinRoots(
target: string,
roots: Set<string>
): boolean {
for (const root of roots) {
// Determine which platform rules apply
const useWin32 = isWindowsAbsolutePath(target) ||
isWindowsAbsolutePath(root);
const resolver = useWin32 ? path.win32 : path;
// Resolve to absolute paths
const resolvedTarget = resolver.resolve(target);
const resolvedRoot = resolver.resolve(root);
// Apply case-insensitivity when Windows rules active
const [a, b] = useWin32
? [resolvedTarget.toLowerCase(), resolvedRoot.toLowerCase()]
: [resolvedTarget, resolvedRoot];
// Ensure root ends with separator for prefix safety
const rootPrefix = b.endsWith(resolver.sep) ? b : b + resolver.sep;
// Check equality or directory containment
if (a === b || a.startsWith(rootPrefix)) return true;
}
return false;
}
Key security features:
- Context-aware resolver selection — Uses
path.win32when either path is Windows-absolute - Root separator enforcement — Prevents prefix attacks like
/allowedmatching/allowed-evil - Symlink-aware variant —
isExistingPathWithinRoots()(lines 24-42) callsfs.realpathSync()before containment checks
Practical Usage Example
import { isPathWithinRoots, isExistingPathWithinRoots } from "@/lib/path-security";
import { toSlashPath, samePath } from "@/lib/paths";
// Internal storage uses slash form
const allowedRoots = new Set([
toSlashPath("D:/repo"), // Windows root
toSlashPath("/home/user/project") // POSIX root
]);
// User input from HTTP request
const userPath = "D:\\repo\\src\\index.ts";
// Convert to internal representation
const normalized = toSlashPath(userPath);
// Lexical containment check
const allowed = isPathWithinRoots(normalized, allowedRoots);
console.log(allowed); // true
// Direct equality check
console.log(samePath("D:\\Repo", "d:/repo")); // true on Windows, false on POSIX
// Symlink-resolved containment
const realAllowed = isExistingPathWithinRoots(userPath, allowedRoots);
Platform Differences Summary
| Aspect | Windows Behavior | POSIX Behavior |
|---|---|---|
| Separators | \ and / equivalent |
/ only |
| Case sensitivity | Insensitive (samePath() uses toLowerCase()) |
Sensitive (verbatim compare) |
| Absolute detection | Drive letter or UNC prefix | Leading / |
| Path module | path.win32 when detected |
Default path |
Summary
samePath()implements platform-aware equality using normalization plus case-folding on WindowsisWindowsAbsolutePath()detects drive-letter and UNC patterns to select appropriate rules- Dual path forms (native vs slash) prevent representation mismatches in internal storage
isPathWithinRoots()applies the same Windows/POSIX logic to multi-root containment tests- Symlink resolution via
isExistingPathWithinRoots()closes time-of-check-to-time-of-use vulnerabilities
Frequently Asked Questions
How does samePath() handle case differences on Windows?
samePath() calls toLowerCase() on both normalized paths when process.platform === 'win32'. This makes C:\Repo, c:\repo, and C:/REPO compare as equal. On POSIX systems, the comparison remains case-sensitive to match filesystem behavior.
What happens if one path uses backslashes and another uses forward slashes?
The normalizeForComparison() helper calls path.normalize(), which converts separators to the platform default. When Windows rules are active (detected via isWindowsAbsolutePath()), path.win32.normalize() handles both separator types. The subsequent comparison operates on normalized strings where separator differences are resolved.
Why does isPathWithinRoots() check both target and root for Windows patterns?
A POSIX-style root like /mnt/d/repo might contain a Windows-absolute target D:\repo. The || condition ensures Windows rules apply whenever either path requires them. This supports WSL and Docker scenarios where path representations mix conventions.
How does Pi-Web prevent directory traversal through case variations?
On Windows, case folding normalizes variations like CONFIG and config to the same string. On POSIX, case differences create distinct paths. The containment check adds a mandatory separator after the root prefix, so /allowed cannot match /allowed-anything through prefix spoofing.
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 →