diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx
index 4fb4193..ec5ad0d 100644
--- a/src/components/video-editor/SettingsPanel.tsx
+++ b/src/components/video-editor/SettingsPanel.tsx
@@ -34,11 +34,11 @@ import { Slider } from "@/components/ui/slider";
import { Switch } from "@/components/ui/switch";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useScopedT } from "@/contexts/I18nContext";
-import { getAssetPath } from "@/lib/assetPath";
import { WEBCAM_LAYOUT_PRESETS } from "@/lib/compositeLayout";
import type { ExportFormat, ExportQuality, GifFrameRate, GifSizePreset } from "@/lib/exporter";
import { GIF_FRAME_RATES, GIF_SIZE_PRESETS } from "@/lib/exporter";
import { cn } from "@/lib/utils";
+import { resolveImageWallpaperUrl, WALLPAPER_PATHS } from "@/lib/wallpaper";
import { type AspectRatio, isPortraitAspectRatio } from "@/utils/aspectRatioUtils";
import { getTestId } from "@/utils/getTestId";
import { AnnotationSettingsPanel } from "./AnnotationSettingsPanel";
@@ -123,11 +123,6 @@ function CustomSpeedInput({
);
}
-const WALLPAPER_COUNT = 18;
-const WALLPAPER_RELATIVE = Array.from(
- { length: WALLPAPER_COUNT },
- (_, i) => `wallpapers/wallpaper${i + 1}.jpg`,
-);
const GRADIENTS = [
"linear-gradient( 111.6deg, rgba(114,167,232,1) 9.4%, rgba(253,129,82,1) 43.9%, rgba(253,129,82,1) 54.8%, rgba(249,202,86,1) 86.3% )",
"linear-gradient(120deg, #d4fc79 0%, #96e6a1 100%)",
@@ -334,10 +329,10 @@ export function SettingsPanel({
let mounted = true;
(async () => {
try {
- const resolved = await Promise.all(WALLPAPER_RELATIVE.map((p) => getAssetPath(p)));
+ const resolved = await Promise.all(WALLPAPER_PATHS.map((p) => resolveImageWallpaperUrl(p)));
if (mounted) setWallpaperPaths(resolved);
} catch (_err) {
- if (mounted) setWallpaperPaths(WALLPAPER_RELATIVE.map((p) => `/${p}`));
+ if (mounted) setWallpaperPaths([...WALLPAPER_PATHS]);
}
})();
return () => {
@@ -526,7 +521,7 @@ export function SettingsPanel({
setCustomImages((prev) => prev.filter((img) => img !== imageUrl));
// If the removed image was selected, clear selection
if (selected === imageUrl) {
- onWallpaperChange(wallpaperPaths[0] || WALLPAPER_RELATIVE[0]);
+ onWallpaperChange(wallpaperPaths[0] || WALLPAPER_PATHS[0]);
}
};
@@ -1146,42 +1141,41 @@ export function SettingsPanel({
);
})}
- {(wallpaperPaths.length > 0
- ? wallpaperPaths
- : WALLPAPER_RELATIVE.map((p) => `/${p}`)
- ).map((path) => {
- const isSelected = (() => {
- if (!selected) return false;
- if (selected === path) return true;
- try {
- const clean = (s: string) =>
- s.replace(/^file:\/\//, "").replace(/^\//, "");
- if (clean(selected).endsWith(clean(path))) return true;
- if (clean(path).endsWith(clean(selected))) return true;
- } catch {
- // Best-effort comparison; fallback to strict match.
- }
- return false;
- })();
- return (
-
onWallpaperChange(path)}
- role="button"
- />
- );
- })}
+ {(wallpaperPaths.length > 0 ? wallpaperPaths : WALLPAPER_PATHS).map(
+ (path) => {
+ const isSelected = (() => {
+ if (!selected) return false;
+ if (selected === path) return true;
+ try {
+ const clean = (s: string) =>
+ s.replace(/^file:\/\//, "").replace(/^\//, "");
+ if (clean(selected).endsWith(clean(path))) return true;
+ if (clean(path).endsWith(clean(selected))) return true;
+ } catch {
+ // Best-effort comparison; fallback to strict match.
+ }
+ return false;
+ })();
+ return (
+
onWallpaperChange(path)}
+ role="button"
+ />
+ );
+ },
+ )}
diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx
index 3694d9e..0a03bf1 100644
--- a/src/components/video-editor/VideoEditor.tsx
+++ b/src/components/video-editor/VideoEditor.tsx
@@ -1568,7 +1568,7 @@ export default function VideoEditor() {
} catch (error) {
console.error("Export error:", error);
if (error instanceof BackgroundLoadError) {
- const message = t("errors.exportBackgroundLoadFailed", { url: error.url });
+ const message = t("errors.exportBackgroundLoadFailed", { url: error.displayUrl });
setExportError(message);
toast.error(message);
} else {
diff --git a/src/components/video-editor/VideoPlayback.tsx b/src/components/video-editor/VideoPlayback.tsx
index db69310..d356012 100644
--- a/src/components/video-editor/VideoPlayback.tsx
+++ b/src/components/video-editor/VideoPlayback.tsx
@@ -1185,8 +1185,13 @@ const VideoPlayback = forwardRef
(
if (mounted) setResolvedWallpaper(classified.value);
return;
}
- const resolved = await resolveImageWallpaperUrl(classified.path);
- if (mounted) setResolvedWallpaper(resolved);
+ try {
+ const resolved = await resolveImageWallpaperUrl(classified.path);
+ if (mounted) setResolvedWallpaper(resolved);
+ } catch (err) {
+ console.warn("[VideoPlayback] wallpaper resolve failed:", err);
+ if (mounted) setResolvedWallpaper(null);
+ }
})();
return () => {
mounted = false;
diff --git a/src/components/video-editor/projectPersistence.ts b/src/components/video-editor/projectPersistence.ts
index f9640e4..6d8f689 100644
--- a/src/components/video-editor/projectPersistence.ts
+++ b/src/components/video-editor/projectPersistence.ts
@@ -2,7 +2,7 @@ import { normalizeBlurColor, normalizeBlurType } from "@/lib/blurEffects";
import type { ExportFormat, ExportQuality, GifFrameRate, GifSizePreset } from "@/lib/exporter";
import type { ProjectMedia } from "@/lib/recordingSession";
import { normalizeProjectMedia } from "@/lib/recordingSession";
-import { DEFAULT_WALLPAPER } from "@/lib/wallpaper";
+import { DEFAULT_WALLPAPER, WALLPAPER_PATHS } from "@/lib/wallpaper";
import { ASPECT_RATIOS, type AspectRatio, isPortraitAspectRatio } from "@/utils/aspectRatioUtils";
import {
type AnnotationRegion,
@@ -38,13 +38,9 @@ import {
type ZoomRegion,
} from "./types";
-const WALLPAPER_COUNT = 18;
const VALID_BLUR_SHAPES = new Set(["rectangle", "oval", "freehand"] as const);
-export const WALLPAPER_PATHS = Array.from(
- { length: WALLPAPER_COUNT },
- (_, i) => `/wallpapers/wallpaper${i + 1}.jpg`,
-);
+export { WALLPAPER_PATHS };
export const PROJECT_VERSION = 2;
diff --git a/src/lib/assetPath.ts b/src/lib/assetPath.ts
index 8188de5..7ba1015 100644
--- a/src/lib/assetPath.ts
+++ b/src/lib/assetPath.ts
@@ -1,9 +1,22 @@
+export class UnsafeAssetPathError extends Error {
+ constructor(segment: string) {
+ super(`Unsafe asset path segment: ${segment}`);
+ this.name = "UnsafeAssetPathError";
+ }
+}
+
function encodeRelativeAssetPath(relativePath: string): string {
return relativePath
.replace(/^\/+/, "")
.split("/")
.filter(Boolean)
- .map((part) => encodeURIComponent(part))
+ .map((part) => {
+ const decoded = decodeURIComponent(part);
+ if (decoded === "." || decoded === "..") {
+ throw new UnsafeAssetPathError(decoded);
+ }
+ return encodeURIComponent(decoded);
+ })
.join("/");
}
@@ -16,7 +29,6 @@ export async function getAssetPath(relativePath: string): Promise {
try {
if (typeof window !== "undefined") {
- // If running in a dev server (http/https), prefer the web-served path
if (
window.location &&
window.location.protocol &&
@@ -32,11 +44,12 @@ export async function getAssetPath(relativePath: string): Promise {
}
}
}
- } catch {
- // ignore and use fallback
+ } catch (err) {
+ if (err instanceof UnsafeAssetPathError) {
+ throw err;
+ }
}
- // Fallback for web/dev server: public/wallpapers are served at '/wallpapers/...'
return `/${encodedRelativePath}`;
}
diff --git a/src/lib/exporter/gifExporter.browser.test.ts b/src/lib/exporter/gifExporter.browser.test.ts
index 5a69468..1d96076 100644
--- a/src/lib/exporter/gifExporter.browser.test.ts
+++ b/src/lib/exporter/gifExporter.browser.test.ts
@@ -79,6 +79,10 @@ describe("GifExporter (real browser)", () => {
cropRegion: { x: 0, y: 0, width: 1, height: 1 },
});
- await expect(exporter.export()).rejects.toBeInstanceOf(BackgroundLoadError);
+ const rejection = exporter.export();
+ await expect(rejection).rejects.toBeInstanceOf(BackgroundLoadError);
+ await expect(rejection).rejects.toMatchObject({
+ url: expect.stringContaining("does-not-exist"),
+ });
});
});
diff --git a/src/lib/exporter/videoExporter.browser.test.ts b/src/lib/exporter/videoExporter.browser.test.ts
index ae8c7bc..cca896f 100644
--- a/src/lib/exporter/videoExporter.browser.test.ts
+++ b/src/lib/exporter/videoExporter.browser.test.ts
@@ -77,6 +77,10 @@ describe("VideoExporter (real browser)", () => {
cropRegion: { x: 0, y: 0, width: 1, height: 1 },
});
- await expect(exporter.export()).rejects.toBeInstanceOf(BackgroundLoadError);
+ const rejection = exporter.export();
+ await expect(rejection).rejects.toBeInstanceOf(BackgroundLoadError);
+ await expect(rejection).rejects.toMatchObject({
+ url: expect.stringContaining("does-not-exist"),
+ });
});
});
diff --git a/src/lib/exporter/videoExporter.ts b/src/lib/exporter/videoExporter.ts
index 0ea1ec6..cc8b7cf 100644
--- a/src/lib/exporter/videoExporter.ts
+++ b/src/lib/exporter/videoExporter.ts
@@ -84,7 +84,6 @@ export class VideoExporter {
}
if (normalizedError instanceof BackgroundLoadError) {
- this.cleanup();
throw normalizedError;
}
diff --git a/src/lib/wallpaper.test.ts b/src/lib/wallpaper.test.ts
index dbb5e3c..f4fe08e 100644
--- a/src/lib/wallpaper.test.ts
+++ b/src/lib/wallpaper.test.ts
@@ -1,54 +1,109 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { UnsafeAssetPathError } from "./assetPath";
import {
BackgroundLoadError,
classifyWallpaper,
DEFAULT_WALLPAPER,
resolveImageWallpaperUrl,
+ WALLPAPER_COUNT,
+ WALLPAPER_PATHS,
} from "./wallpaper";
+describe("WALLPAPER_PATHS", () => {
+ it("contains WALLPAPER_COUNT entries", () => {
+ expect(WALLPAPER_PATHS).toHaveLength(WALLPAPER_COUNT);
+ });
+
+ it("DEFAULT_WALLPAPER is WALLPAPER_PATHS[0]", () => {
+ expect(DEFAULT_WALLPAPER).toBe(WALLPAPER_PATHS[0]);
+ });
+});
+
describe("classifyWallpaper", () => {
- it("classifies hex color", () => {
+ it("hex color", () => {
expect(classifyWallpaper("#1a1a2e")).toEqual({ kind: "color", value: "#1a1a2e" });
});
- it("classifies linear gradient", () => {
- const value = "linear-gradient(90deg, red, blue)";
- expect(classifyWallpaper(value)).toEqual({ kind: "gradient", value });
+ it("rgb() color", () => {
+ expect(classifyWallpaper("rgb(1, 2, 3)")).toEqual({ kind: "color", value: "rgb(1, 2, 3)" });
});
- it("classifies radial gradient", () => {
- const value = "radial-gradient(circle, red, blue)";
- expect(classifyWallpaper(value)).toEqual({ kind: "gradient", value });
+ it("rgba() color", () => {
+ expect(classifyWallpaper("rgba(1, 2, 3, 0.5)")).toEqual({
+ kind: "color",
+ value: "rgba(1, 2, 3, 0.5)",
+ });
});
- it("classifies leading-slash image path", () => {
+ it("hsl() color", () => {
+ expect(classifyWallpaper("hsl(180, 50%, 50%)")).toEqual({
+ kind: "color",
+ value: "hsl(180, 50%, 50%)",
+ });
+ });
+
+ it("oklch() color", () => {
+ expect(classifyWallpaper("oklch(50% 0.1 180)")).toEqual({
+ kind: "color",
+ value: "oklch(50% 0.1 180)",
+ });
+ });
+
+ it("linear gradient", () => {
+ const v = "linear-gradient(90deg, red, blue)";
+ expect(classifyWallpaper(v)).toEqual({ kind: "gradient", value: v });
+ });
+
+ it("radial gradient", () => {
+ const v = "radial-gradient(circle, red, blue)";
+ expect(classifyWallpaper(v)).toEqual({ kind: "gradient", value: v });
+ });
+
+ it("conic gradient", () => {
+ const v = "conic-gradient(red, blue)";
+ expect(classifyWallpaper(v)).toEqual({ kind: "gradient", value: v });
+ });
+
+ it("leading-slash image path", () => {
expect(classifyWallpaper("/wallpapers/wallpaper1.jpg")).toEqual({
kind: "image",
path: "/wallpapers/wallpaper1.jpg",
});
});
- it("classifies http URL as image", () => {
+ it("http URL as image", () => {
expect(classifyWallpaper("https://example.com/bg.jpg")).toEqual({
kind: "image",
path: "https://example.com/bg.jpg",
});
});
- it("classifies file:// URL as image", () => {
+ it("file:// URL as image", () => {
expect(classifyWallpaper("file:///tmp/bg.jpg")).toEqual({
kind: "image",
path: "file:///tmp/bg.jpg",
});
});
- it("classifies data URI as image", () => {
+ it("data URI as image", () => {
expect(classifyWallpaper("data:image/png;base64,AAA")).toEqual({
kind: "image",
path: "data:image/png;base64,AAA",
});
});
+ it("named color falls back to color", () => {
+ expect(classifyWallpaper("red")).toEqual({ kind: "color", value: "red" });
+ });
+
+ it("empty string falls back to black", () => {
+ expect(classifyWallpaper("")).toEqual({ kind: "color", value: "#000000" });
+ });
+
+ it("trims whitespace", () => {
+ expect(classifyWallpaper(" #abcdef ")).toEqual({ kind: "color", value: "#abcdef" });
+ });
+
it("DEFAULT_WALLPAPER classifies as image", () => {
expect(classifyWallpaper(DEFAULT_WALLPAPER)).toEqual({
kind: "image",
@@ -70,46 +125,64 @@ describe("resolveImageWallpaperUrl", () => {
vi.unstubAllGlobals();
});
- it("passes through http URL unchanged", async () => {
+ it("passes through http URL", async () => {
expect(await resolveImageWallpaperUrl("http://example.com/bg.jpg")).toBe(
"http://example.com/bg.jpg",
);
});
- it("passes through https URL unchanged", async () => {
+ it("passes through https URL", async () => {
expect(await resolveImageWallpaperUrl("https://example.com/bg.jpg")).toBe(
"https://example.com/bg.jpg",
);
});
- it("passes through file:// URL unchanged", async () => {
+ it("passes through file:// URL", async () => {
expect(await resolveImageWallpaperUrl("file:///tmp/bg.jpg")).toBe("file:///tmp/bg.jpg");
});
- it("passes through data URI unchanged", async () => {
+ it("passes through data URI", async () => {
const uri = "data:image/png;base64,AAAA";
expect(await resolveImageWallpaperUrl(uri)).toBe(uri);
});
- it("resolves leading-slash path via http dev server fallback", async () => {
+ it("resolves leading-slash wallpaper path via http fallback", async () => {
expect(await resolveImageWallpaperUrl("/wallpapers/wallpaper1.jpg")).toBe(
"/wallpapers/wallpaper1.jpg",
);
});
- it("resolves bare relative path via http dev server fallback", async () => {
+ it("resolves bare relative wallpaper path", async () => {
expect(await resolveImageWallpaperUrl("wallpapers/wallpaper1.jpg")).toBe(
"/wallpapers/wallpaper1.jpg",
);
});
- it("encodes path segments with special characters", async () => {
+ it("encodes special characters in path segments", async () => {
expect(await resolveImageWallpaperUrl("/wallpapers/my image.jpg")).toBe(
"/wallpapers/my%20image.jpg",
);
});
- it("resolves via electronAPI when not http protocol", async () => {
+ it("rejects image paths outside /wallpapers/", async () => {
+ await expect(resolveImageWallpaperUrl("/etc/passwd")).rejects.toBeInstanceOf(
+ BackgroundLoadError,
+ );
+ });
+
+ it("rejects traversal attempts", async () => {
+ await expect(resolveImageWallpaperUrl("/wallpapers/../etc/passwd")).rejects.toBeInstanceOf(
+ UnsafeAssetPathError,
+ );
+ });
+
+ it("rejects percent-encoded traversal", async () => {
+ await expect(resolveImageWallpaperUrl("/wallpapers/%2e%2e/app.asar")).rejects.toBeInstanceOf(
+ UnsafeAssetPathError,
+ );
+ });
+
+ it("resolves via electronAPI when not http", async () => {
vi.stubGlobal("window", {
...globalThis.window,
location: { protocol: "file:" },
@@ -122,7 +195,7 @@ describe("resolveImageWallpaperUrl", () => {
);
});
- it("electronAPI branch appends trailing slash to base if missing", async () => {
+ it("electronAPI branch appends trailing slash if missing", async () => {
vi.stubGlobal("window", {
...globalThis.window,
location: { protocol: "file:" },
@@ -151,12 +224,21 @@ describe("resolveImageWallpaperUrl", () => {
describe("BackgroundLoadError", () => {
it("carries the failing URL and is instanceof Error", () => {
- const err = new BackgroundLoadError("file:///missing.jpg");
+ const err = new BackgroundLoadError("/home/user/secret/wallpaper.jpg");
expect(err).toBeInstanceOf(Error);
expect(err).toBeInstanceOf(BackgroundLoadError);
- expect(err.url).toBe("file:///missing.jpg");
+ expect(err.url).toBe("/home/user/secret/wallpaper.jpg");
expect(err.name).toBe("BackgroundLoadError");
- expect(err.message).toContain("file:///missing.jpg");
+ });
+
+ it("displayUrl hides parent directories to avoid leaking PII", () => {
+ const err = new BackgroundLoadError("file:///home/enrique/projects/openscreen/wallpaper1.jpg");
+ expect(err.displayUrl).toBe("wallpaper1.jpg");
+ });
+
+ it("displayUrl abbreviates data URIs", () => {
+ const err = new BackgroundLoadError("data:image/png;base64,AAA");
+ expect(err.displayUrl).toBe("data:…");
});
it("preserves cause when provided", () => {
diff --git a/src/lib/wallpaper.ts b/src/lib/wallpaper.ts
index f949177..449d2e2 100644
--- a/src/lib/wallpaper.ts
+++ b/src/lib/wallpaper.ts
@@ -1,22 +1,42 @@
import { getAssetPath } from "@/lib/assetPath";
-export const DEFAULT_WALLPAPER = "/wallpapers/wallpaper1.jpg";
+export const WALLPAPER_COUNT = 18;
+
+export const WALLPAPER_PATHS: readonly string[] = Array.from(
+ { length: WALLPAPER_COUNT },
+ (_, i) => `/wallpapers/wallpaper${i + 1}.jpg`,
+);
+
+export const DEFAULT_WALLPAPER = WALLPAPER_PATHS[0];
export type WallpaperClassification =
| { kind: "color"; value: string }
| { kind: "gradient"; value: string }
| { kind: "image"; path: string };
+const GRADIENT_RE = /^(linear|radial|conic)-gradient\(/;
+const COLOR_FUNC_RE = /^(rgb|rgba|hsl|hsla|hwb|lab|lch|oklab|oklch|color)\(/;
+const IMAGE_URL_RE = /^(\/|https?:\/\/|file:\/\/|data:)/;
+
export function classifyWallpaper(value: string): WallpaperClassification {
- if (value.startsWith("#")) {
- return { kind: "color", value };
+ const trimmed = value.trim();
+ if (trimmed === "") {
+ return { kind: "color", value: "#000000" };
}
- if (value.startsWith("linear-gradient") || value.startsWith("radial-gradient")) {
- return { kind: "gradient", value };
+ if (trimmed.startsWith("#") || COLOR_FUNC_RE.test(trimmed)) {
+ return { kind: "color", value: trimmed };
}
- return { kind: "image", path: value };
+ if (GRADIENT_RE.test(trimmed)) {
+ return { kind: "gradient", value: trimmed };
+ }
+ if (IMAGE_URL_RE.test(trimmed)) {
+ return { kind: "image", path: trimmed };
+ }
+ return { kind: "color", value: trimmed };
}
+const ALLOWED_IMAGE_PREFIX = "/wallpapers/";
+
export async function resolveImageWallpaperUrl(imagePath: string): Promise {
if (
imagePath.startsWith("http://") ||
@@ -26,8 +46,14 @@ export async function resolveImageWallpaperUrl(imagePath: string): Promise