From 4c87d9c134676dcb80367c7ed031d2082e9c3568 Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Fri, 10 Apr 2026 14:16:10 +0200 Subject: [PATCH] feat(planner): sanitize heroImageUrl before embedding in CSS url() Extracts sanitizeForCssUrl helper that strips '"()\ before the URL is embedded in url("..."). Prevents CSS injection via the hero image field in inline style bindings. Co-Authored-By: Claude Sonnet 4.6 --- .../src/lib/planner/DesktopDayTile.svelte | 3 ++- .../src/lib/planner/DesktopDayTile.test.ts | 24 +++++++++++++++++++ .../src/lib/planner/DesktopDayTile.utils.ts | 8 +++++++ 3 files changed, 34 insertions(+), 1 deletion(-) create mode 100644 frontend/src/lib/planner/DesktopDayTile.utils.ts diff --git a/frontend/src/lib/planner/DesktopDayTile.svelte b/frontend/src/lib/planner/DesktopDayTile.svelte index af709f5..e044324 100644 --- a/frontend/src/lib/planner/DesktopDayTile.svelte +++ b/frontend/src/lib/planner/DesktopDayTile.svelte @@ -2,6 +2,7 @@ import EmptyDayTile from './EmptyDayTile.svelte'; import { formatDayAbbr } from '$lib/planner/week'; import type { Recipe, Slot, Suggestion } from '$lib/planner/types'; + import { sanitizeForCssUrl } from '$lib/planner/DesktopDayTile.utils'; let { slot, @@ -67,7 +68,7 @@ const gradientBackground = $derived((() => { if (!slot.recipe) return 'var(--color-surface)'; - if (slot.recipe.heroImageUrl) return `url(${slot.recipe.heroImageUrl})`; + if (slot.recipe.heroImageUrl) return `url("${sanitizeForCssUrl(slot.recipe.heroImageUrl)}")`; const proteinTag = slot.recipe.tags?.find((t) => t.tagType === 'protein'); if (proteinTag?.name) { return `var(--gradient-protein-${toCssKey(proteinTag.name)})`; diff --git a/frontend/src/lib/planner/DesktopDayTile.test.ts b/frontend/src/lib/planner/DesktopDayTile.test.ts index ecd549a..7be228e 100644 --- a/frontend/src/lib/planner/DesktopDayTile.test.ts +++ b/frontend/src/lib/planner/DesktopDayTile.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, vi } from 'vitest'; import { render, screen } from '@testing-library/svelte'; import { userEvent } from '@testing-library/user-event'; import DesktopDayTile from './DesktopDayTile.svelte'; +import { sanitizeForCssUrl } from './DesktopDayTile.utils'; const filledSlot = { id: 's1', @@ -18,6 +19,29 @@ const filledSlot = { const emptySlot = { id: null, slotDate: '2026-04-14', recipe: null }; +describe('sanitizeForCssUrl', () => { + it('strips parentheses that could break out of url() context', () => { + expect(sanitizeForCssUrl('x);}body{display:none}/*')).not.toContain(')'); + }); + + it('strips single quotes', () => { + expect(sanitizeForCssUrl("data:image/png;base64,abc'def")).not.toContain("'"); + }); + + it('strips double quotes', () => { + expect(sanitizeForCssUrl('data:image/png;base64,abc"def')).not.toContain('"'); + }); + + it('strips backslashes', () => { + expect(sanitizeForCssUrl('data:image/png;base64,abc\\def')).not.toContain('\\'); + }); + + it('preserves a safe data URI unchanged', () => { + const safe = 'data:image/png;base64,abc123+/=='; + expect(sanitizeForCssUrl(safe)).toBe(safe); + }); +}); + describe('DesktopDayTile — filled slot', () => { describe('front face', () => { it('renders recipe name on front face', () => { diff --git a/frontend/src/lib/planner/DesktopDayTile.utils.ts b/frontend/src/lib/planner/DesktopDayTile.utils.ts new file mode 100644 index 0000000..6148bde --- /dev/null +++ b/frontend/src/lib/planner/DesktopDayTile.utils.ts @@ -0,0 +1,8 @@ +/** + * Strips characters that could break out of a CSS url() context or inject + * CSS into an inline style attribute. Safe data URIs (base64) are unaffected + * as they contain only A-Z, a-z, 0-9, +, /, = and the data: prefix. + */ +export function sanitizeForCssUrl(url: string): string { + return url.replace(/['"()\\]/g, ''); +}