Files
site-builder/craft/src/components/basic/Logo.tsx
T
shadowdaoandClaude Opus 4.8 3f3c6fb851 security: add safeImageUrl, un-break M-5's over-blocking of image-context SVG data URIs
M-5 made safeUrl() block data:image/svg+xml everywhere, including the
image-only sinks (<img src>, CSS url()) that Gallery's default images and
other SVG placeholders rely on. Loaded as an image, an SVG is rasterized
and never executes an inline <script>/onload= -- that only happens when
it's navigated to or loaded as an <iframe> document -- so M-5 over-blocked
the safe contexts and broke every published Gallery (and other components
using an SVG placeholder) using safeUrl's default images in prod.

Adds safeImageUrl(): identical javascript:/vbscript: handling to safeUrl,
but treats data: as an allowlist of image/* subtypes instead of a
blocklist -- allows all data:image/* (including svg+xml, with or without
base64), still blocks data:text/html and any other non-image data: type.

Swapped to safeImageUrl at IMAGE-src / CSS-image url() sinks only:
- Gallery.tsx img src + lightbox data-lb-src
- ImageBlock.tsx img src (toHtml)
- Logo.tsx / Navbar.tsx logo <img> src (their href/link targets keep safeUrl)
- style-helpers.ts sanitizeCssValue's url(...) handling (background-image
  for HeroSimple/BackgroundSection/Section/CallToAction)

Left on safeUrl (href/iframe/form-action/navigation sinks, where
data:image/svg+xml must stay blocked): ButtonLink, Icon link, SocialLinks,
Menu/Navbar link hrefs, PricingTable buttonHref, _cta-helpers,
ContentSlider buttonHref, FeaturesGrid buttonUrl, FormContainer action
(via form-relay-wiring), MapEmbed/VideoBlock iframe src.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 19:45:58 -07:00

130 lines
3.2 KiB
TypeScript

import React, { CSSProperties } from 'react';
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
import { useSiteDesign } from '../../state/SiteDesignContext';
import { escapeHtml, escapeAttr, safeUrl, safeImageUrl } from '../../utils/escape';
/* ---------- Types ---------- */
interface LogoProps {
type?: 'text' | 'image';
text?: string;
imageSrc?: string;
imageWidth?: string;
href?: string;
fontFamily?: string;
fontSize?: string;
fontWeight?: string;
color?: string;
style?: CSSProperties;
}
/* ---------- Component ---------- */
export const Logo: UserComponent<LogoProps> = ({
type = 'text',
text = 'MySite',
imageSrc = '',
imageWidth = '120px',
href = '/',
fontFamily = 'Inter, sans-serif',
fontSize = '20px',
fontWeight = '700',
color,
style = {},
}) => {
const {
connectors: { connect, drag },
} = useNode();
const { design } = useSiteDesign();
const resolvedColor = color || design.textColor;
return (
<a
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
href={href}
onClick={(e) => e.preventDefault()}
style={{
textDecoration: 'none',
display: 'inline-flex',
alignItems: 'center',
flexShrink: 0,
...style,
}}
>
{type === 'image' && imageSrc ? (
<img
src={imageSrc}
alt={text || 'Logo'}
style={{ width: imageWidth, height: 'auto', display: 'block' }}
/>
) : (
<span style={{
fontWeight,
fontSize,
fontFamily,
color: resolvedColor,
}}>
{text}
</span>
)}
</a>
);
};
/* ---------- Craft config ---------- */
Logo.craft = {
displayName: 'Logo',
props: {
type: 'text',
text: 'MySite',
imageSrc: '',
imageWidth: '120px',
href: '/',
fontFamily: 'Inter, sans-serif',
fontSize: '20px',
fontWeight: '700',
color: undefined,
style: {},
} as LogoProps,
rules: {
canDrag: () => true,
canMoveIn: () => false,
canMoveOut: () => true,
},
};
/* ---------- HTML export ---------- */
(Logo as any).toHtml = (props: LogoProps, _childrenHtml: string) => {
const href = props.href || '/';
let innerHtml: string;
if (props.type === 'image' && props.imageSrc) {
const imgStyle = cssPropsToString({ width: props.imageWidth || '120px', height: 'auto', display: 'block' });
innerHtml = `<img src="${escapeAttr(safeImageUrl(props.imageSrc))}" alt="${escapeAttr(props.text || 'Logo')}"${imgStyle ? ` style="${imgStyle}"` : ''} />`;
} else {
const spanStyle = cssPropsToString({
fontWeight: props.fontWeight || '700',
fontSize: props.fontSize || '20px',
fontFamily: props.fontFamily || 'Inter, sans-serif',
color: props.color || '#1f2937',
});
innerHtml = `<span${spanStyle ? ` style="${spanStyle}"` : ''}>${escapeHtml(props.text || 'MySite')}</span>`;
}
const aStyle = cssPropsToString({
textDecoration: 'none',
display: 'inline-flex',
alignItems: 'center',
flexShrink: '0',
...props.style,
});
return {
html: `<a href="${escapeAttr(safeUrl(href))}"${aStyle ? ` style="${aStyle}"` : ''}>${innerHtml}</a>`,
};
};