180 lines
6.2 KiB
TypeScript
180 lines
6.2 KiB
TypeScript
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', () => {
|
|
let root: string;
|
|
|
|
beforeEach(() => {
|
|
root = mkdtempSync(join(tmpdir(), 'mosaic-secure-file-'));
|
|
filesystemRaceState.afterLstat = undefined;
|
|
filesystemRaceState.afterOpen = undefined;
|
|
});
|
|
|
|
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(
|
|
'path escapes managed root',
|
|
);
|
|
});
|
|
|
|
it('rejects a symlink in a file ancestor', () => {
|
|
const external = join(root, 'external');
|
|
mkdirSync(external);
|
|
writeFileSync(join(external, 'file'), 'external\n');
|
|
symlinkSync(external, join(root, 'linked'));
|
|
|
|
expect(() => readRegularFileSecure(join(root, 'linked', 'file'), { root })).toThrow(
|
|
'path ancestor is a symbolic link',
|
|
);
|
|
});
|
|
|
|
it('rejects a symlink target', () => {
|
|
const external = join(root, 'external');
|
|
writeFileSync(external, 'external\n');
|
|
symlinkSync(external, join(root, 'linked-file'));
|
|
|
|
expect(() => readRegularFileSecure(join(root, 'linked-file'), { root })).toThrow(
|
|
'file is a symbolic link',
|
|
);
|
|
});
|
|
|
|
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 });
|
|
expect(() => readRegularFileSecure(file, { root, executable: true })).toThrow();
|
|
|
|
chmodSync(file, 0o755);
|
|
expect(readRegularFileSecure(file, { root, executable: true }).content.toString()).toBe(
|
|
'#!/bin/sh\n',
|
|
);
|
|
});
|
|
});
|