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:
@@ -2,6 +2,7 @@
|
|||||||
import EmptyDayTile from './EmptyDayTile.svelte';
|
import EmptyDayTile from './EmptyDayTile.svelte';
|
||||||
import { formatDayAbbr } from '$lib/planner/week';
|
import { formatDayAbbr } from '$lib/planner/week';
|
||||||
import type { Recipe, Slot, Suggestion } from '$lib/planner/types';
|
import type { Recipe, Slot, Suggestion } from '$lib/planner/types';
|
||||||
|
import { sanitizeForCssUrl } from '$lib/planner/DesktopDayTile.utils';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
slot,
|
slot,
|
||||||
@@ -67,7 +68,7 @@
|
|||||||
|
|
||||||
const gradientBackground = $derived((() => {
|
const gradientBackground = $derived((() => {
|
||||||
if (!slot.recipe) return 'var(--color-surface)';
|
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');
|
const proteinTag = slot.recipe.tags?.find((t) => t.tagType === 'protein');
|
||||||
if (proteinTag?.name) {
|
if (proteinTag?.name) {
|
||||||
return `var(--gradient-protein-${toCssKey(proteinTag.name)})`;
|
return `var(--gradient-protein-${toCssKey(proteinTag.name)})`;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { describe, it, expect, vi } from 'vitest';
|
|||||||
import { render, screen } from '@testing-library/svelte';
|
import { render, screen } from '@testing-library/svelte';
|
||||||
import { userEvent } from '@testing-library/user-event';
|
import { userEvent } from '@testing-library/user-event';
|
||||||
import DesktopDayTile from './DesktopDayTile.svelte';
|
import DesktopDayTile from './DesktopDayTile.svelte';
|
||||||
|
import { sanitizeForCssUrl } from './DesktopDayTile.utils';
|
||||||
|
|
||||||
const filledSlot = {
|
const filledSlot = {
|
||||||
id: 's1',
|
id: 's1',
|
||||||
@@ -18,6 +19,29 @@ const filledSlot = {
|
|||||||
|
|
||||||
const emptySlot = { id: null, slotDate: '2026-04-14', recipe: null };
|
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('DesktopDayTile — filled slot', () => {
|
||||||
describe('front face', () => {
|
describe('front face', () => {
|
||||||
it('renders recipe name on front face', () => {
|
it('renders recipe name on front face', () => {
|
||||||
|
|||||||
8
frontend/src/lib/planner/DesktopDayTile.utils.ts
Normal file
8
frontend/src/lib/planner/DesktopDayTile.utils.ts
Normal 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, '');
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user