fix(fleet): bind secure reads to descriptors
All checks were successful
ci/woodpecker/pr/ci Pipeline was successful
All checks were successful
ci/woodpecker/pr/ci Pipeline was successful
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,42 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { chmodSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
chmodSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
renameSync,
|
||||
rmSync,
|
||||
symlinkSync,
|
||||
writeFileSync,
|
||||
type PathLike,
|
||||
} from 'node:fs';
|
||||
import type * as NodeFs from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
interface FilesystemRaceState {
|
||||
afterLstat?: (path: string) => void;
|
||||
afterOpen?: (path: string) => void;
|
||||
}
|
||||
|
||||
const filesystemRaceState = vi.hoisted<FilesystemRaceState>(() => ({}));
|
||||
|
||||
vi.mock('node:fs', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof NodeFs>();
|
||||
return {
|
||||
...actual,
|
||||
lstatSync: (path: PathLike) => {
|
||||
const result = actual.lstatSync(path);
|
||||
filesystemRaceState.afterLstat?.(String(path));
|
||||
return result;
|
||||
},
|
||||
openSync: (path: PathLike, flags: string | number, mode?: number) => {
|
||||
const fd = actual.openSync(path, flags, mode);
|
||||
filesystemRaceState.afterOpen?.(String(path));
|
||||
return fd;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import { assertCanonicalContainment, readRegularFileSecure } from './secure-file.js';
|
||||
|
||||
describe('secure file reads', () => {
|
||||
@@ -9,9 +44,15 @@ describe('secure file reads', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
root = mkdtempSync(join(tmpdir(), 'mosaic-secure-file-'));
|
||||
filesystemRaceState.afterLstat = undefined;
|
||||
filesystemRaceState.afterOpen = undefined;
|
||||
});
|
||||
|
||||
afterEach(() => rmSync(root, { recursive: true, force: true }));
|
||||
afterEach(() => {
|
||||
filesystemRaceState.afterLstat = undefined;
|
||||
filesystemRaceState.afterOpen = undefined;
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('rejects canonical path escape', () => {
|
||||
expect(() => assertCanonicalContainment(root, join(root, '..', 'outside'))).toThrow(
|
||||
@@ -40,6 +81,91 @@ describe('secure file reads', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps ancestor traversal bound when an opened directory is substituted', () => {
|
||||
const tools = join(root, 'tools');
|
||||
const displacedTools = join(root, 'tools.displaced');
|
||||
const external = join(root, 'external');
|
||||
const helper = join(tools, 'helper.sh');
|
||||
mkdirSync(tools);
|
||||
mkdirSync(external);
|
||||
writeFileSync(helper, 'trusted\n', { mode: 0o755 });
|
||||
writeFileSync(join(external, 'helper.sh'), 'external marker\n', { mode: 0o755 });
|
||||
|
||||
let substituted = false;
|
||||
filesystemRaceState.afterOpen = (openedPath: string): void => {
|
||||
if (substituted || !openedPath.startsWith('/proc/self/fd/')) return;
|
||||
if (openedPath.split('/').at(-1) !== 'tools') return;
|
||||
substituted = true;
|
||||
renameSync(tools, displacedTools);
|
||||
symlinkSync(external, tools);
|
||||
};
|
||||
|
||||
const result = readRegularFileSecure(helper, { root, executable: true });
|
||||
expect(substituted).toBe(true);
|
||||
expect(result.content.toString('utf8')).toBe('trusted\n');
|
||||
});
|
||||
|
||||
it('keeps root selection bound when the opened root is substituted', () => {
|
||||
const displacedRoot = `${root}.displaced`;
|
||||
const externalRoot = `${root}.external`;
|
||||
const helper = join(root, 'helper.sh');
|
||||
mkdirSync(externalRoot);
|
||||
writeFileSync(helper, 'trusted root\n', { mode: 0o755 });
|
||||
writeFileSync(join(externalRoot, 'helper.sh'), 'external root marker\n', { mode: 0o755 });
|
||||
|
||||
let substituted = false;
|
||||
filesystemRaceState.afterOpen = (openedPath: string): void => {
|
||||
if (substituted || !openedPath.startsWith('/proc/self/fd/')) return;
|
||||
const match = openedPath.match(/\/([^/]+)$/);
|
||||
if (match?.[1] !== root.split('/').filter(Boolean).at(-1)) return;
|
||||
substituted = true;
|
||||
renameSync(root, displacedRoot);
|
||||
symlinkSync(externalRoot, root);
|
||||
};
|
||||
|
||||
const result = readRegularFileSecure(helper, { root, executable: true });
|
||||
expect(substituted).toBe(true);
|
||||
expect(result.content.toString('utf8')).toBe('trusted root\n');
|
||||
filesystemRaceState.afterOpen = undefined;
|
||||
rmSync(root);
|
||||
renameSync(displacedRoot, root);
|
||||
});
|
||||
|
||||
it('keeps target read and execute validation bound to the opened file', () => {
|
||||
const file = join(root, 'helper.sh');
|
||||
const displaced = join(root, 'helper.displaced.sh');
|
||||
const external = join(root, 'external-helper.sh');
|
||||
writeFileSync(file, 'trusted target\n', { mode: 0o755 });
|
||||
writeFileSync(external, 'external target marker\n', { mode: 0o755 });
|
||||
|
||||
let substituted = false;
|
||||
filesystemRaceState.afterOpen = (openedPath: string): void => {
|
||||
if (substituted || !openedPath.startsWith('/proc/self/fd/')) return;
|
||||
if (openedPath.split('/').at(-1) !== 'helper.sh') return;
|
||||
substituted = true;
|
||||
renameSync(file, displaced);
|
||||
symlinkSync(external, file);
|
||||
};
|
||||
|
||||
const result = readRegularFileSecure(file, { root, executable: true });
|
||||
expect(substituted).toBe(true);
|
||||
expect(result.content.toString('utf8')).toBe('trusted target\n');
|
||||
});
|
||||
|
||||
it('uses a stable redacted executable error while retaining the error code', () => {
|
||||
const file = join(root, 'helper.sh');
|
||||
writeFileSync(file, '#!/bin/sh\n', { mode: 0o644 });
|
||||
|
||||
try {
|
||||
readRegularFileSecure(file, { root, executable: true });
|
||||
throw new Error('expected executable validation to fail');
|
||||
} catch (error) {
|
||||
expect(error).toMatchObject({ message: 'managed file is not executable', code: 'EACCES' });
|
||||
expect(String(error)).not.toContain('/proc/self/fd/');
|
||||
expect(String(error)).not.toContain(root);
|
||||
}
|
||||
});
|
||||
|
||||
it('uses effective-identity execute access after regular-file validation', () => {
|
||||
const file = join(root, 'helper.sh');
|
||||
writeFileSync(file, '#!/bin/sh\n', { mode: 0o644 });
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
openSync,
|
||||
readFileSync,
|
||||
} from 'node:fs';
|
||||
import { platform } from 'node:os';
|
||||
import { dirname, isAbsolute, relative, resolve, sep } from 'node:path';
|
||||
|
||||
export interface SecureFileReadOptions {
|
||||
@@ -30,6 +31,109 @@ function sameIdentity(
|
||||
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);
|
||||
@@ -99,43 +203,32 @@ export function readRegularFileSecure(
|
||||
path: string,
|
||||
options: SecureFileReadOptions,
|
||||
): SecureFileSnapshot {
|
||||
assertCanonicalContainment(options.root, path);
|
||||
assertNoSymlinkAncestors(path);
|
||||
const before = lstatSync(path);
|
||||
if (before.isSymbolicLink()) throw new Error(`file is a symbolic link: ${path}`);
|
||||
if (!before.isFile()) throw new Error(`file is not a regular file: ${path}`);
|
||||
if (options.maxBytes !== undefined && before.size > options.maxBytes) {
|
||||
throw new Error(`file exceeds ${options.maxBytes} bytes: ${path}`);
|
||||
}
|
||||
|
||||
const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
||||
const openedFile = openFileBeneathRoot(options.root, path);
|
||||
try {
|
||||
const opened = fstatSync(fd);
|
||||
if (!opened.isFile() || !sameIdentity(before, opened)) {
|
||||
throw new Error(`file changed during secure open: ${path}`);
|
||||
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) {
|
||||
accessSync(path, constants.X_OK);
|
||||
const afterAccess = lstatSync(path);
|
||||
if (
|
||||
afterAccess.isSymbolicLink() ||
|
||||
!afterAccess.isFile() ||
|
||||
!sameIdentity(opened, afterAccess)
|
||||
) {
|
||||
throw new Error(`file changed during executable access check: ${path}`);
|
||||
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(fd);
|
||||
const after = fstatSync(fd);
|
||||
const current = lstatSync(path);
|
||||
if (
|
||||
!after.isFile() ||
|
||||
!sameIdentity(opened, after) ||
|
||||
current.isSymbolicLink() ||
|
||||
!current.isFile() ||
|
||||
!sameIdentity(opened, current)
|
||||
) {
|
||||
throw new Error(`file changed during secure read: ${path}`);
|
||||
|
||||
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,
|
||||
@@ -144,6 +237,6 @@ export function readRegularFileSecure(
|
||||
ino: opened.ino,
|
||||
};
|
||||
} finally {
|
||||
closeSync(fd);
|
||||
closeDescriptors(openedFile.descriptors);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user