mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-09-25 15:25:44 +00:00
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:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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)));
|
||||
|
||||
@@ -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) };
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user