import * as fs from "node:fs"; import * as path from "node:path"; export function isSafePathId(value: string): boolean { return /^[A-Za-z0-9_-]+$/.test(value); } export function assertSafePathId(kind: string, value: string): string { if (!isSafePathId(value)) throw new Error(`Invalid ${kind}: ${value}`); return value; } /** * Resolve `targetPath` against `baseDir` and verify the result stays inside it. * * @deprecated This returns a non-canonical `resolved` path and only validates * the final resolved location, so callers can follow symlink components * (TOCTOU risk). Prefer {@link resolveRealContainedPath}, which performs an * O_NOFOLLOW ancestor walk plus post-realpath revalidation. The internal * callers in this module (resolveRealContainedPath / resolveContainedRelativePath) * still use this for the initial containment check by design. */ export function resolveContainedPath(baseDir: string, targetPath: string): string { if (targetPath.includes("\0")) { throw new Error(`Security: path contains null byte`); } const base = path.resolve(baseDir); const resolved = path.isAbsolute(targetPath) ? path.resolve(targetPath) : path.resolve(base, targetPath); // Normalize BOTH paths to canonical form on ALL platforms. This resolves // symlinks so that a base and target that refer to the same physical dir // via different paths (Windows 8.3 short-name; macOS /var → /private/var) // compare equal. Without this, a legitimately-contained target under an // OS-managed symlink is wrongly rejected as "outside" the base. const baseNorm = resolveCanonicalPath(base); const resolvedNorm = resolveCanonicalPath(resolved); const relative = process.platform === "win32" ? path.relative(baseNorm.toLowerCase(), resolvedNorm.toLowerCase()) : path.relative(baseNorm, resolvedNorm); if (relative.startsWith("..") || path.isAbsolute(relative)) throw new Error(`Path is outside ${baseDir}: ${targetPath}`); return resolved; } /** * Platform-agnostic canonical path resolution. Resolves symlinks (via * realpathSync) to normalize paths so the same physical location always * yields the same string regardless of how it was referenced. * * - Windows: delegates to resolveWindowsCanonical (canonical LONG-NAME form, * resolving 8.3 short-name aliases like RUNNER~1 → runneradmin). * - POSIX: uses realpathSync, which resolves system symlinks such as the * macOS /var → /private/var mapping. * * For non-existent paths (write targets), walks up to the deepest existing * ancestor and joins the remaining components, so the canonical prefix is * still comparable. */ function resolveCanonicalPath(p: string): string { if (process.platform === "win32") return resolveWindowsCanonical(p); try { return fs.realpathSync(p); } catch { const parts: string[] = []; let current = p; while (current !== path.dirname(current)) { try { const real = fs.realpathSync(current); let acc = real; for (let i = parts.length - 1; i >= 0; i--) acc = path.join(acc, parts[i]); return acc; } catch { /* keep walking up */ } parts.push(path.basename(current)); current = path.dirname(current); } return p; } } /** * On Windows, resolve a path to its canonical (long-name) form. * Walks up ancestors until finding one that exists, then joins back down. * This handles paths where intermediate directories don't exist yet but * their ancestors do (and may use short-name aliases). * * Uses fs.realpathSync.native (canonical long-name form) as the primary * resolver, falling back to non-native realpathSync if .native fails. */ function resolveWindowsCanonical(p: string): string { try { // Use the NATIVE realpath to resolve to canonical LONG-NAME form. // On Windows, fs.realpathSync.native resolves 8.3 short-name aliases // (e.g. RUNNER~1) to long-name (runneradmin). This is essential for // containment checks: base (from cwd, often long-name) and target // (from os.tmpdir()/mkdtempSync, often short-name) must normalize to // the SAME form or a contained target is wrongly rejected as "outside". const real = fs.realpathSync.native(p); // Guard against NTFS internal paths (e.g. C:\$Extend\$Deleted) if (real.includes("$Extend") || real.includes("$Deleted")) throw new Error("NTFS internal path"); return real; } catch { // Fallback: try realpathSync (non-native) which may succeed where .native fails try { const real = fs.realpathSync(p); return real; } catch { /* proceed to ancestor walk */ } // Walk up to find the deepest existing ancestor const parts: string[] = []; let current = p; while (current !== path.dirname(current)) { try { let real: string; try { real = fs.realpathSync.native(current); } catch { real = fs.realpathSync(current); } // Guard against NTFS internal paths if (real.includes("$Extend") || real.includes("$Deleted")) throw new Error("NTFS internal path"); // Found existing ancestor — join with remaining parts in reverse order // (parts were pushed bottom-up, so iterate from last to first) for (let i = parts.length - 1; i >= 0; i--) { real = path.join(real, parts[i]); } return real; } catch { /* keep walking */ } parts.push(path.basename(current)); current = path.dirname(current); } // Couldn't resolve any ancestor — return original return p; } } /** * Resolve a target path to its real (symlink-resolved) absolute path while * guaranteeing the result stays inside `baseDir`. * * ## Security model — asymmetric ancestor handling * * `baseDir` and `targetPath` are validated with different policies because * they play different roles: * * - **baseDir** (the container): all ancestors MUST exist and MUST NOT be * symlinks. We refuse to operate if any component is missing or symlinked, * because a symlinked container could point the caller outside the * intended trust boundary (e.g. `/var/run -> /run` resolving into an * attacker-controlled directory). * * - **targetPath** (the contained file): the FINAL component may be * non-existent (for write operations creating a new file) and EXISTING * ancestors of the target may also be missing. We DO require that any * ancestor that DOES exist must not be a symlink — an attacker who can * create a directory in the container must not be able to redirect the * file being created. * * This asymmetry is intentional: callers that need to create a new file * pass a non-existent targetPath. Callers that operate on an existing file * get full symlink protection. Callers MUST NOT pass a symlinked * intermediate component; if you need to, use `resolveContainedPath` * instead (which only checks the resolved path, not the chain). * * Throws on: * - null byte in targetPath * - targetPath resolves outside baseDir * - any existing ancestor (base or target) is a symlink * - baseDir itself does not exist * * Returns the resolved real path on success, or the resolved (but not * realpathed) path when the target does not exist yet. * * NOTE: There is a race condition window between validation and use where an * attacker could create a directory component after validation but before the * file is created. Callers MUST create parent directories atomically * (e.g., mkdirSync with { recursive: true }) and use O_CREAT | O_NOFOLLOW | O_EXCL * for atomic file creation, as atomicWriteFile does. This ensures the entire * operation is atomic and prevents TOCTOU attacks. */ export function resolveRealContainedPath(baseDir: string, targetPath: string): string { if (targetPath.includes("\0")) { throw new Error(`Security: path contains null byte`); } const resolved = resolveContainedPath(baseDir, targetPath); // Open baseDir with O_NOFOLLOW to atomically validate no symlinks in the path. // O_NOFOLLOW makes the open fail with ELOOP if any path component is a symlink. let baseFd: number | undefined; try { baseFd = fs.openSync(baseDir, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW); } catch (error) { const errCode = (error as NodeJS.ErrnoException).code; if (errCode === "ENOENT") { // baseDir doesn't exist yet — create it and retry try { fs.mkdirSync(baseDir, { recursive: true }); baseFd = fs.openSync(baseDir, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW); } catch (retryError) { throw new Error( `Cannot open base directory ${baseDir}: ${retryError instanceof Error ? retryError.message : String(retryError)}`, ); } } else if (errCode === "ELOOP") { // On macOS, system directories like /var → /private/var contain symlinks. // If baseDir is under such a path, resolve through realpath and retry. if (process.platform === "darwin") { try { const realBaseDir = fs.realpathSync(baseDir); if (realBaseDir !== baseDir) { baseFd = fs.openSync(realBaseDir, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW); baseDir = realBaseDir; // update for later use // Fall through to fstatSync below } } catch { /* throw original */ } } if (baseFd === undefined) throw new Error("Refusing to resolve: baseDir path contains a symlink: " + baseDir); } else { throw new Error(`Cannot open base directory ${baseDir}: ${error instanceof Error ? error.message : String(error)}`); } } let realBase: string; try { const stat = fs.fstatSync(baseFd); if (!stat.isDirectory()) throw new Error(`baseDir ${baseDir} is not a directory`); // Use regular realpathSync (not .native) to preserve input path form. // On Windows CI, .native always returns long-name form (runneradmin) // while non-native preserves short-name (RUNNER~1). Using non-native // ensures the result matches what the caller passed in. realBase = fs.realpathSync(baseDir); } catch (error) { // baseDir MUST exist and be resolvable for the containment guarantee to hold. // Callers creating new directories must create baseDir atomically (e.g., // mkdirSync with { recursive: true }) BEFORE calling this function, and use // O_NOFOLLOW|O_CREAT|O_EXCL for the actual file creation to ensure atomicity. // The safe-paths validation and the file creation are two separate operations // with a gap between them — callers must close this gap with atomic primitives. throw new Error(`Cannot resolve real path of base directory ${baseDir}: ${error instanceof Error ? error.message : String(error)}`); } finally { fs.closeSync(baseFd); } // Walk the ancestor chain of the resolved target path, using O_NOFOLLOW // on each ancestor to atomically validate none are symlinks. const O_NOFOLLOW = fs.constants.O_NOFOLLOW; const O_RDONLY = fs.constants.O_RDONLY; const resolvedParts = resolved.split(path.sep); let resolvedAccumulated = ""; if (resolvedParts[0] === "") resolvedAccumulated = "/"; // Unix root for (let i = 1; i < resolvedParts.length; i++) { if (resolvedParts[i] === "") continue; resolvedAccumulated = path.join(resolvedAccumulated, resolvedParts[i]); try { const fd = fs.openSync(resolvedAccumulated, O_RDONLY | O_NOFOLLOW); fs.closeSync(fd); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ELOOP") { // On macOS, /var → /private/var, /tmp → /private/tmp, /etc → /private/etc // are system symlinks managed by the OS. Allow them. if (process.platform === "darwin") { const resolvedSymlink = resolvedAccumulated; const knownDarwinSymlinks = ["/var", "/tmp", "/etc", "/private/var", "/private/tmp", "/private/etc"]; // Use prefix match to handle subdirectories like /var/folders/... if (knownDarwinSymlinks.some((s) => resolvedSymlink === s || resolvedSymlink.startsWith(s + "/"))) continue; } throw new Error("Refusing to resolve: target path ancestor is a symlink: " + resolvedAccumulated); } // EPERM on Windows when opening a directory — skip validation if ((error as NodeJS.ErrnoException).code === "EPERM" && process.platform === "win32") continue; // ENOENT means component doesn't exist — that's OK. Only existing symlinks // are a security risk (symlinks to attacker-controlled targets). Non-existent // paths can be created by the caller and don't pose a symlink risk. if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; // For the final component (target itself), ENOENT is expected for non-existent targets. if (i === resolvedParts.length - 1) continue; } } // Open the target with O_NOFOLLOW to catch any symlinks. // ENOENT is acceptable for write operations — the file may not exist yet. let targetFd: number; try { targetFd = fs.openSync(resolved, O_RDONLY | O_NOFOLLOW); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ELOOP") throw new Error("Refusing to resolve: target path is a symlink: " + resolved); // EPERM on Windows when opening a directory — treat as non-existent if ((error as NodeJS.ErrnoException).code === "EPERM" && process.platform === "win32") return resolved; if ((error as NodeJS.ErrnoException).code === "ENOENT") { // Target doesn't exist yet — that's OK for write operations. // All ancestors have been validated above (no symlinks). // The caller will create the file with atomic primitives. return resolved; } throw new Error(`Cannot open ${resolved}: ${error instanceof Error ? error.message : String(error)}`); } let realTarget: string; try { // Use regular realpathSync (not .native) to preserve input path form. realTarget = fs.realpathSync(resolved); } catch (targetError) { if ((targetError as NodeJS.ErrnoException).code === "ENOENT") { // Target doesn't exist yet — this is OK for write operations. // Return the resolved path so the caller can create the file. // We already validated all ancestors are not symlinks above. return resolved; } throw new Error( `Cannot resolve real path of ${resolved}: ${targetError instanceof Error ? targetError.message : String(targetError)}`, ); } finally { fs.closeSync(targetFd); } // Re-validate the ancestor chain of the resolved path to catch any TOCTOU // races that occurred between the initial O_NOFOLLOW validation and the // realpathSync call. An attacker could have replaced a validated ancestor // with a symlink during this window. // // Skip the final path component (realTarget itself) — we just successfully // realpathSync'd it, so it exists. Re-validating it can spuriously fail on // Windows where the resolved path uses short-name (8.3) form that // openSync cannot reopen, or where the realpathSync result differs in // case/separator form from the original. // // Walk via path.dirname which is portable across all platforms and // correctly handles extended-length (\\?\), UNC (\\server\share), and // short-name paths on Windows without manual parsing. let ancestor = path.dirname(realTarget); while (ancestor && ancestor !== path.dirname(ancestor)) { try { const fd = fs.openSync(ancestor, O_RDONLY | O_NOFOLLOW); fs.closeSync(fd); } catch (error) { const errCode = (error as NodeJS.ErrnoException).code; if (errCode === "ELOOP") throw new Error("Refusing to resolve: TOCTOU race detected, path became a symlink: " + ancestor); // Windows: EPERM can occur when opening system directories (e.g. C:\) // or NTFS internal paths ($Extend/$Deleted). Skip and continue walking. if (process.platform === "win32" && errCode === "EPERM") { if (ancestor.includes("$Extend") || ancestor.includes("$Deleted")) { // NTFS internal path — stop walking, we've reached the filesystem root break; } // System directory — continue walking up ancestor = path.dirname(ancestor); continue; } if (errCode !== "ENOENT") throw error; // ENOENT on an ancestor of realTarget after realpathSync is concerning // — the path existed when we validated it but now doesn't. This could // indicate a race or attack. For safety, treat this as an error. throw new Error( `Cannot validate resolved path: ${ancestor} disappeared after realpathSync: ${error instanceof Error ? error.message : String(error)}`, ); } ancestor = path.dirname(ancestor); } // Verify the resolved real path is still within baseDir. // On Windows, realpathSync.native may return different short/long-name forms // for the same physical directory depending on how the path was opened. // Use resolveWindowsCanonical (same as resolveContainedPath) to normalize // both paths consistently before comparison. if (process.platform === "win32") { const normBase = resolveWindowsCanonical(realBase).replace(/\\/g, "/").toLowerCase(); const normTarget = resolveWindowsCanonical(realTarget).replace(/\\/g, "/").toLowerCase(); if (!normTarget.startsWith(normBase + "/") && normBase !== normTarget) { throw new Error(`Path is outside ${baseDir}: ${targetPath}`); } } else { const relative = path.relative(realBase, realTarget); if (relative.startsWith("..") || path.isAbsolute(relative)) throw new Error(`Path is outside ${baseDir}: ${targetPath}`); } return realTarget; } export function resolveContainedRelativePath(baseDir: string, relativePath: string, kind = "path"): string { if (relativePath.includes("\0")) { throw new Error(`Security: path contains null byte: ${kind}`); } const normalized = relativePath.replace(/\\/g, "/").replace(/^\.\/+/, ""); // Detect Windows absolute paths (C:\, \\server\share) that path.isAbsolute may miss after normalization if (/^[A-Za-z]:/.test(normalized)) throw new Error(`Invalid ${kind}: ${relativePath}`); if (!normalized || normalized.split("/").some((segment) => segment === "..") || path.isAbsolute(normalized)) throw new Error(`Invalid ${kind}: ${relativePath}`); return resolveContainedPath(baseDir, path.resolve(baseDir, normalized)); }