Fix image-block placeholder (src="" override) + published Gallery/SVG regression (safeImageUrl) #5

Merged
jknapp merged 3 commits from placeholder-and-gallery-fix into main 2026-07-13 02:54:24 +00:00
6 changed files with 46 additions and 5 deletions
Showing only changes of commit 621bb21d52 - Show all commits
@@ -109,6 +109,15 @@ describe('ContentSlider.toHtml renders slide.imageSrc as a background-image (INT
expect(html).not.toContain('background-image:url('); expect(html).not.toContain('background-image:url(');
expect(html).toContain('background-color:#123456'); expect(html).toContain('background-color:#123456');
}); });
test('a slide with a data:image/svg+xml imageSrc exports a non-empty background-image url (safeImageUrl, not safeUrl)', () => {
const svgDataUri = 'data:image/svg+xml,%3Csvg%2F%3E';
const slidesWithSvg = [
{ type: 'image' as const, imageSrc: svgDataUri, heading: 'One' },
];
const { html } = toHtml({ slides: slidesWithSvg }, '');
expect(html).toContain(`background-image:url('${svgDataUri}')`);
});
}); });
describe('ContentSlider.toHtml interval is NOT runtime-type-checked -- must be coerced before it reaches the inline <script> numeric context', () => { describe('ContentSlider.toHtml interval is NOT runtime-type-checked -- must be coerced before it reaches the inline <script> numeric context', () => {
@@ -1,7 +1,7 @@
import React, { CSSProperties, useState, useEffect, useRef, useCallback } from 'react'; import React, { CSSProperties, useState, useEffect, useRef, useCallback } from 'react';
import { useNode, UserComponent } from '@craftjs/core'; import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers'; import { cssPropsToString } from '../../utils/style-helpers';
import { escapeHtml, escapeAttr, safeUrl, scopeId, cssValue } from '../../utils/escape'; import { escapeHtml, escapeAttr, safeUrl, safeImageUrl, scopeId, cssValue } from '../../utils/escape';
interface Slide { interface Slide {
type: 'image' | 'content'; type: 'image' | 'content';
@@ -283,7 +283,7 @@ ContentSlider.craft = {
// sink (a malicious value could break out of the style="..." attribute). // sink (a malicious value could break out of the style="..." attribute).
const safeBgColor = cssValue(slide.bgColor) || '#3b82f6'; const safeBgColor = cssValue(slide.bgColor) || '#3b82f6';
const bgStyle = hasBgImage const bgStyle = hasBgImage
? `background-image:url('${escapeAttr(safeUrl(slide.imageSrc!))}');background-size:cover;background-position:center` ? `background-image:url('${escapeAttr(safeImageUrl(slide.imageSrc!))}');background-size:cover;background-position:center`
: slide.bgColor?.startsWith('linear-gradient') : slide.bgColor?.startsWith('linear-gradient')
? `background-image:${safeBgColor}` ? `background-image:${safeBgColor}`
: `background-color:${safeBgColor}`; : `background-color:${safeBgColor}`;
@@ -0,0 +1,23 @@
import { describe, test, expect } from 'vitest';
import { FeaturesGrid } from './FeaturesGrid';
const toHtml = (FeaturesGrid as any).toHtml;
describe('FeaturesGrid.toHtml image sink uses safeImageUrl (data:image/svg+xml allowed)', () => {
test('feat.image as a data:image/svg+xml value emits a non-empty <img src>', () => {
const svgDataUri = 'data:image/svg+xml,%3Csvg%2F%3E';
const features = [
{ title: 'Feature', description: 'Desc', icon: '⚡', image: svgDataUri, imageAlt: 'alt' },
];
const { html } = toHtml({ features }, '');
expect(html).toContain(`<img src="${svgDataUri}"`);
});
test('feat.buttonUrl stays on safeUrl (data:image/svg+xml blocked as a navigation target)', () => {
const features = [
{ title: 'Feature', description: 'Desc', icon: '⚡', buttonText: 'Go', buttonUrl: 'data:image/svg+xml,<svg onload=alert(1)>' },
];
const { html } = toHtml({ features }, '');
expect(html).toMatch(/<a href=""/);
});
});
@@ -1,7 +1,7 @@
import React, { CSSProperties } from 'react'; import React, { CSSProperties } from 'react';
import { useNode, UserComponent } from '@craftjs/core'; import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers'; import { cssPropsToString } from '../../utils/style-helpers';
import { escapeHtml, escapeAttr, safeUrl } from '../../utils/escape'; import { escapeHtml, escapeAttr, safeUrl, safeImageUrl } from '../../utils/escape';
interface FeatureItem { interface FeatureItem {
title: string; title: string;
@@ -116,7 +116,7 @@ FeaturesGrid.craft = {
const idAttr = props.anchorId ? ` id="${escapeAttr(props.anchorId)}"` : ''; const idAttr = props.anchorId ? ` id="${escapeAttr(props.anchorId)}"` : '';
const cards = (props.features || defaultFeatures).map((feat) => { const cards = (props.features || defaultFeatures).map((feat) => {
const media = feat.image const media = feat.image
? `<img src="${escapeAttr(safeUrl(feat.image))}" alt="${escapeAttr(feat.imageAlt || feat.title || '')}" style="max-width:100%;height:auto;margin-bottom:16px;border-radius:8px">` ? `<img src="${escapeAttr(safeImageUrl(feat.image))}" alt="${escapeAttr(feat.imageAlt || feat.title || '')}" style="max-width:100%;height:auto;margin-bottom:16px;border-radius:8px">`
: `<div style="font-size:36px;margin-bottom:16px">${escapeHtml(feat.icon)}</div>`; : `<div style="font-size:36px;margin-bottom:16px">${escapeHtml(feat.icon)}</div>`;
const button = feat.buttonText const button = feat.buttonText
? `\n <a href="${escapeAttr(safeUrl(feat.buttonUrl || '#'))}" style="display:inline-block;margin-top:16px;padding:10px 24px;background:#3b82f6;color:#fff;border-radius:8px;text-decoration:none;font-size:14px;font-weight:600">${escapeHtml(feat.buttonText)}</a>` ? `\n <a href="${escapeAttr(safeUrl(feat.buttonUrl || '#'))}" style="display:inline-block;margin-top:16px;padding:10px 24px;background:#3b82f6;color:#fff;border-radius:8px;text-decoration:none;font-size:14px;font-weight:600">${escapeHtml(feat.buttonText)}</a>`
+9
View File
@@ -162,6 +162,15 @@ describe('safeImageUrl (Bug 2: image-context sink -- data:image/svg+xml is safe
expect(safeImageUrl(null as any)).toBe(''); expect(safeImageUrl(null as any)).toBe('');
expect(safeImageUrl(undefined as any)).toBe(''); expect(safeImageUrl(undefined as any)).toBe('');
}); });
test('tightened allowlist: blocks a bogus MIME that merely starts with "image" but has no slash (e.g. data:imagehtml/...)', () => {
expect(safeImageUrl('data:imagehtml/svg+xml,x')).toBe('');
});
test('tightened allowlist: still allows legit data:image/* subtypes', () => {
expect(safeImageUrl('data:image/png;base64,x')).toBe('data:image/png;base64,x');
expect(safeImageUrl('data:image/svg+xml,<svg/>')).toBe('data:image/svg+xml,<svg/>');
});
}); });
describe('safeUrl still blocks data:image/svg+xml (href/iframe/navigation context unchanged by safeImageUrl addition)', () => { describe('safeUrl still blocks data:image/svg+xml (href/iframe/navigation context unchanged by safeImageUrl addition)', () => {
+1 -1
View File
@@ -102,7 +102,7 @@ export function safeImageUrl(s: unknown): string {
if (collapsed.startsWith('javascript') || collapsed.startsWith('vbscript')) { if (collapsed.startsWith('javascript') || collapsed.startsWith('vbscript')) {
return ''; return '';
} }
if (collapsed.startsWith('data') && !collapsed.startsWith('dataimage')) { if (collapsed.startsWith('data') && !collapsed.startsWith('dataimage/')) {
// A data: URI whose MIME type isn't image/* -- e.g. data:text/html, // A data: URI whose MIME type isn't image/* -- e.g. data:text/html,
// data:application/javascript, data:text/javascript. No legitimate // data:application/javascript, data:text/javascript. No legitimate
// image source needs these; block unconditionally. // image source needs these; block unconditionally.