From 3ff33c68ded007202c198f325f44df1c534a6ea5 Mon Sep 17 00:00:00 2001 From: Derk Zomer Date: Wed, 30 Sep 2026 09:08:19 +1000 Subject: [PATCH] fix: reject invalid hex and merge tokens in color sanitize The prior HEX_PATTERN treated 5-digit values like #00000 as valid, so they still reached Pixi Color and threw. Align with Pixi's 3/4/6/8 rule, cover unsubstituted {{ VAR }} tokens, and sanitize timeline background fills before Graphics fillStyle. --- src/components/canvas/shotstack-canvas.ts | 5 +++-- src/core/shared/color-utils.ts | 7 ++++--- tests/color-utils.test.ts | 14 ++++++++++++++ 3 files changed, 21 insertions(+), 5 deletions(-) diff --git a/src/components/canvas/shotstack-canvas.ts b/src/components/canvas/shotstack-canvas.ts index 34055d9b..c84a3ad6 100644 --- a/src/components/canvas/shotstack-canvas.ts +++ b/src/components/canvas/shotstack-canvas.ts @@ -3,6 +3,7 @@ import { AlignmentGuides } from "@canvas/system/alignment-guides"; import { createWebGLErrorOverlay } from "@canvas/webgl-error-overlay"; import { Edit } from "@core/edit-session"; import { InternalEvent } from "@core/events/edit-events"; +import { sanitizeColor } from "@core/shared/color-utils"; import { ms } from "@core/timing/types"; import type { UIController } from "@core/ui/ui-controller"; import { checkWebGLSupport, WebGLUnsupportedError } from "@core/webgl-support"; @@ -110,7 +111,7 @@ export class Canvas { this.viewportContainer.sortableChildren = true; this.editBackground = new pixi.Graphics(); - this.editBackground.fillStyle = { color: this.edit.getTimelineBackground() }; + this.editBackground.fillStyle = { color: sanitizeColor(this.edit.getTimelineBackground(), "#000000") }; this.editBackground.rect(0, 0, this.edit.size.width, this.edit.size.height); this.editBackground.fill(); this.viewportContainer.addChild(this.editBackground); @@ -533,7 +534,7 @@ export class Canvas { public updateViewportForSize(width: number, height: number, backgroundColor: string): void { if (this.editBackground) { this.editBackground.clear(); - this.editBackground.fillStyle = { color: backgroundColor }; + this.editBackground.fillStyle = { color: sanitizeColor(backgroundColor, "#000000") }; this.editBackground.rect(0, 0, width, height); this.editBackground.fill(); } diff --git a/src/core/shared/color-utils.ts b/src/core/shared/color-utils.ts index 42914027..182cda7d 100644 --- a/src/core/shared/color-utils.ts +++ b/src/core/shared/color-utils.ts @@ -2,14 +2,15 @@ * Color helpers for Pixi text/style construction. */ -/** Matches Pixi Color HEX_PATTERN (3/4/6/8 hex digits, optional # or 0x). */ -const HEX_PATTERN = /^(#|0x)?(([a-f0-9]{3}){1,2}([a-f0-9]{2})?)$/i; +/** Matches Pixi Color HEX_PATTERN: exactly 3, 4, 6, or 8 hex digits (optional # or 0x). */ +const HEX_PATTERN = /^(#|0x)?([a-f0-9]{3}|[a-f0-9]{4}|[a-f0-9]{6}|[a-f0-9]{8})$/i; const FUNCTIONAL_COLOR = /^(rgb|rgba|hsl|hsla)\(/i; const CSS_COLOR_NAME = /^[a-z]+$/i; /** * Return a Pixi-safe color string, or `fallback` when the value is missing/invalid. - * Incomplete hex (e.g. `#00`, `#0`) and empty strings fall back instead of throwing in TextStyle. + * Incomplete hex (e.g. `#00`, `#00000`), unsubstituted merge tokens (e.g. `{{ BG_COLOR }}`), + * and empty strings fall back instead of throwing in TextStyle / Graphics fillStyle. * @internal */ export function sanitizeColor(color: string | undefined | null, fallback = "#ffffff"): string { diff --git a/tests/color-utils.test.ts b/tests/color-utils.test.ts index 5cea1d35..0e7074a4 100644 --- a/tests/color-utils.test.ts +++ b/tests/color-utils.test.ts @@ -8,6 +8,18 @@ describe("sanitizeColor", () => { expect(sanitizeColor(" ")).toBe("#ffffff"); }); + it("falls back for 5-digit hex that previously slipped past the sanitizer", () => { + expect(sanitizeColor("#00000")).toBe("#ffffff"); + expect(sanitizeColor("#12345")).toBe("#ffffff"); + expect(sanitizeColor("0x00000")).toBe("#ffffff"); + }); + + it("falls back for unsubstituted merge-field tokens", () => { + expect(sanitizeColor("{{ BG_COLOR }}")).toBe("#ffffff"); + expect(sanitizeColor("{{BG_COLOR}}")).toBe("#ffffff"); + expect(sanitizeColor("{{ FONT_COLOR }}", "#000000")).toBe("#000000"); + }); + it("falls back for nullish and non-string values", () => { expect(sanitizeColor(undefined)).toBe("#ffffff"); expect(sanitizeColor(null)).toBe("#ffffff"); @@ -16,6 +28,7 @@ describe("sanitizeColor", () => { it("preserves valid hex, named, and functional colors", () => { expect(sanitizeColor("#ffffff")).toBe("#ffffff"); expect(sanitizeColor("#fff")).toBe("#fff"); + expect(sanitizeColor("#ffff")).toBe("#ffff"); expect(sanitizeColor("#ff00ff00")).toBe("#ff00ff00"); expect(sanitizeColor("red")).toBe("red"); expect(sanitizeColor("rgb(255, 0, 0)")).toBe("rgb(255, 0, 0)"); @@ -23,5 +36,6 @@ describe("sanitizeColor", () => { it("uses a custom fallback when provided", () => { expect(sanitizeColor("#00", "#000000")).toBe("#000000"); + expect(sanitizeColor("#00000", "#000000")).toBe("#000000"); }); });