import { accessSync, closeSync, constants, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, readlinkSync, statSync, } from 'node:fs'; import { platform } from 'node:os'; import { basename, dirname, isAbsolute, relative, resolve, sep } from 'node:path'; export interface SecureFileReadOptions { root: string; maxBytes?: number; executable?: boolean; /** * Additional roots a symlink component may resolve into (stack#1380). * Default: only the managed root itself. Every symlink hop is validated — * containment under the root or one of these roots, current-user ownership, * no group/world-writable mode — and refusal stays the default for anything * else. Callers that operate the split-home layout pass the brain home so * the framework-created roster symlink resolves. */ symlinkTargetRoots?: string[]; } export interface SecureFileSnapshot { content: Buffer; mode: number; dev: number | bigint; ino: number | bigint; } function sameIdentity( left: { dev: number | bigint; ino: number | bigint }, right: { dev: number | bigint; ino: number | bigint }, ): boolean { return left.dev === right.dev && left.ino === right.ino; } function secureFilesystemError(message: string, cause: unknown): Error { const error = new Error(message); if (cause instanceof Error && 'code' in cause && typeof cause.code === 'string') { Object.defineProperty(error, 'code', { value: cause.code, enumerable: true }); } return error; } function closeDescriptors(descriptors: number[]): void { for (const fd of descriptors.reverse()) { try { closeSync(fd); } catch { // Best-effort cleanup must not replace the security decision already made. } } } function procDescriptorPath(fd: number, component?: string): string { const descriptor = `/proc/self/fd/${fd}`; return component === undefined ? descriptor : `${descriptor}/${component}`; } /** * Hold each directory while opening its child through Linux proc-fd. The only * symlink followed is the kernel-owned descriptor link; O_NOFOLLOW protects * every appended filesystem component from substitution. */ function openDirectoryChain(absoluteDirectory: string): { fd: number; descriptors: number[] } { if (platform() !== 'linux') { throw new Error('secure descriptor traversal is unsupported on this platform'); } const descriptors: number[] = []; try { let fd = openSync(sep, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW); descriptors.push(fd); for (const component of absoluteDirectory.split(sep).filter(Boolean)) { fd = openSync( procDescriptorPath(fd, component), constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW, ); descriptors.push(fd); if (!fstatSync(fd).isDirectory()) { throw new Error('secure descriptor traversal encountered a non-directory component'); } } return { fd, descriptors }; } catch (error) { closeDescriptors(descriptors); throw secureFilesystemError( 'secure descriptor traversal failed: symbolic link, unavailable, or not a directory', error, ); } } function containedUnder(root: string, target: string): boolean { const rel = relative(resolve(root), resolve(target)); return rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel) && rel !== ''; } const MAX_SYMLINK_HOPS = 40; /** * Resolve every symlink on `lexical` component-wise, validating each hop * (stack#1380 resolve-then-validate): the hop target must stay under one of * the sanctioned roots, must be owned by the current user (or root), and must * not be group- or world-writable. Returns a symlink-free absolute path. */ function resolveRealPath(lexical: string, sanctionedRoots: string[]): string { const hopTargets: string[] = []; let current: string = sep; for (const piece of resolve(lexical).split(sep).filter(Boolean)) { current = resolve(current, piece); for (let hops = 0; lstatSync(current).isSymbolicLink(); ) { if (++hops > MAX_SYMLINK_HOPS) { throw new Error(`symlink chain exceeds ${MAX_SYMLINK_HOPS} hops: ${lexical}`); } const linkTarget = readlinkSync(current); const absolute = resolve(dirname(current), linkTarget); if (!sanctionedRoots.some((root) => containedUnder(root, absolute))) { throw new Error( `symlink target escapes managed roots [${sanctionedRoots.join(', ')}]: ${absolute}`, ); } hopTargets.push(absolute); current = absolute; } } const uid = typeof process.getuid === 'function' ? process.getuid() : 0; for (const hop of hopTargets) { const stat = statSync(hop); if (stat.uid !== uid && stat.uid !== 0) { throw new Error(`symlink target is not owned by the current user: ${hop}`); } if (stat.mode & 0o022) { throw new Error(`symlink target is group- or world-writable: ${hop}`); } } return current; } function openFileBeneathRoot( root: string, target: string, symlinkTargetRoots: string[] = [], ): { fd: number; descriptors: number[] } { const canonicalRoot = resolve(root); const canonicalTarget = resolve(target); assertCanonicalContainment(canonicalRoot, canonicalTarget); const components = relative(canonicalRoot, canonicalTarget).split(sep).filter(Boolean); const fileName = components.pop(); if (fileName === undefined) throw new Error('managed file path names the managed root'); // stack#1380: resolve-then-validate. The lexical path must name the managed // root (above); symlink components are then resolved hop-by-hop under the // sanctioned roots (validated per hop), and the descriptor traversal walks // the symlink-free real path — keeping the O_NOFOLLOW chain as the race // guard for anything substituted after resolution. let realRoot: string; try { realRoot = resolveRealPath(canonicalRoot, [canonicalRoot]); } catch (error) { throw secureFilesystemError( 'secure descriptor traversal failed: symbolic link, unavailable, or not a directory', error, ); } const sanctioned = [realRoot, ...symlinkTargetRoots.map((extra) => resolve(extra))]; let realTarget: string; try { realTarget = resolveRealPath(canonicalTarget, sanctioned); } catch (error) { if (error instanceof Error && !('code' in error)) throw error; throw secureFilesystemError( 'secure descriptor traversal failed: symbolic link, unavailable, or not a directory', error, ); } if (!sanctioned.some((sr) => containedUnder(sr, realTarget) || resolve(sr) === realTarget)) { throw new Error( `resolved path escapes managed roots [${sanctioned.join(', ')}]: ${realTarget}`, ); } const chain = openDirectoryChain(dirname(realTarget)); try { let fd: number; try { fd = openSync( procDescriptorPath(chain.fd, basename(realTarget)), constants.O_RDONLY | constants.O_NONBLOCK | constants.O_NOFOLLOW, ); } catch (error) { throw secureFilesystemError('file is a symbolic link or unavailable', error); } chain.descriptors.push(fd); return { fd, descriptors: chain.descriptors }; } catch (error) { closeDescriptors(chain.descriptors); if (error instanceof Error) throw error; throw new Error('secure managed file open failed'); } } export function assertCanonicalContainment(root: string, target: string): void { const canonicalRoot = resolve(root); const canonicalTarget = resolve(target); const rel = relative(canonicalRoot, canonicalTarget); if (rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) { throw new Error(`path escapes managed root ${canonicalRoot}: ${canonicalTarget}`); } } /** Reject every symlink from the filesystem root through the target's parent. */ export function assertNoSymlinkAncestors(target: string): void { const absolute = resolve(target); const parent = dirname(absolute); const pieces = parent.split(sep).filter(Boolean); let cursor: string = sep; for (const piece of pieces) { cursor = resolve(cursor, piece); const stat = lstatSync(cursor); if (stat.isSymbolicLink()) throw new Error(`path ancestor is a symbolic link: ${cursor}`); if (!stat.isDirectory()) throw new Error(`path ancestor is not a directory: ${cursor}`); } } export function ensureManagedDirectory(root: string, directory: string): void { assertCanonicalContainment(root, directory); const canonicalRoot = resolve(root); const canonicalDirectory = resolve(directory); assertNoSymlinkAncestors(canonicalRoot); try { const rootStat = lstatSync(canonicalRoot); if (rootStat.isSymbolicLink() || !rootStat.isDirectory()) { throw new Error(`managed root is not a real directory: ${canonicalRoot}`); } } catch (error) { if (!(error instanceof Error && 'code' in error && error.code === 'ENOENT')) throw error; mkdirSync(canonicalRoot, { mode: 0o700 }); const rootStat = lstatSync(canonicalRoot); if (rootStat.isSymbolicLink() || !rootStat.isDirectory()) { throw new Error(`managed root creation was redirected: ${canonicalRoot}`); } } const rel = relative(canonicalRoot, canonicalDirectory); let cursor = canonicalRoot; for (const piece of rel.split(sep).filter(Boolean)) { cursor = resolve(cursor, piece); try { const stat = lstatSync(cursor); if (stat.isSymbolicLink()) throw new Error(`path ancestor is a symbolic link: ${cursor}`); if (!stat.isDirectory()) throw new Error(`path ancestor is not a directory: ${cursor}`); } catch (error) { if (!(error instanceof Error && 'code' in error && error.code === 'ENOENT')) throw error; mkdirSync(cursor, { mode: 0o700 }); const created = lstatSync(cursor); if (!created.isDirectory() || created.isSymbolicLink()) { throw new Error(`managed directory creation was redirected: ${cursor}`); } } } } /** * Read a regular file through an O_NOFOLLOW descriptor. The inode is checked * before and after access/read, and executable access is tested against the * already-open descriptor so path replacement cannot redirect the check. */ export function readRegularFileSecure( path: string, options: SecureFileReadOptions, ): SecureFileSnapshot { const openedFile = openFileBeneathRoot(options.root, path, options.symlinkTargetRoots ?? []); try { const opened = fstatSync(openedFile.fd); if (!opened.isFile()) throw new Error('managed file is not a regular file'); if (options.maxBytes !== undefined && opened.size > options.maxBytes) { throw new Error(`managed file exceeds ${options.maxBytes} bytes`); } if (options.executable) { try { accessSync(procDescriptorPath(openedFile.fd), constants.X_OK); } catch (error) { throw secureFilesystemError('managed file is not executable', error); } const afterAccess = fstatSync(openedFile.fd); if (!afterAccess.isFile() || !sameIdentity(opened, afterAccess)) { throw new Error('managed file changed during executable access check'); } } const content = readFileSync(openedFile.fd); const after = fstatSync(openedFile.fd); if (!after.isFile() || !sameIdentity(opened, after)) { throw new Error('managed file changed during secure read'); } if (options.maxBytes !== undefined && content.byteLength > options.maxBytes) { throw new Error(`managed file exceeds ${options.maxBytes} bytes`); } return { content, mode: Number(opened.mode), dev: opened.dev, ino: opened.ino, }; } finally { closeDescriptors(openedFile.descriptors); } }