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, ''); +}