From cfb5402f34db161cae80d77a17cb589d6548c9b1 Mon Sep 17 00:00:00 2001 From: Tarulata Priya Date: Sun, 30 Aug 2026 13:11:30 +0530 Subject: [PATCH] fix(security): sanitize and validate URL recipe injection #1352 --- src/hooks/useVideoEditor.ts | 31 +++++++++++++++++++++---------- src/lib/editorPersistence.ts | 36 ++++++++++++++++++++++++++++++++++-- src/lib/types.ts | 11 +++++++++++ 3 files changed, 66 insertions(+), 12 deletions(-) diff --git a/src/hooks/useVideoEditor.ts b/src/hooks/useVideoEditor.ts index ab67dcf8..9f45b39a 100644 --- a/src/hooks/useVideoEditor.ts +++ b/src/hooks/useVideoEditor.ts @@ -211,16 +211,6 @@ export function useVideoEditor() { return track; }, [addTrack]); - const updateRecipe = useCallback((patch: Partial) => { - setRecipe((prev) => { - const next = { ...prev, ...patch }; - // GIF has no audio — force keepAudio off - if (next.format === "gif") { - next.keepAudio = false; - } - return next; - }); -}, []); const isValidValue = (key: keyof EditRecipe, val: any): boolean => { switch (key) { case "preset": @@ -254,6 +244,23 @@ export function useVideoEditor() { } }; + const updateRecipe = useCallback((patch: Partial) => { + setRecipe((prev) => { + const validated: Partial = {}; + for (const [key, val] of Object.entries(patch)) { + if (isValidValue(key as keyof EditRecipe, val)) { + (validated as any)[key] = val; + } + } + const next = { ...prev, ...validated }; + // GIF has no audio — force keepAudio off + if (next.format === "gif") { + next.keepAudio = false; + } + return next; + }); + }, []); + useEffect(() => { if (typeof window === "undefined") return; @@ -280,6 +287,10 @@ export function useVideoEditor() { if (decoded) { setRecipe(migratePersistedRecipe(decoded)); return; + } else { + const url = new URL(window.location.href); + url.searchParams.delete("settings"); + window.history.replaceState(null, "", url.toString()); } } diff --git a/src/lib/editorPersistence.ts b/src/lib/editorPersistence.ts index 4b51537d..1abc8ba6 100644 --- a/src/lib/editorPersistence.ts +++ b/src/lib/editorPersistence.ts @@ -1,5 +1,6 @@ import { DEFAULT_RECIPE } from "@/lib/constants"; -import { EditRecipe, OverlayPosition, isValidRecipe } from "@/lib/types"; +import { EditRecipe, OverlayPosition, isValidRecipe, TextOverlay } from "@/lib/types"; +import { generateTextOverlayId } from "@/lib/text-overlay"; export const RECIPE_STORAGE_KEY = "reframe:recipe"; export const LEGACY_SETTINGS_KEY = "reframe-settings"; @@ -12,11 +13,42 @@ export interface OverlayEditorState { overlayOpacity?: number; } +function clamp(value: number, min: number, max: number): number { + return Math.min(Math.max(value, min), max); +} + +const VALID_FONT_WEIGHTS = ["normal", "bold", "900"]; + +function sanitizeTextOverlay(overlay: any): TextOverlay { + return { + id: typeof overlay.id === "string" ? overlay.id : generateTextOverlayId(), + text: typeof overlay.text === "string" ? overlay.text.slice(0, 500) : "", + x: typeof overlay.x === "number" && isFinite(overlay.x) ? clamp(overlay.x, 0, 100) : 50, + y: typeof overlay.y === "number" && isFinite(overlay.y) ? clamp(overlay.y, 0, 100) : 20, + fontSize: typeof overlay.fontSize === "number" && isFinite(overlay.fontSize) ? clamp(overlay.fontSize, 12, 120) : 48, + color: typeof overlay.color === "string" && /^#[0-9A-Fa-f]{6}$/.test(overlay.color) ? overlay.color : "#ffffff", + fontWeight: VALID_FONT_WEIGHTS.includes(overlay.fontWeight) ? (overlay.fontWeight as "normal" | "bold" | "900") : "normal", + fontFamily: typeof overlay.fontFamily === "string" ? overlay.fontFamily : "Arial", + fontPath: typeof overlay.fontPath === "string" ? overlay.fontPath : undefined, + }; +} + export function migrateRecipe(recipe: Partial): EditRecipe { + const rotateValue = [0, 90, 180, 270].includes(recipe.rotate as any) ? (recipe.rotate as 0 | 90 | 180 | 270) : DEFAULT_RECIPE.rotate; + return { ...DEFAULT_RECIPE, ...recipe, - textOverlays: Array.isArray(recipe.textOverlays) ? recipe.textOverlays : [], + quality: typeof recipe.quality === "number" ? clamp(recipe.quality, 18, 30) : DEFAULT_RECIPE.quality, + speed: typeof recipe.speed === "number" ? clamp(recipe.speed, 0.25, 4) : DEFAULT_RECIPE.speed, + brightness: typeof recipe.brightness === "number" ? clamp(recipe.brightness, -1, 1) : DEFAULT_RECIPE.brightness, + contrast: typeof recipe.contrast === "number" ? clamp(recipe.contrast, 0, 2) : DEFAULT_RECIPE.contrast, + saturation: typeof recipe.saturation === "number" ? clamp(recipe.saturation, 0, 3) : DEFAULT_RECIPE.saturation, + customWidth: typeof recipe.customWidth === "number" ? clamp(recipe.customWidth, 16, 7680) : DEFAULT_RECIPE.customWidth, + customHeight: typeof recipe.customHeight === "number" ? clamp(recipe.customHeight, 16, 7680) : DEFAULT_RECIPE.customHeight, + trimStart: typeof recipe.trimStart === "number" ? Math.max(0, recipe.trimStart) : DEFAULT_RECIPE.trimStart, + rotate: rotateValue, + textOverlays: Array.isArray(recipe.textOverlays) ? recipe.textOverlays.map(sanitizeTextOverlay) : [], }; } diff --git a/src/lib/types.ts b/src/lib/types.ts index c48fea49..6ce4d081 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -148,5 +148,16 @@ export function isValidRecipe(value: unknown): value is EditRecipe { if (typeof v.soundOnCompletion !== "boolean") return false; if (!Array.isArray(v.textOverlays)) return false; + for (const overlay of v.textOverlays) { + if (!overlay || typeof overlay !== "object") return false; + if (typeof overlay.id !== "string") return false; + if (typeof overlay.text !== "string") return false; + if (typeof overlay.x !== "number" || !isFinite(overlay.x) || overlay.x < 0 || overlay.x > 100) return false; + if (typeof overlay.y !== "number" || !isFinite(overlay.y) || overlay.y < 0 || overlay.y > 100) return false; + if (typeof overlay.fontSize !== "number" || !isFinite(overlay.fontSize) || overlay.fontSize < 12 || overlay.fontSize > 120) return false; + if (typeof overlay.color !== "string") return false; + if (!["normal", "bold", "900"].includes(overlay.fontWeight)) return false; + } + return true; }