ci/woodpecker/push/publish Pipeline failed
Co-authored-by: code-be-01 <[email protected]>
317 lines
12 KiB
TypeScript
317 lines
12 KiB
TypeScript
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);
|
|
}
|
|
}
|