fix(security): gate privileged file IPC with capabilities

This commit is contained in:
Sucukdeluxe
2026-08-11 22:51:47 +02:00
parent de05539422
commit a79af3f3fe
10 changed files with 662 additions and 144 deletions
+120
View File
@@ -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');
});
});
+206
View File
@@ -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);
}
}