fix(fleet): enforce exact comms authority (#787)
All checks were successful
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful

This commit was merged in pull request #787.
This commit is contained in:
2026-07-16 00:32:23 +00:00
parent c1aecfabe9
commit adad486b6f
22 changed files with 3534 additions and 859 deletions

View File

@@ -0,0 +1,242 @@
import {
accessSync,
closeSync,
constants,
fstatSync,
lstatSync,
mkdirSync,
openSync,
readFileSync,
} from 'node:fs';
import { platform } from 'node:os';
import { dirname, isAbsolute, relative, resolve, sep } from 'node:path';
export interface SecureFileReadOptions {
root: string;
maxBytes?: number;
executable?: boolean;
}
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 openFileBeneathRoot(root: string, target: 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');
const rootChain = openDirectoryChain(canonicalRoot);
try {
let parentFd = rootChain.fd;
for (const component of components) {
try {
parentFd = openSync(
procDescriptorPath(parentFd, component),
constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW,
);
} catch (error) {
throw secureFilesystemError(
'path ancestor is a symbolic link, unavailable, or not a directory',
error,
);
}
rootChain.descriptors.push(parentFd);
if (!fstatSync(parentFd).isDirectory()) {
throw new Error('path ancestor is a symbolic link or not a directory');
}
}
let fd: number;
try {
fd = openSync(
procDescriptorPath(parentFd, fileName),
constants.O_RDONLY | constants.O_NONBLOCK | constants.O_NOFOLLOW,
);
} catch (error) {
throw secureFilesystemError('file is a symbolic link or unavailable', error);
}
rootChain.descriptors.push(fd);
return { fd, descriptors: rootChain.descriptors };
} catch (error) {
closeDescriptors(rootChain.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);
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);
}
}