fix: tighten local read allowlist and gracefully skip whisper-runtime build without CMake

isAllowedLocalReadPath previously returned true for any existing path because of an existsSync fast-path, which made the read-local-file IPC handler and the local media URL policy effectively allow reading arbitrary files on disk. Drop the existsSync bypass so only paths under app-managed directories or paths that have been explicitly approved (via dialogs, exports, recording sessions, etc.) are accepted. Adjust the local media path policy tests to cover the new behaviour.

Also make build-whisper-runtime fall back to bundled artifacts when CMake is missing (mirroring build-windows-capture) so npm ci does not fail on machines without a C++ toolchain.
This commit is contained in:
Recordly Reviewer
2026-05-04 14:21:49 -04:00
parent 42c6cf458e
commit dce19d5209
3 changed files with 44 additions and 8 deletions
+16 -4
View File
@@ -47,13 +47,17 @@ describe("local media path policy", () => {
}
});
it("allows existing exported media files outside the session directories", async () => {
it("rejects existing media files outside allowed directories until they are approved", async () => {
const downloadsPath = path.join(tempRoot, "Downloads");
const exportPath = path.join(downloadsPath, "export-test.mp4");
await fs.mkdir(downloadsPath, { recursive: true });
await fs.writeFile(exportPath, "test-video");
const { isAllowedLocalMediaPath } = await import("./manager");
const { isAllowedLocalMediaPath, rememberApprovedLocalReadPath } = await import("./manager");
await expect(isAllowedLocalMediaPath(exportPath)).resolves.toBe(false);
await rememberApprovedLocalReadPath(exportPath);
await expect(isAllowedLocalMediaPath(exportPath)).resolves.toBe(true);
});
@@ -74,17 +78,25 @@ describe("local media path policy", () => {
await expect(isAllowedLocalMediaPath(pendingExportPath)).resolves.toBe(true);
});
it("approves media-server access for existing external files resolved through the URL policy", async () => {
it("approves media-server access for approved external files resolved through the URL policy", async () => {
const downloadsPath = path.join(tempRoot, "Downloads");
const videoPath = path.join(downloadsPath, "external-video.mp4");
await fs.mkdir(downloadsPath, { recursive: true });
await fs.writeFile(videoPath, "test-video");
const resolvedVideoPath = await fs.realpath(videoPath);
const { resolveApprovedLocalMediaPath } = await import("./manager");
const { resolveApprovedLocalMediaPath, rememberApprovedLocalReadPath } = await import(
"./manager"
);
const { isAllowedMediaPath } = await import("../../mediaServer");
// Unapproved external paths are rejected before they ever reach the media server.
expect(isAllowedMediaPath(videoPath)).toBe(false);
await expect(resolveApprovedLocalMediaPath(videoPath)).resolves.toBeNull();
// Once the user opts in (via dialog/export/etc.) the path is approved.
await rememberApprovedLocalReadPath(videoPath);
await expect(resolveApprovedLocalMediaPath(videoPath)).resolves.toBe(resolvedVideoPath);
expect(isAllowedMediaPath(videoPath)).toBe(true);
});
+5 -1
View File
@@ -54,8 +54,12 @@ export function isAllowedLocalReadPath(candidatePath: string) {
const allowedPrefixes = [RECORDINGS_DIR, USER_DATA_PATH, getAssetRootPath(), app.getPath("temp")];
const normalizedCandidatePath = normalizePath(candidatePath);
// Security: only allow paths under app-managed directories or paths the user
// has explicitly opted into (recording session sources, files chosen via
// dialog, app-produced exports). Previously this returned true for any
// existing file, which made the allowlist a no-op for read-local-file and
// the local media URL handler.
return (
existsSync(normalizedCandidatePath) ||
allowedPrefixes.some((prefix) => isPathInsideDirectory(normalizedCandidatePath, prefix)) ||
approvedLocalReadPaths.has(normalizedCandidatePath)
);
+23 -3
View File
@@ -361,15 +361,35 @@ async function stageRuntimeArtifacts(target, candidateDir, runtimeEntries) {
}
async function main() {
const targets = getTargetConfigs();
const cmake = findCmake();
if (!cmake) {
throw new Error(
"[build-whisper-runtime] CMake is required to build the bundled Whisper runtime.",
// Mirror build-windows-capture: if every target already has a staged
// runtime, postinstall is a no-op. This keeps `npm ci` working for
// contributors who do not have CMake installed and only need to run the
// app or tests against the bundled binaries.
const skipChecks = await Promise.all(targets.map((target) => shouldSkipBuild(target)));
if (skipChecks.every(Boolean)) {
console.log(
"[build-whisper-runtime] CMake not found; using bundled whisper runtime artifacts.",
);
return;
}
const missing = targets
.filter((_target, index) => !skipChecks[index])
.map((target) => target.archTag)
.join(", ");
console.warn(
`[build-whisper-runtime] CMake not found and no bundled runtime is staged for: ${missing}. ` +
"Auto-caption features that rely on whisper.cpp will be unavailable until you install CMake " +
"and rerun `npm run build:whisper-runtime`.",
);
return;
}
const sourceDir = await ensureSourceTree();
const targets = getTargetConfigs();
console.log(
`[build-whisper-runtime] Target architectures for ${process.platform}: ${targets.map((target) => target.archTag).join(", ")}`,