fix(builder): escape/allowlist all attribute-value sinks incl. numeric/enum props (XSS)

An adversarial pass found 5 Critical XSS sinks where props declared number/enum
in TypeScript were interpolated raw into exported HTML attribute values,
trusting the type — but nothing enforces it at runtime (AI update_props only
validates node_id; deserialized saved state is untyped JSON). Fixed all 5
(NumberCounter data-target, StarRating aria-label, FormContainer method,
ContactForm/InputField input type) plus 6 sibling sinks found by an exhaustive
audit of every attribute-value interpolation across src/components: a
JS-source injection into ContentSlider's inline setInterval script, a
prototype-pollution-adjacent allowlist gap in Section's divider-shape lookup,
TextareaField rows, Testimonials rating aria-label, HeroSimple textAlign, and
MapEmbed zoom. Adds shared sanitizeFormMethod/sanitizeInputType allowlist
helpers to utils/escape.ts alongside the existing escapeAttr/safeUrl/cssValue
primitives. Every fix is TDD'd: a malicious-value test reproduces the raw
injection against the pre-fix code, then passes after the fix.

502 tests green (npx vitest run), tsc + vite build green (npm run build).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-12 18:03:44 -07:00
co-authored by Claude Opus 4.8
parent 7ba91d9829
commit 591a51dcc2
45 changed files with 1039 additions and 26 deletions
@@ -0,0 +1,54 @@
import { describe, test, expect } from 'vitest';
import { ButtonLink } from './ButtonLink';
const toHtml = (ButtonLink as any).toHtml;
describe('ButtonLink.toHtml href sanitization (attacker-controlled `href` prop)', () => {
test('a javascript: URL is neutralized', () => {
const { html } = toHtml({ href: 'javascript:alert(1)', text: 'Click' }, '');
expect(html).not.toContain('javascript:alert');
});
test('a quote-breakout href does not escape the href attribute', () => {
const malicious = '"><script>alert(1)</script>';
const { html } = toHtml({ href: malicious, text: 'Click' }, '');
expect(html).not.toContain('<script>alert(1)</script>');
});
test('a normal href still renders correctly', () => {
const { html } = toHtml({ href: 'https://example.com', text: 'Click' }, '');
expect(html).toContain('href="https://example.com"');
});
});
describe('ButtonLink.toHtml target (boolean-gated, not raw interpolation)', () => {
test('an attribute-breakout value for target does not reach the output raw', () => {
const malicious = '_blank" onmouseover="alert(1)' as any;
const { html } = toHtml({ href: '#', text: 'x', target: malicious }, '');
expect(html).not.toContain('onmouseover');
});
test('target="_blank" still adds rel=noopener noreferrer', () => {
const { html } = toHtml({ href: '#', text: 'x', target: '_blank' }, '');
expect(html).toContain('target="_blank"');
expect(html).toContain('rel="noopener noreferrer"');
});
});
describe('ButtonLink.toHtml text escaping (attacker-controlled `text` prop)', () => {
test('a tag-breakout attempt in text is neutralized (no injected element)', () => {
const { html } = toHtml({ href: '#', text: '</a><img src=x onerror=alert(1)>' }, '');
expect(html).not.toContain('<img');
expect(html).toContain('&lt;img');
});
test('ampersand is escaped for well-formed text content (consistency with escapeHtml)', () => {
const { html } = toHtml({ href: '#', text: 'Tom & Jerry' }, '');
expect(html).toContain('Tom &amp; Jerry');
});
test('a normal text value still renders unchanged', () => {
const { html } = toHtml({ href: '#', text: 'Click Me' }, '');
expect(html).toContain('>Click Me</a>');
});
});
+2 -2
View File
@@ -1,7 +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';
import { escapeHtml, escapeAttr, safeUrl } from '../../utils/escape';
interface ButtonLinkProps {
text?: string;
@@ -78,7 +78,7 @@ ButtonLink.craft = {
textDecoration: 'none',
...props.style,
});
const escapedText = (props.text || '').replace(/</g, '&lt;').replace(/>/g, '&gt;');
const escapedText = escapeHtml(props.text || '');
const targetAttr = props.target === '_blank' ? ' target="_blank" rel="noopener noreferrer"' : '';
return {
html: `<a href="${escapeAttr(safeUrl(props.href || '#'))}"${targetAttr}${styleStr ? ` style="${styleStr}"` : ''}>${escapedText}</a>`,
@@ -0,0 +1,34 @@
import { describe, test, expect } from 'vitest';
import { Divider } from './Divider';
const toHtml = (Divider as any).toHtml;
describe('Divider.toHtml normal rendering', () => {
test('renders thickness/color into the border-top style', () => {
const { html } = toHtml({ thickness: '2px', color: '#ff0000' }, '');
expect(html).toContain('border-top:2px solid #ff0000');
});
});
describe('Divider.toHtml XSS hardening (thickness/color into style=)', () => {
test('a thickness value with an attribute-breakout string cannot escape style=""', () => {
const malicious = '1px" onmouseover="alert(1)';
const { html } = toHtml({ thickness: malicious as any, color: '#000' }, '');
// The quote must not survive unescaped -- otherwise it closes style=""
// early and "onmouseover" becomes a live, attacker-controlled attribute.
expect(html).not.toMatch(/"\s+onmouseover="/);
expect(html).not.toMatch(/style="[^"]*"[^>]*onmouseover/);
});
test('a color value with a </style><script> breakout is neutralized', () => {
const malicious = '#000</style><script>alert(1)</script>';
const { html } = toHtml({ thickness: '1px', color: malicious as any }, '');
expect(html).not.toContain('<script>alert(1)</script>');
});
test('a non-string thickness (object) does not raw-splice into style=""', () => {
const malicious = { toString: () => '1px" onmouseover="alert(1)' };
const { html } = toHtml({ thickness: malicious as any, color: '#000' }, '');
expect(html).not.toMatch(/"\s+onmouseover="/);
});
});
@@ -0,0 +1,22 @@
import { describe, test, expect } from 'vitest';
import { Footer } from './Footer';
const toHtml = (Footer as any).toHtml;
describe('Footer.toHtml text escaping (attacker-controlled `text` prop)', () => {
test('a tag-breakout attempt in text is neutralized (no injected element)', () => {
const { html } = toHtml({ text: '</footer><img src=x onerror=alert(1)>' }, '');
expect(html).not.toContain('<img');
expect(html).toContain('&lt;img');
});
test('ampersand is escaped for well-formed text content (consistency with escapeHtml)', () => {
const { html } = toHtml({ text: 'Terms & Conditions' }, '');
expect(html).toContain('Terms &amp; Conditions');
});
test('a normal copyright text value still renders unchanged', () => {
const { html } = toHtml({ text: '© 2026 MySite. All rights reserved.' }, '');
expect(html).toContain('© 2026 MySite. All rights reserved.');
});
});
+2 -1
View File
@@ -1,6 +1,7 @@
import React, { CSSProperties, useCallback, useRef, useEffect } from 'react';
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
import { escapeHtml } from '../../utils/escape';
interface FooterProps {
text?: string;
@@ -107,6 +108,6 @@ Footer.craft = {
textAlign: 'center',
...props.style,
});
const escapedText = (props.text || '').replace(/</g, '&lt;').replace(/>/g, '&gt;');
const escapedText = escapeHtml(props.text || '');
return { html: `<footer${styleStr ? ` style="${styleStr}"` : ''}>${escapedText}</footer>` };
};
@@ -38,3 +38,21 @@ describe('Heading.toHtml level allowlist (adversarial re-review, same class as C
}
});
});
describe('Heading.toHtml text escaping (attacker-controlled `text` prop)', () => {
test('a tag-breakout attempt in text is neutralized (no injected element)', () => {
const { html } = toHtml({ text: '</h2><img src=x onerror=alert(1)>', level: 'h2' }, '');
expect(html).not.toContain('<img');
expect(html).toContain('&lt;img');
});
test('ampersand is escaped for well-formed text content (consistency with escapeHtml)', () => {
const { html } = toHtml({ text: 'Fish & Chips', level: 'h2' }, '');
expect(html).toContain('Fish &amp; Chips');
});
test('a normal text value still renders unchanged', () => {
const { html } = toHtml({ text: 'Hello world', level: 'h2' }, '');
expect(html).toBe('<h2>Hello world</h2>');
});
});
+2 -1
View File
@@ -1,6 +1,7 @@
import React, { CSSProperties, useCallback, useRef, useEffect } from 'react';
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
import { escapeHtml } from '../../utils/escape';
type HeadingLevel = 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
@@ -110,7 +111,7 @@ Heading.craft = {
(Heading as any).toHtml = (props: HeadingProps, _childrenHtml: string) => {
const tag = sanitizeHeadingLevel(props.level);
const safeText = (props.text || '').replace(/</g, '&lt;').replace(/>/g, '&gt;');
const safeText = escapeHtml(props.text || '');
const styleStr = cssPropsToString(props.style);
return { html: `<${tag}${styleStr ? ` style="${styleStr}"` : ''}>${safeText}</${tag}>` };
};
@@ -10,4 +10,16 @@ describe('HtmlBlock.toHtml sanitizes raw code (A4.1)', () => {
expect(html).not.toContain('onclick');
expect(html).toContain('<p>hi</p>');
});
test('does not wrap output in an unsanitized element carrying the style prop raw', () => {
// toHtml only ever returns the sanitized `code` blob -- there is no
// wrapper <div style="..."> in the exported HTML, so a malicious
// `style` prop (e.g. an attacker-controlled object with a breakout
// toString()) has nothing to splice into.
const malicious = { toString: () => 'color:red" onmouseover="alert(1)' } as any;
const { html } = toHtml({ code: '<p>hi</p>', style: malicious }, '');
expect(html).not.toMatch(/onmouseover/);
expect(html).not.toMatch(/<div/);
expect(html).toBe('<p>hi</p>');
});
});
@@ -0,0 +1,46 @@
import { describe, test, expect } from 'vitest';
import { Icon } from './Icon';
const toHtml = (Icon as any).toHtml;
describe('Icon.toHtml normal rendering', () => {
test('renders icon class, size/color style, and link href', () => {
const { html } = toHtml({ icon: 'fa-star', size: '32px', color: '#3b82f6', link: 'https://example.com' }, '');
expect(html).toContain('class="fa fa-star"');
expect(html).toContain('font-size:32px');
expect(html).toContain('color:#3b82f6');
expect(html).toContain('href="https://example.com"');
});
});
describe('Icon.toHtml XSS hardening', () => {
test('an icon name with an attribute-breakout string is escaped, not raw-concatenated', () => {
const malicious = 'star"><script>alert(1)</script>';
const { html } = toHtml({ icon: malicious as any }, '');
expect(html).not.toContain('<script>alert(1)</script>');
expect(html).not.toMatch(/class="fa star"><script>/);
});
test('a size value with an attribute-breakout string cannot escape style=""', () => {
const malicious = '24px" onerror="alert(1)';
const { html } = toHtml({ size: malicious as any }, '');
expect(html).not.toMatch(/"\s+onerror="/);
});
test('a bgSize/bgColor breakout via background wrapper is neutralized', () => {
const malicious = '56px" onmouseover="alert(1)';
const { html } = toHtml({ bgShape: 'circle', bgColor: '#fff', bgSize: malicious as any }, '');
expect(html).not.toMatch(/"\s+onmouseover="/);
});
test('a javascript: link is neutralized to an empty href', () => {
const { html } = toHtml({ link: 'javascript:alert(1)' }, '');
expect(html).not.toContain('javascript:alert(1)');
});
test('a link value with an attribute-breakout string cannot escape href=""', () => {
const malicious = 'https://example.com" onclick="alert(1)';
const { html } = toHtml({ link: malicious as any }, '');
expect(html).not.toMatch(/"\s+onclick="/);
});
});
@@ -0,0 +1,62 @@
import { describe, test, expect } from 'vitest';
import { Logo } from './Logo';
/*
* Regression coverage for Logo.toHtml -- audited during the toHtml
* attribute-XSS sweep (see task-cssxss-brief.md) and found already fully
* sanitized (href/src via escapeAttr(safeUrl()), alt/text via escapeAttr /
* escapeHtml, imageWidth/fontSize/etc. routed through cssPropsToString which
* sanitizes every value regardless of declared type). No fix was required;
* these tests lock that behavior in against regressions.
*/
const toHtml = (Logo as any).toHtml;
describe('Logo.toHtml href sanitization (attacker-controlled `href` prop)', () => {
test('a javascript: URL is neutralized', () => {
const { html } = toHtml({ href: 'javascript:alert(1)' }, '');
expect(html).not.toContain('javascript:alert');
});
test('a quote-breakout href does not escape the anchor attribute', () => {
const malicious = '"><script>alert(1)</script>';
const { html } = toHtml({ href: malicious }, '');
expect(html).not.toContain('<script>alert(1)</script>');
});
});
describe('Logo.toHtml image src/alt sanitization (type="image")', () => {
test('a javascript: imageSrc is neutralized', () => {
const { html } = toHtml({ type: 'image', imageSrc: 'javascript:alert(1)', text: 'Logo' }, '');
expect(html).not.toContain('javascript:alert');
});
test('a quote-breakout alt (from `text`) does not escape the img attribute', () => {
const malicious = '"><script>alert(1)</script>';
const { html } = toHtml({ type: 'image', imageSrc: 'https://example.com/logo.png', text: malicious }, '');
expect(html).not.toContain('<script>alert(1)</script>');
});
test('a non-numeric imageWidth (attribute-breakout attempt) does not escape the style attribute', () => {
const malicious = '1"><script>alert(1)</script>';
const { html } = toHtml({ type: 'image', imageSrc: 'https://example.com/logo.png', imageWidth: malicious }, '');
expect(html).not.toContain('<script>alert(1)</script>');
});
});
describe('Logo.toHtml text-logo styling sanitization', () => {
test('a quote-breakout color does not escape the span style attribute', () => {
const malicious = 'red" onmouseover="alert(1)';
const { html } = toHtml({ type: 'text', text: 'MySite', color: malicious }, '');
// The raw `"` must never survive un-escaped inside the style attribute
// value -- if it did, `onmouseover` would land as a REAL new HTML
// attribute (breakout) rather than being inert CSS-value garbage inside
// a properly-escaped style="...".
expect(html).not.toMatch(/style="[^"]*"[^>]*onmouseover/);
});
test('a normal logo renders as expected', () => {
const { html } = toHtml({ type: 'text', text: 'MySite', href: '/' }, '');
expect(html).toContain('href="/"');
expect(html).toContain('MySite');
});
});
@@ -11,3 +11,22 @@ describe('SearchBar.toHtml decorative icons (F2.5)', () => {
icons.forEach((tag: string) => expect(tag).toContain('aria-hidden="true"'));
});
});
describe('SearchBar.toHtml XSS hardening (placeholder/buttonText/showButton)', () => {
test('a placeholder value with an attribute-breakout string cannot escape placeholder=""', () => {
const malicious = 'Search..." onmouseover="alert(1)';
const { html } = toHtml({ placeholder: malicious }, '');
expect(html).not.toMatch(/"\s+onmouseover="/);
});
test('a buttonText value with a script tag is escaped as text content, not raw HTML', () => {
const malicious = '<script>alert(1)</script>';
const { html } = toHtml({ buttonText: malicious, showButton: true }, '');
expect(html).not.toContain('<script>alert(1)</script>');
});
test('a non-boolean showButton (string "false") still yields fixed, safe border-radius values', () => {
const { html } = toHtml({ showButton: 'false' as any }, '');
expect(html).toMatch(/border-radius:(8px 0 0 8px|8px)/);
});
});
@@ -14,3 +14,40 @@ describe('SocialLinks.toHtml accessibility (F2.5)', () => {
expect(html).toMatch(/<i class="fa fa-twitter"[^>]*aria-hidden="true"/);
});
});
describe('SocialLinks.toHtml XSS hardening (iconSize/iconColor/iconBgColor/gap into style=)', () => {
test('an iconSize value with an attribute-breakout string cannot escape style=""', () => {
const malicious = '20px" onmouseover="alert(1)';
const { html } = toHtml({ links: [{ platform: 'facebook', url: '#' }], iconSize: malicious as any }, '');
expect(html).not.toMatch(/"\s+onmouseover="/);
});
test('an iconColor value with an attribute-breakout string cannot escape style=""', () => {
const malicious = '#fff" onmouseover="alert(1)';
const { html } = toHtml({ links: [{ platform: 'facebook', url: '#' }], iconColor: malicious as any }, '');
expect(html).not.toMatch(/"\s+onmouseover="/);
});
test('an iconBgColor value with an attribute-breakout string cannot escape style=""', () => {
const malicious = '#374151" onmouseover="alert(1)';
const { html } = toHtml({ links: [{ platform: 'facebook', url: '#' }], iconShape: 'circle', iconBgColor: malicious as any }, '');
expect(html).not.toMatch(/"\s+onmouseover="/);
});
test('a gap value with an attribute-breakout string cannot escape the wrapper style=""', () => {
const malicious = '10px" onmouseover="alert(1)';
const { html } = toHtml({ links: [{ platform: 'facebook', url: '#' }], gap: malicious as any }, '');
expect(html).not.toMatch(/"\s+onmouseover="/);
});
test('a malicious platform key does not produce a raw class-attribute breakout', () => {
const malicious = 'x"><script>alert(1)</script>';
const { html } = toHtml({ links: [{ platform: malicious, url: '#' }] }, '');
expect(html).not.toContain('<script>alert(1)</script>');
});
test('a link url with a javascript: scheme is neutralized', () => {
const { html } = toHtml({ links: [{ platform: 'facebook', url: 'javascript:alert(1)' }] }, '');
expect(html).not.toContain('javascript:alert(1)');
});
});
@@ -0,0 +1,26 @@
import { describe, test, expect } from 'vitest';
import { Spacer } from './Spacer';
const toHtml = (Spacer as any).toHtml;
describe('Spacer.toHtml normal rendering', () => {
test('renders height into the style attribute', () => {
const { html } = toHtml({ height: '80px' }, '');
expect(html).toContain('height:80px');
});
});
describe('Spacer.toHtml XSS hardening (height into style=)', () => {
test('a height value with an attribute-breakout string cannot escape style=""', () => {
const malicious = '40px" onmouseover="alert(1)';
const { html } = toHtml({ height: malicious as any }, '');
expect(html).not.toMatch(/"\s+onmouseover="/);
expect(html).not.toMatch(/style="[^"]*"[^>]*onmouseover/);
});
test('a height value with a </style><script> breakout is neutralized', () => {
const malicious = '40px</style><script>alert(1)</script>';
const { html } = toHtml({ height: malicious as any }, '');
expect(html).not.toContain('<script>alert(1)</script>');
});
});
@@ -40,3 +40,38 @@ describe('StarRating.toHtml XSS hardening (filledColor/emptyColor/size into styl
expect(html).toContain('color:#ff9900');
});
});
describe('StarRating.toHtml XSS hardening (rating/maxStars into aria-label, F2.2 CONFIRMED sink)', () => {
test('a maxStars value with an attribute-breakout string is neutralized in aria-label', () => {
const malicious = '5" onmouseover="alert(1)';
const { html } = toHtml({ rating: 3, maxStars: malicious as any }, '');
expect(html).not.toMatch(/onmouseover/);
expect(html).not.toMatch(/aria-label="Rating: 3 out of 5" onmouseover/);
});
test('a rating value with an attribute-breakout string is neutralized in aria-label', () => {
const malicious = '4.5" onmouseover="alert(1)';
const { html } = toHtml({ rating: malicious as any, maxStars: 5 }, '');
expect(html).not.toMatch(/onmouseover/);
});
test('a non-numeric maxStars does not blow up the star loop (no NaN glyph count, no huge output)', () => {
const malicious = '5" onmouseover="alert(1)';
const { html } = toHtml({ rating: 3, maxStars: malicious as any }, '');
const glyphs = html.match(/<i class="fa fa-star"/g) || [];
// Falls back to a sane default star count rather than looping 0 or NaN times.
expect(glyphs.length).toBeGreaterThan(0);
expect(glyphs.length).toBeLessThanOrEqual(50);
});
test('an absurdly large maxStars is clamped to a sane maximum instead of looping unboundedly', () => {
const { html } = toHtml({ rating: 3, maxStars: 1e9 as any }, '');
const glyphs = html.match(/<i class="fa fa-star"/g) || [];
expect(glyphs.length).toBeLessThanOrEqual(50);
});
test('normal numeric rating/maxStars still render the expected aria-label', () => {
const { html } = toHtml({ rating: 4.5, maxStars: 5 }, '');
expect(html).toMatch(/<span role="img" aria-label="Rating: 4\.5 out of 5"/);
});
});
+20 -4
View File
@@ -1,7 +1,7 @@
import React, { CSSProperties } from 'react';
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
import { cssValue } from '../../utils/escape';
import { cssValue, escapeAttr } from '../../utils/escape';
interface StarRatingProps {
rating?: number;
@@ -98,8 +98,19 @@ StarRating.craft = {
/* ---------- HTML export ---------- */
(StarRating as any).toHtml = (props: StarRatingProps, _childrenHtml: string) => {
const rating = props.rating ?? 4.5;
const maxStars = props.maxStars || 5;
// `rating`/`maxStars` are declared `number` in TS but arrive unchecked at
// runtime (AI update_props only validates node_id; deserialized saved
// state is untyped JSON) -- a string like `5" onmouseover="alert(1)`
// breaks out of the aria-label attribute below, and an uncoerced/unclamped
// maxStars can also blow up the star-glyph loop (NaN, absurd loop count,
// or -- observed -- a RangeError from string concatenation overflow with
// e.g. maxStars=1e9). Coerce to numbers with sane fallbacks/clamps first.
const ratingRaw = Number(props.rating);
const rating = Number.isFinite(ratingRaw) ? ratingRaw : 4.5;
const maxStarsRaw = Number(props.maxStars);
const maxStars = Number.isFinite(maxStarsRaw)
? Math.min(Math.max(Math.trunc(maxStarsRaw), 0), 50)
: 5;
// Sanitized -- raw string-interpolation sinks in the star glyphs below.
const size = cssValue(props.size) || '24px';
const filledColor = cssValue(props.filledColor) || '#f59e0b';
@@ -125,7 +136,12 @@ StarRating.craft = {
// The star glyphs convey nothing to assistive tech on their own -- wrap
// in role="img" with a textual equivalent, and hide the decorative glyphs
// themselves (aria-hidden above) so AT doesn't announce each icon.
// Belt-and-suspenders: rating/maxStars are already coerced to numbers
// above, but the assembled label is still run through escapeAttr() in
// case a decimal/negative/Infinity edge case produces odd (though no
// longer dangerous) text.
const ariaLabel = escapeAttr(`Rating: ${rating} out of ${maxStars}`);
return {
html: `<span role="img" aria-label="Rating: ${rating} out of ${maxStars}"${wrapperStyle ? ` style="${wrapperStyle}"` : ''}>${starsHtml}</span>`,
html: `<span role="img" aria-label="${ariaLabel}"${wrapperStyle ? ` style="${wrapperStyle}"` : ''}>${starsHtml}</span>`,
};
};
@@ -0,0 +1,22 @@
import { describe, test, expect } from 'vitest';
import { TextBlock } from './TextBlock';
const toHtml = (TextBlock as any).toHtml;
describe('TextBlock.toHtml text escaping (attacker-controlled `text` prop)', () => {
test('a tag-breakout attempt in text is neutralized (no injected element)', () => {
const { html } = toHtml({ text: '</p><img src=x onerror=alert(1)>' }, '');
expect(html).not.toContain('<img');
expect(html).toContain('&lt;img');
});
test('ampersand is escaped for well-formed text content (consistency with escapeHtml)', () => {
const { html } = toHtml({ text: 'Tom & Jerry' }, '');
expect(html).toContain('Tom &amp; Jerry');
});
test('a normal text value still renders unchanged', () => {
const { html } = toHtml({ text: 'Hello world' }, '');
expect(html).toBe('<p>Hello world</p>');
});
});
+2 -1
View File
@@ -1,6 +1,7 @@
import React, { CSSProperties, useCallback, useRef, useEffect } from 'react';
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
import { escapeHtml } from '../../utils/escape';
interface TextBlockProps {
text?: string;
@@ -95,6 +96,6 @@ TextBlock.craft = {
(TextBlock as any).toHtml = (props: TextBlockProps, _childrenHtml: string) => {
const styleStr = cssPropsToString(props.style);
const escapedText = (props.text || '').replace(/</g, '&lt;').replace(/>/g, '&gt;');
const escapedText = escapeHtml(props.text || '');
return { html: `<p${styleStr ? ` style="${styleStr}"` : ''}>${escapedText}</p>` };
};