From 48d0441be35b18a7eb2cfd293939df402d6db50e Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 12 Jul 2026 12:05:57 -0700 Subject: [PATCH] fix(builder): neutralize javascript:/breakout URLs in export Every user-controlled URL emitted by a component's static toHtml (href, src, action, and CSS url()) now runs through escapeAttr(safeUrl(...)) before hitting the exported HTML string, closing the XSS gaps flagged in the A2 review (PricingTable buttonHref was fully unescaped, Gallery/ HeroSimple/ImageBlock/VideoBlock/etc. lacked scheme filtering) plus a few more found via a grep sweep of href=/src=/action=/url( inside toHtml template strings: MapEmbed's iframe src and the shared form-relay-wiring fallback form action (a javascript: form action executes on submit). Co-Authored-By: Claude Opus 4.8 (1M context) --- craft/src/components/basic/ButtonLink.tsx | 3 +- craft/src/components/basic/Icon.tsx | 5 +- craft/src/components/basic/Logo.tsx | 6 +- craft/src/components/basic/Menu.tsx | 4 +- craft/src/components/basic/Navbar.tsx | 10 +- craft/src/components/basic/SocialLinks.tsx | 3 +- craft/src/components/media/ImageBlock.tsx | 5 +- craft/src/components/media/MapEmbed.tsx | 3 +- craft/src/components/media/VideoBlock.tsx | 9 +- .../src/components/sections/ContentSlider.tsx | 6 +- .../src/components/sections/FeaturesGrid.tsx | 6 +- craft/src/components/sections/HeroSimple.tsx | 4 +- .../src/components/sections/PricingTable.tsx | 4 +- .../src/components/sections/_cta-helpers.tsx | 4 +- .../src/components/toHtml-url-safety.test.ts | 159 ++++++++++++++++++ craft/src/utils/form-relay-wiring.ts | 4 +- 16 files changed, 200 insertions(+), 35 deletions(-) create mode 100644 craft/src/components/toHtml-url-safety.test.ts diff --git a/craft/src/components/basic/ButtonLink.tsx b/craft/src/components/basic/ButtonLink.tsx index 6cbfdcb..a6e255c 100644 --- a/craft/src/components/basic/ButtonLink.tsx +++ b/craft/src/components/basic/ButtonLink.tsx @@ -1,6 +1,7 @@ import React, { CSSProperties } from 'react'; import { useNode, UserComponent } from '@craftjs/core'; import { cssPropsToString } from '../../utils/style-helpers'; +import { escapeAttr, safeUrl } from '../../utils/escape'; interface ButtonLinkProps { text?: string; @@ -227,6 +228,6 @@ ButtonLink.craft = { const escapedText = (props.text || '').replace(//g, '>'); const targetAttr = props.target === '_blank' ? ' target="_blank" rel="noopener noreferrer"' : ''; return { - html: `${escapedText}`, + html: `${escapedText}`, }; }; diff --git a/craft/src/components/basic/Icon.tsx b/craft/src/components/basic/Icon.tsx index 54785a9..85d4ca1 100644 --- a/craft/src/components/basic/Icon.tsx +++ b/craft/src/components/basic/Icon.tsx @@ -1,6 +1,7 @@ import React, { CSSProperties } from 'react'; import { useNode, UserComponent } from '@craftjs/core'; import { cssPropsToString } from '../../utils/style-helpers'; +import { escapeAttr, safeUrl } from '../../utils/escape'; interface IconProps { icon?: string; @@ -300,7 +301,7 @@ Icon.craft = { } = props; const iconStyle = cssPropsToString({ fontSize: size, color, lineHeight: '1' }); - let iconHtml = ``; + let iconHtml = ``; const hasBg = bgShape !== 'none' && bgColor !== 'transparent'; if (hasBg) { @@ -317,7 +318,7 @@ Icon.craft = { } if (link) { - iconHtml = `${iconHtml}`; + iconHtml = `${iconHtml}`; } const wrapperStyle = cssPropsToString({ display: 'inline-block', ...style }); diff --git a/craft/src/components/basic/Logo.tsx b/craft/src/components/basic/Logo.tsx index 1442e79..9a9c417 100644 --- a/craft/src/components/basic/Logo.tsx +++ b/craft/src/components/basic/Logo.tsx @@ -2,7 +2,7 @@ import React, { CSSProperties, useCallback, useRef, useState } from 'react'; import { useNode, UserComponent } from '@craftjs/core'; import { cssPropsToString } from '../../utils/style-helpers'; import { useSiteDesign } from '../../state/SiteDesignContext'; -import { escapeHtml, escapeAttr } from '../../utils/escape'; +import { escapeHtml, escapeAttr, safeUrl } from '../../utils/escape'; /* ---------- Types ---------- */ @@ -389,7 +389,7 @@ Logo.craft = { let innerHtml: string; if (props.type === 'image' && props.imageSrc) { const imgStyle = cssPropsToString({ width: props.imageWidth || '120px', height: 'auto', display: 'block' }); - innerHtml = `${escapeAttr(props.text || 'Logo')}`; + innerHtml = `${escapeAttr(props.text || 'Logo')}`; } else { const spanStyle = cssPropsToString({ fontWeight: props.fontWeight || '700', @@ -409,6 +409,6 @@ Logo.craft = { }); return { - html: `${innerHtml}`, + html: `${innerHtml}`, }; }; diff --git a/craft/src/components/basic/Menu.tsx b/craft/src/components/basic/Menu.tsx index aad7af3..beb45bf 100644 --- a/craft/src/components/basic/Menu.tsx +++ b/craft/src/components/basic/Menu.tsx @@ -2,7 +2,7 @@ import React, { CSSProperties, useState } from 'react'; import { useNode, UserComponent } from '@craftjs/core'; import { cssPropsToString } from '../../utils/style-helpers'; import { usePages } from '../../state/PageContext'; -import { escapeHtml, escapeAttr } from '../../utils/escape'; +import { escapeHtml, escapeAttr, safeUrl } from '../../utils/escape'; /* ---------- Types ---------- */ @@ -489,7 +489,7 @@ Menu.craft = { borderRadius: link.isCta ? '6px' : '0', transition: 'color 0.15s, background-color 0.15s', }); - return `${escapeHtml(link.text)}`; + return `${escapeHtml(link.text)}`; }).join('\n '); const hoverCss = `