Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions electron/ipc/project/manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,4 +73,31 @@ 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);
});

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);
});
});
22 changes: 22 additions & 0 deletions electron/ipc/project/manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -83,6 +84,27 @@ export async function rememberApprovedLocalReadPath(filePath?: string | null) {
}
}

export async function resolveApprovedLocalMediaPath(candidatePath: string): Promise<string | null> {
const normalizedCandidatePath = normalizePath(candidatePath);
const realPath = await fs.realpath(normalizedCandidatePath).catch(() => null);

if (!realPath) {
return null;
}

const stat = await fs.stat(realPath).catch(() => null);
if (!stat?.isFile() || !isSupportedLocalMediaPath(realPath)) {
return null;
}

if (!(await isAllowedLocalMediaPath(realPath))) {
return null;
}

await rememberApprovedLocalReadPath(realPath);
return realPath;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

export async function replaceApprovedSessionLocalReadPaths(filePaths: Array<string | null | undefined>) {
approvedLocalReadPaths.clear();
await Promise.all(filePaths.map((filePath) => rememberApprovedLocalReadPath(filePath)));
Expand Down
15 changes: 5 additions & 10 deletions electron/ipc/register/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,14 @@ import {
import {
getProjectsDir,
getProjectThumbnailPath,
isAllowedLocalMediaPath,
isPathInsideDirectory,
isTrustedProjectPath,
listProjectLibraryEntries,
loadRecentProjectPaths,
loadProjectFromPath,
persistRecordingsDirectorySetting,
replaceApprovedSessionLocalReadPaths,
resolveApprovedLocalMediaPath,
rememberRecentProject,
saveRecentProjectPaths,
saveProjectThumbnail,
Expand Down Expand Up @@ -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) };
Expand Down
72 changes: 72 additions & 0 deletions electron/mediaServer.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
21 changes: 2 additions & 19 deletions electron/mediaServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> = {
".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<string> | null = null;

function getMediaContentType(filePath: string): string {
return MEDIA_MIME_TYPES[path.extname(filePath).toLowerCase()] ?? "application/octet-stream";
}

async function resolveRealPath(filePath: string): Promise<string | null> {
try {
return await fs.realpath(path.resolve(filePath));
Expand All @@ -33,7 +16,7 @@ async function resolveRealPath(filePath: string): Promise<string | null> {
}
}

function isAllowedMediaPath(realPath: string): boolean {
export function isAllowedMediaPath(realPath: string): boolean {
return approvedLocalReadPaths.has(realPath);
}

Expand Down
23 changes: 23 additions & 0 deletions electron/mediaTypes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import path from "node:path";

export const MEDIA_CONTENT_TYPES: Record<string, string> = {
".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;
}