fix(fleet): enforce exact comms authority (#787)
This commit was merged in pull request #787.
This commit is contained in:
179
packages/mosaic/src/fleet/secure-file.spec.ts
Normal file
179
packages/mosaic/src/fleet/secure-file.spec.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
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',
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user