From c8c9386346d054cae98366c0d45b3f92150975f8 Mon Sep 17 00:00:00 2001 From: wiiiii123 Date: Tue, 21 Apr 2026 19:00:29 +0700 Subject: [PATCH 1/2] fix(media): approve loopback video paths when minting URLs --- electron/ipc/project/manager.test.ts | 14 ++++++ electron/ipc/project/manager.ts | 16 +++++++ electron/ipc/register/project.ts | 15 ++---- electron/mediaServer.test.ts | 72 ++++++++++++++++++++++++++++ electron/mediaServer.ts | 2 +- 5 files changed, 108 insertions(+), 11 deletions(-) create mode 100644 electron/mediaServer.test.ts diff --git a/electron/ipc/project/manager.test.ts b/electron/ipc/project/manager.test.ts index 375d0f9d0..33606541c 100644 --- a/electron/ipc/project/manager.test.ts +++ b/electron/ipc/project/manager.test.ts @@ -73,4 +73,18 @@ 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 () => { + 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 { resolveApprovedLocalMediaPath } = await import("./manager"); + const { isAllowedMediaPath } = await import("../../mediaServer"); + + expect(isAllowedMediaPath(videoPath)).toBe(false); + await expect(resolveApprovedLocalMediaPath(videoPath)).resolves.toBe(videoPath); + expect(isAllowedMediaPath(videoPath)).toBe(true); + }); }); diff --git a/electron/ipc/project/manager.ts b/electron/ipc/project/manager.ts index e13127f63..67e3dead7 100644 --- a/electron/ipc/project/manager.ts +++ b/electron/ipc/project/manager.ts @@ -83,6 +83,22 @@ export async function rememberApprovedLocalReadPath(filePath?: string | null) { } } +export async function resolveApprovedLocalMediaPath(candidatePath: string): Promise { + const normalizedCandidatePath = normalizePath(candidatePath); + const realPath = await fs.realpath(normalizedCandidatePath).catch(() => null); + + if (!realPath) { + return null; + } + + if (!(await isAllowedLocalMediaPath(realPath))) { + return null; + } + + await rememberApprovedLocalReadPath(realPath); + return realPath; +} + export async function replaceApprovedSessionLocalReadPaths(filePaths: Array) { approvedLocalReadPaths.clear(); await Promise.all(filePaths.map((filePath) => rememberApprovedLocalReadPath(filePath))); diff --git a/electron/ipc/register/project.ts b/electron/ipc/register/project.ts index 29f138dc9..94c25b561 100644 --- a/electron/ipc/register/project.ts +++ b/electron/ipc/register/project.ts @@ -20,7 +20,6 @@ import { import { getProjectsDir, getProjectThumbnailPath, - isAllowedLocalMediaPath, isPathInsideDirectory, isTrustedProjectPath, listProjectLibraryEntries, @@ -28,6 +27,7 @@ import { loadProjectFromPath, persistRecordingsDirectorySetting, replaceApprovedSessionLocalReadPaths, + resolveApprovedLocalMediaPath, rememberRecentProject, saveRecentProjectPaths, saveProjectThumbnail, @@ -622,15 +622,10 @@ export function registerProjectHandlers() { if (!baseUrl || !filePath) { return { success: false as const }; } - const normalized = path.resolve(filePath); - let resolved: string; - try { - resolved = await fs.realpath(normalized); - } catch { - return { success: false as const }; - } - if (!(await isAllowedLocalMediaPath(resolved))) { - console.warn(`[get-local-media-url] Blocked disallowed path: ${resolved}`); + const resolved = await resolveApprovedLocalMediaPath(filePath); + if (!resolved) { + const normalized = path.resolve(filePath); + console.warn(`[get-local-media-url] Blocked disallowed path: ${normalized}`); return { success: false as const }; } return { success: true as const, url: buildMediaUrl(baseUrl, resolved) }; diff --git a/electron/mediaServer.test.ts b/electron/mediaServer.test.ts new file mode 100644 index 000000000..f0effc57b --- /dev/null +++ b/electron/mediaServer.test.ts @@ -0,0 +1,72 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +describe("media server path policy", () => { + let tempRoot: string; + let appDataPath: string; + let userDataPath: string; + let tempPath: string; + let appPath: string; + + beforeEach(async () => { + tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "recordly-media-server-")); + appDataPath = path.join(tempRoot, "AppData"); + userDataPath = path.join(tempRoot, "UserData"); + tempPath = path.join(tempRoot, "Temp"); + appPath = path.join(tempRoot, "App"); + + await Promise.all( + [appDataPath, userDataPath, tempPath, appPath].map((dirPath) => + fs.mkdir(dirPath, { recursive: true }), + ), + ); + + vi.resetModules(); + vi.doMock("electron", () => ({ + app: { + isPackaged: false, + getAppPath: () => appPath, + getPath: (name: string) => { + if (name === "appData") return appDataPath; + if (name === "userData") return userDataPath; + if (name === "temp") return tempPath; + return tempRoot; + }, + setPath: () => undefined, + }, + })); + }); + + afterEach(async () => { + vi.resetModules(); + vi.doUnmock("electron"); + if (tempRoot) { + await fs.rm(tempRoot, { recursive: true, force: true }); + } + }); + + it("rejects existing media files outside the session directories until they are approved", async () => { + const downloadsPath = path.join(tempRoot, "Downloads"); + const videoPath = path.join(downloadsPath, "personal-video.mp4"); + await fs.mkdir(downloadsPath, { recursive: true }); + await fs.writeFile(videoPath, "test-video"); + + const { isAllowedMediaPath } = await import("./mediaServer"); + const { rememberApprovedLocalReadPath } = await import("./ipc/project/manager"); + + expect(isAllowedMediaPath(videoPath)).toBe(false); + + await rememberApprovedLocalReadPath(videoPath); + + expect(isAllowedMediaPath(videoPath)).toBe(true); + }); + + it("rejects missing media files outside the allowed directories", async () => { + const missingPath = path.join(tempRoot, "Downloads", "missing.mp4"); + const { isAllowedMediaPath } = await import("./mediaServer"); + + expect(isAllowedMediaPath(missingPath)).toBe(false); + }); +}); diff --git a/electron/mediaServer.ts b/electron/mediaServer.ts index 2f790a971..ff7837882 100644 --- a/electron/mediaServer.ts +++ b/electron/mediaServer.ts @@ -33,7 +33,7 @@ async function resolveRealPath(filePath: string): Promise { } } -function isAllowedMediaPath(realPath: string): boolean { +export function isAllowedMediaPath(realPath: string): boolean { return approvedLocalReadPaths.has(realPath); } From 7e87356cf8b38d3d206f39534ac065d8e4684b15 Mon Sep 17 00:00:00 2001 From: wiiiii123 Date: Tue, 21 Apr 2026 19:15:02 +0700 Subject: [PATCH 2/2] fix(media): restrict loopback approvals to supported files --- electron/ipc/project/manager.test.ts | 13 +++++++++++++ electron/ipc/project/manager.ts | 6 ++++++ electron/mediaServer.ts | 19 +------------------ electron/mediaTypes.ts | 23 +++++++++++++++++++++++ 4 files changed, 43 insertions(+), 18 deletions(-) create mode 100644 electron/mediaTypes.ts diff --git a/electron/ipc/project/manager.test.ts b/electron/ipc/project/manager.test.ts index 33606541c..0236b55b4 100644 --- a/electron/ipc/project/manager.test.ts +++ b/electron/ipc/project/manager.test.ts @@ -87,4 +87,17 @@ describe("local media path policy", () => { await expect(resolveApprovedLocalMediaPath(videoPath)).resolves.toBe(videoPath); expect(isAllowedMediaPath(videoPath)).toBe(true); }); + + it("rejects existing non-media files when resolving local media URLs", async () => { + const downloadsPath = path.join(tempRoot, "Downloads"); + const textPath = path.join(downloadsPath, "notes.txt"); + await fs.mkdir(downloadsPath, { recursive: true }); + await fs.writeFile(textPath, "not media"); + + const { resolveApprovedLocalMediaPath } = await import("./manager"); + const { isAllowedMediaPath } = await import("../../mediaServer"); + + await expect(resolveApprovedLocalMediaPath(textPath)).resolves.toBeNull(); + expect(isAllowedMediaPath(textPath)).toBe(false); + }); }); diff --git a/electron/ipc/project/manager.ts b/electron/ipc/project/manager.ts index 67e3dead7..473f3a63d 100644 --- a/electron/ipc/project/manager.ts +++ b/electron/ipc/project/manager.ts @@ -4,6 +4,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { app } from "electron"; import { RECORDINGS_DIR, USER_DATA_PATH } from "../../appPaths"; +import { isSupportedLocalMediaPath } from "../../mediaTypes"; import { PROJECT_FILE_EXTENSION, LEGACY_PROJECT_FILE_EXTENSIONS, @@ -91,6 +92,11 @@ export async function resolveApprovedLocalMediaPath(candidatePath: string): Prom return null; } + const stat = await fs.stat(realPath).catch(() => null); + if (!stat?.isFile() || !isSupportedLocalMediaPath(realPath)) { + return null; + } + if (!(await isAllowedLocalMediaPath(realPath))) { return null; } diff --git a/electron/mediaServer.ts b/electron/mediaServer.ts index ff7837882..201e58aa6 100644 --- a/electron/mediaServer.ts +++ b/electron/mediaServer.ts @@ -3,28 +3,11 @@ import fs from "node:fs/promises"; import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; import path from "node:path"; import { approvedLocalReadPaths } from "./ipc/state"; - -const MEDIA_MIME_TYPES: Record = { - ".mp4": "video/mp4", - ".webm": "video/webm", - ".mov": "video/quicktime", - ".mkv": "video/x-matroska", - ".avi": "video/x-msvideo", - ".wav": "audio/wav", - ".mp3": "audio/mpeg", - ".ogg": "audio/ogg", - ".png": "image/png", - ".jpg": "image/jpeg", - ".jpeg": "image/jpeg", -}; +import { getMediaContentType } from "./mediaTypes"; let mediaServerBaseUrl: string | null = null; let mediaServerStartPromise: Promise | null = null; -function getMediaContentType(filePath: string): string { - return MEDIA_MIME_TYPES[path.extname(filePath).toLowerCase()] ?? "application/octet-stream"; -} - async function resolveRealPath(filePath: string): Promise { try { return await fs.realpath(path.resolve(filePath)); diff --git a/electron/mediaTypes.ts b/electron/mediaTypes.ts new file mode 100644 index 000000000..5c4307b63 --- /dev/null +++ b/electron/mediaTypes.ts @@ -0,0 +1,23 @@ +import path from "node:path"; + +export const MEDIA_CONTENT_TYPES: Record = { + ".mp4": "video/mp4", + ".webm": "video/webm", + ".mov": "video/quicktime", + ".mkv": "video/x-matroska", + ".avi": "video/x-msvideo", + ".wav": "audio/wav", + ".mp3": "audio/mpeg", + ".ogg": "audio/ogg", + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", +}; + +export function getMediaContentType(filePath: string): string { + return MEDIA_CONTENT_TYPES[path.extname(filePath).toLowerCase()] ?? "application/octet-stream"; +} + +export function isSupportedLocalMediaPath(filePath: string): boolean { + return path.extname(filePath).toLowerCase() in MEDIA_CONTENT_TYPES; +}