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 <noreply@anthropic.com>
This commit is contained in:
2026-04-10 14:16:10 +02:00
parent e5c361fe42
commit 4c87d9c134
3 changed files with 34 additions and 1 deletions

View File

@@ -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)})`;

View File

@@ -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', () => {

View File

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