fix(security): gate privileged file IPC with capabilities
This commit is contained in:
@@ -0,0 +1,120 @@
|
||||
import { mkdtempSync, mkdirSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
FileCapabilityStore,
|
||||
isTrustedFileIpcSender,
|
||||
publishCapabilityOutput,
|
||||
} from './file-capability';
|
||||
|
||||
describe('file capability boundary', () => {
|
||||
const directories: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const directory of directories.splice(0)) rmSync(directory, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function createFixture(): { directory: string; video: string; chat: string; output: string } {
|
||||
const directory = mkdtempSync(join(tmpdir(), 'tvm-capability-'));
|
||||
directories.push(directory);
|
||||
const video = join(directory, 'source.mp4');
|
||||
const chat = join(directory, 'source.chat.jsonl');
|
||||
writeFileSync(video, 'video');
|
||||
writeFileSync(chat, '{"message":"hello"}\n');
|
||||
return { directory, video, chat, output: join(directory, 'result.mp4') };
|
||||
}
|
||||
|
||||
it('accepts only the expected renderer owner and document URL', () => {
|
||||
const expectedUrl = 'file:///C:/app/src/index.html';
|
||||
expect(isTrustedFileIpcSender(17, expectedUrl, 17, `${expectedUrl}?language=de#cutter`)).toBe(true);
|
||||
expect(isTrustedFileIpcSender(17, expectedUrl, 18, expectedUrl)).toBe(false);
|
||||
expect(isTrustedFileIpcSender(17, expectedUrl, 17, 'file:///C:/app/src/forged.html')).toBe(false);
|
||||
expect(isTrustedFileIpcSender(17, expectedUrl, 17, 'https://attacker.invalid/')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects forged, wrong-owner, wrong-purpose, expired, and reused tokens', () => {
|
||||
const fixture = createFixture();
|
||||
let now = 1_000;
|
||||
const store = new FileCapabilityStore({ now: () => now, defaultTtlMs: 500 });
|
||||
const mergeInput = store.issue({ ownerId: 7, purpose: 'merge-input', path: fixture.video, kind: 'input-file', extensions: ['.mp4'] });
|
||||
|
||||
expect(() => store.consume('forged', 7, 'merge-input')).toThrow('Invalid file capability');
|
||||
expect(() => store.consume(mergeInput.token, 8, 'merge-input')).toThrow('Invalid file capability owner');
|
||||
expect(() => store.consume(mergeInput.token, 7, 'cutter-input')).toThrow('Invalid file capability purpose');
|
||||
expect(store.consume(mergeInput.token, 7, 'merge-input')).toBe(realpathSync.native(fixture.video));
|
||||
expect(() => store.consume(mergeInput.token, 7, 'merge-input')).toThrow('Invalid file capability');
|
||||
|
||||
const expired = store.issue({ ownerId: 7, purpose: 'chat-input', path: fixture.chat, kind: 'input-file', extensions: ['.chat.jsonl'] });
|
||||
now = 1_500;
|
||||
expect(() => store.resolve(expired.token, 7, 'chat-input')).toThrow('Expired file capability');
|
||||
});
|
||||
|
||||
it('binds canonical input and output paths to the allowed extension and semantics', () => {
|
||||
const fixture = createFixture();
|
||||
const store = new FileCapabilityStore();
|
||||
const aliasedInput = join(fixture.directory, 'nested', '..', 'source.mp4');
|
||||
mkdirSync(dirname(aliasedInput), { recursive: true });
|
||||
const input = store.issue({ ownerId: 3, purpose: 'cutter-input', path: aliasedInput, kind: 'input-file', extensions: ['mp4'] });
|
||||
expect(store.resolve(input.token, 3, 'cutter-input')).toBe(realpathSync.native(fixture.video));
|
||||
|
||||
expect(() => store.issue({ ownerId: 3, purpose: 'cutter-input', path: fixture.chat, kind: 'input-file', extensions: ['mp4'] })).toThrow('File extension is not allowed');
|
||||
expect(() => store.issue({ ownerId: 3, purpose: 'merge-output', path: join(fixture.directory, 'result.exe'), kind: 'output-file', extensions: ['mp4'] })).toThrow('File extension is not allowed');
|
||||
expect(() => store.issue({ ownerId: 3, purpose: 'merge-output', path: join(fixture.directory, 'missing', 'result.mp4'), kind: 'output-file', extensions: ['mp4'] })).toThrow('Output directory does not exist');
|
||||
const directoryTarget = join(fixture.directory, 'directory.mp4');
|
||||
mkdirSync(directoryTarget);
|
||||
expect(() => store.issue({ ownerId: 3, purpose: 'merge-output', path: directoryTarget, kind: 'output-file', extensions: ['mp4'] })).toThrow('Output target is not a file');
|
||||
|
||||
const output = store.issue({ ownerId: 3, purpose: 'merge-output', path: fixture.output, kind: 'output-file', extensions: ['mp4'] });
|
||||
expect(store.consume(output.token, 3, 'merge-output')).toBe(fixture.output);
|
||||
});
|
||||
|
||||
it('rejects an output capability that aliases a protected input', () => {
|
||||
const fixture = createFixture();
|
||||
const store = new FileCapabilityStore();
|
||||
const output = store.issue({ ownerId: 3, purpose: 'merge-output', path: fixture.video, kind: 'output-file', extensions: ['mp4'] });
|
||||
|
||||
expect(() => store.consume(output.token, 3, 'merge-output', [fixture.video])).toThrow('Output path conflicts with a protected input');
|
||||
});
|
||||
|
||||
it('detects canonical-path replacement after a capability is issued', () => {
|
||||
const fixture = createFixture();
|
||||
const store = new FileCapabilityStore();
|
||||
const input = store.issue({ ownerId: 3, purpose: 'cutter-input', path: fixture.video, kind: 'input-file', extensions: ['mp4'] });
|
||||
const replacement = join(fixture.directory, 'replacement.mp4');
|
||||
writeFileSync(replacement, 'replacement');
|
||||
rmSync(fixture.video);
|
||||
try {
|
||||
symlinkSync(replacement, fixture.video, 'file');
|
||||
} catch {
|
||||
writeFileSync(fixture.video, 'changed');
|
||||
}
|
||||
expect(() => store.resolve(input.token, 3, 'cutter-input')).toThrow('File capability path changed');
|
||||
});
|
||||
|
||||
it('never removes or replaces an existing destination when output production fails', async () => {
|
||||
const fixture = createFixture();
|
||||
writeFileSync(fixture.output, 'existing-user-file');
|
||||
|
||||
const success = await publishCapabilityOutput(fixture.output, async (partialPath) => {
|
||||
writeFileSync(partialPath, 'incomplete-merge');
|
||||
return false;
|
||||
});
|
||||
|
||||
expect(success).toBe(false);
|
||||
expect(readFileSync(fixture.output, 'utf8')).toBe('existing-user-file');
|
||||
});
|
||||
|
||||
it('atomically replaces the selected destination only after successful production', async () => {
|
||||
const fixture = createFixture();
|
||||
writeFileSync(fixture.output, 'existing-user-file');
|
||||
|
||||
const success = await publishCapabilityOutput(fixture.output, async (partialPath) => {
|
||||
writeFileSync(partialPath, 'complete-merge');
|
||||
return true;
|
||||
});
|
||||
|
||||
expect(success).toBe(true);
|
||||
expect(readFileSync(fixture.output, 'utf8')).toBe('complete-merge');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,206 @@
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { existsSync, realpathSync, renameSync, rmSync, statSync } from 'node:fs';
|
||||
import { basename, dirname, extname, isAbsolute, join, resolve } from 'node:path';
|
||||
|
||||
export type FileCapabilityPurpose =
|
||||
| 'cutter-input'
|
||||
| 'cutter-output'
|
||||
| 'merge-input'
|
||||
| 'merge-output'
|
||||
| 'chat-input'
|
||||
| 'config-import'
|
||||
| 'config-export'
|
||||
| 'runtime-export'
|
||||
| 'selected-folder'
|
||||
| 'open-file'
|
||||
| 'show-in-folder';
|
||||
|
||||
export type FileCapabilityKind = 'input-file' | 'output-file' | 'directory';
|
||||
|
||||
export interface FileCapabilityReference {
|
||||
token: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface FileIdentity {
|
||||
dev: number;
|
||||
ino: number;
|
||||
size: number;
|
||||
mtimeMs: number;
|
||||
}
|
||||
|
||||
interface FileCapabilityGrant {
|
||||
ownerId: number;
|
||||
purpose: FileCapabilityPurpose;
|
||||
path: string;
|
||||
kind: FileCapabilityKind;
|
||||
extensions: Set<string>;
|
||||
expiresAt: number;
|
||||
identity: FileIdentity | null;
|
||||
}
|
||||
|
||||
interface IssueFileCapabilityOptions {
|
||||
ownerId: number;
|
||||
purpose: FileCapabilityPurpose;
|
||||
path: string;
|
||||
kind: FileCapabilityKind;
|
||||
extensions?: string[];
|
||||
ttlMs?: number;
|
||||
}
|
||||
|
||||
interface FileCapabilityStoreOptions {
|
||||
now?: () => number;
|
||||
defaultTtlMs?: number;
|
||||
}
|
||||
|
||||
function normalizeExtension(extension: string): string {
|
||||
const normalized = extension.trim().toLowerCase();
|
||||
return normalized.startsWith('.') ? normalized : `.${normalized}`;
|
||||
}
|
||||
|
||||
function comparablePath(filePath: string): string {
|
||||
return process.platform === 'win32' ? filePath.toLocaleLowerCase('en-US') : filePath;
|
||||
}
|
||||
|
||||
function canonicalInputPath(filePath: string, kind: FileCapabilityKind): string {
|
||||
if (typeof filePath !== 'string' || !filePath || !isAbsolute(filePath)) throw new Error('File path must be absolute');
|
||||
if (!existsSync(filePath)) throw new Error(kind === 'directory' ? 'Directory does not exist' : 'Input file does not exist');
|
||||
const canonical = realpathSync.native(resolve(filePath));
|
||||
const stats = statSync(canonical);
|
||||
if (kind === 'directory' && !stats.isDirectory()) throw new Error('Capability path is not a directory');
|
||||
if (kind === 'input-file' && !stats.isFile()) throw new Error('Capability path is not a file');
|
||||
return canonical;
|
||||
}
|
||||
|
||||
function canonicalOutputPath(filePath: string): string {
|
||||
if (typeof filePath !== 'string' || !filePath || !isAbsolute(filePath)) throw new Error('File path must be absolute');
|
||||
const resolved = resolve(filePath);
|
||||
const parent = dirname(resolved);
|
||||
if (!existsSync(parent) || !statSync(parent).isDirectory()) throw new Error('Output directory does not exist');
|
||||
if (existsSync(resolved) && !statSync(resolved).isFile()) throw new Error('Output target is not a file');
|
||||
return join(realpathSync.native(parent), basename(resolved));
|
||||
}
|
||||
|
||||
function validateExtension(filePath: string, extensions: Set<string>): void {
|
||||
const lowerPath = filePath.toLowerCase();
|
||||
if (extensions.size > 0 && !Array.from(extensions).some((extension) => lowerPath.endsWith(extension))) throw new Error('File extension is not allowed');
|
||||
}
|
||||
|
||||
function getIdentity(filePath: string): FileIdentity {
|
||||
const stats = statSync(filePath);
|
||||
return { dev: stats.dev, ino: stats.ino, size: stats.size, mtimeMs: stats.mtimeMs };
|
||||
}
|
||||
|
||||
function identitiesMatch(left: FileIdentity, right: FileIdentity): boolean {
|
||||
if (left.dev !== right.dev || left.size !== right.size || left.mtimeMs !== right.mtimeMs) return false;
|
||||
return left.ino === 0 || right.ino === 0 || left.ino === right.ino;
|
||||
}
|
||||
|
||||
export function isTrustedFileIpcSender(expectedOwnerId: number, expectedUrl: string, actualOwnerId: number, actualUrl: string): boolean {
|
||||
if (actualOwnerId !== expectedOwnerId || typeof actualUrl !== 'string') return false;
|
||||
return actualUrl.split(/[?#]/, 1)[0] === expectedUrl;
|
||||
}
|
||||
|
||||
export class FileCapabilityStore {
|
||||
private readonly grants = new Map<string, FileCapabilityGrant>();
|
||||
private readonly now: () => number;
|
||||
private readonly defaultTtlMs: number;
|
||||
|
||||
constructor(options: FileCapabilityStoreOptions = {}) {
|
||||
this.now = options.now ?? Date.now;
|
||||
this.defaultTtlMs = options.defaultTtlMs ?? 15 * 60 * 1000;
|
||||
}
|
||||
|
||||
issue(options: IssueFileCapabilityOptions): FileCapabilityReference {
|
||||
const extensions = new Set((options.extensions ?? []).map(normalizeExtension));
|
||||
const canonical = options.kind === 'output-file'
|
||||
? canonicalOutputPath(options.path)
|
||||
: canonicalInputPath(options.path, options.kind);
|
||||
validateExtension(canonical, extensions);
|
||||
const ttlMs = options.ttlMs ?? this.defaultTtlMs;
|
||||
if (!Number.isFinite(ttlMs) || ttlMs <= 0) throw new Error('File capability lifetime is invalid');
|
||||
const token = randomBytes(32).toString('base64url');
|
||||
this.grants.set(token, {
|
||||
ownerId: options.ownerId,
|
||||
purpose: options.purpose,
|
||||
path: canonical,
|
||||
kind: options.kind,
|
||||
extensions,
|
||||
expiresAt: this.now() + ttlMs,
|
||||
identity: options.kind === 'input-file' ? getIdentity(canonical) : null,
|
||||
});
|
||||
return { token, name: basename(canonical) };
|
||||
}
|
||||
|
||||
resolve(token: string, ownerId: number, purpose: FileCapabilityPurpose): string {
|
||||
return this.resolveGrant(token, ownerId, purpose, false);
|
||||
}
|
||||
|
||||
consume(token: string, ownerId: number, purpose: FileCapabilityPurpose, protectedPaths: string[] = []): string {
|
||||
const resolved = this.resolveGrant(token, ownerId, purpose, false);
|
||||
for (const protectedPath of protectedPaths) {
|
||||
const canonicalProtected = existsSync(protectedPath)
|
||||
? realpathSync.native(resolve(protectedPath))
|
||||
: canonicalOutputPath(protectedPath);
|
||||
const samePath = comparablePath(resolved) === comparablePath(canonicalProtected);
|
||||
const sameFile = existsSync(resolved)
|
||||
&& existsSync(canonicalProtected)
|
||||
&& identitiesMatch(getIdentity(resolved), getIdentity(canonicalProtected));
|
||||
if (samePath || sameFile) throw new Error('Output path conflicts with a protected input');
|
||||
}
|
||||
this.grants.delete(token);
|
||||
return resolved;
|
||||
}
|
||||
|
||||
revoke(token: string): void {
|
||||
this.grants.delete(token);
|
||||
}
|
||||
|
||||
private resolveGrant(token: string, ownerId: number, purpose: FileCapabilityPurpose, consume: boolean): string {
|
||||
if (typeof token !== 'string' || !token) throw new Error('Invalid file capability');
|
||||
const grant = this.grants.get(token);
|
||||
if (!grant) throw new Error('Invalid file capability');
|
||||
if (grant.ownerId !== ownerId) throw new Error('Invalid file capability owner');
|
||||
if (grant.purpose !== purpose) throw new Error('Invalid file capability purpose');
|
||||
if (this.now() >= grant.expiresAt) {
|
||||
this.grants.delete(token);
|
||||
throw new Error('Expired file capability');
|
||||
}
|
||||
const currentPath = grant.kind === 'output-file'
|
||||
? canonicalOutputPath(grant.path)
|
||||
: canonicalInputPath(grant.path, grant.kind);
|
||||
validateExtension(currentPath, grant.extensions);
|
||||
if (comparablePath(currentPath) !== comparablePath(grant.path)) throw new Error('File capability path changed');
|
||||
if (grant.identity && !identitiesMatch(grant.identity, getIdentity(currentPath))) throw new Error('File capability path changed');
|
||||
if (consume) this.grants.delete(token);
|
||||
return currentPath;
|
||||
}
|
||||
}
|
||||
|
||||
export async function publishCapabilityOutput(outputPath: string, produce: (partialPath: string) => Promise<boolean>): Promise<boolean> {
|
||||
const canonicalOutput = canonicalOutputPath(outputPath);
|
||||
const extension = extname(canonicalOutput);
|
||||
const stem = basename(canonicalOutput, extension);
|
||||
const partialPath = join(dirname(canonicalOutput), `.${stem}.${process.pid}.${randomBytes(8).toString('hex')}.partial${extension}`);
|
||||
const backupPath = `${canonicalOutput}.${process.pid}.${randomBytes(8).toString('hex')}.backup`;
|
||||
let backupCreated = false;
|
||||
try {
|
||||
const produced = await produce(partialPath);
|
||||
if (!produced || !existsSync(partialPath) || !statSync(partialPath).isFile()) return false;
|
||||
if (existsSync(canonicalOutput)) {
|
||||
renameSync(canonicalOutput, backupPath);
|
||||
backupCreated = true;
|
||||
}
|
||||
try {
|
||||
renameSync(partialPath, canonicalOutput);
|
||||
} catch (error) {
|
||||
if (backupCreated && !existsSync(canonicalOutput)) renameSync(backupPath, canonicalOutput);
|
||||
throw error;
|
||||
}
|
||||
if (backupCreated) rmSync(backupPath, { force: true });
|
||||
return true;
|
||||
} finally {
|
||||
rmSync(partialPath, { force: true });
|
||||
if (backupCreated && existsSync(backupPath) && !existsSync(canonicalOutput)) renameSync(backupPath, canonicalOutput);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user