Merge pull request #308 from meiiie/fix/local-media-url-approval

fix(media): approve loopback video paths when minting URLs
This commit is contained in:
webadderall
2026-04-22 17:18:33 +10:00
committed by GitHub
6 changed files with 151 additions and 29 deletions
+27
View File
@@ -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
View File
@@ -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,
@@ -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;
}
export async function replaceApprovedSessionLocalReadPaths(filePaths: Array<string | null | undefined>) {
approvedLocalReadPaths.clear();
await Promise.all(filePaths.map((filePath) => rememberApprovedLocalReadPath(filePath)));
+5 -10
View File
@@ -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) };
+72
View File
@@ -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);
});
});
+2 -19
View File
@@ -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));
@@ -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);
}
+23
View File
@@ -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;
}