diff --git a/craft/src/components/basic/ButtonLink.toHtml.test.ts b/craft/src/components/basic/ButtonLink.toHtml.test.ts
new file mode 100644
index 0000000..82fe9fe
--- /dev/null
+++ b/craft/src/components/basic/ButtonLink.toHtml.test.ts
@@ -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 = '">';
+ const { html } = toHtml({ href: malicious, text: 'Click' }, '');
+ expect(html).not.toContain('');
+ });
+
+ 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: '' }, '');
+ expect(html).not.toContain('
{
+ const { html } = toHtml({ href: '#', text: 'Tom & Jerry' }, '');
+ expect(html).toContain('Tom & Jerry');
+ });
+
+ test('a normal text value still renders unchanged', () => {
+ const { html } = toHtml({ href: '#', text: 'Click Me' }, '');
+ expect(html).toContain('>Click Me');
+ });
+});
diff --git a/craft/src/components/basic/ButtonLink.tsx b/craft/src/components/basic/ButtonLink.tsx
index 893485f..2b46a4f 100644
--- a/craft/src/components/basic/ButtonLink.tsx
+++ b/craft/src/components/basic/ButtonLink.tsx
@@ -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, '>');
+ const escapedText = escapeHtml(props.text || '');
const targetAttr = props.target === '_blank' ? ' target="_blank" rel="noopener noreferrer"' : '';
return {
html: `${escapedText}`,
diff --git a/craft/src/components/basic/Divider.toHtml.test.ts b/craft/src/components/basic/Divider.toHtml.test.ts
new file mode 100644
index 0000000..db186b5
--- /dev/null
+++ b/craft/src/components/basic/Divider.toHtml.test.ts
@@ -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 ';
+ const { html } = toHtml({ thickness: '1px', color: malicious as any }, '');
+ expect(html).not.toContain('');
+ });
+
+ 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="/);
+ });
+});
diff --git a/craft/src/components/basic/Footer.toHtml.test.ts b/craft/src/components/basic/Footer.toHtml.test.ts
new file mode 100644
index 0000000..c25d19a
--- /dev/null
+++ b/craft/src/components/basic/Footer.toHtml.test.ts
@@ -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: '
' }, '');
+ expect(html).not.toContain('
{
+ const { html } = toHtml({ text: 'Terms & Conditions' }, '');
+ expect(html).toContain('Terms & 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.');
+ });
+});
diff --git a/craft/src/components/basic/Footer.tsx b/craft/src/components/basic/Footer.tsx
index e63dbf7..f8b87d0 100644
--- a/craft/src/components/basic/Footer.tsx
+++ b/craft/src/components/basic/Footer.tsx
@@ -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, '>');
+ const escapedText = escapeHtml(props.text || '');
return { html: `` };
};
diff --git a/craft/src/components/basic/Heading.toHtml.test.ts b/craft/src/components/basic/Heading.toHtml.test.ts
index f13d115..112481f 100644
--- a/craft/src/components/basic/Heading.toHtml.test.ts
+++ b/craft/src/components/basic/Heading.toHtml.test.ts
@@ -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: '
', level: 'h2' }, '');
+ expect(html).not.toContain('
{
+ const { html } = toHtml({ text: 'Fish & Chips', level: 'h2' }, '');
+ expect(html).toContain('Fish & Chips');
+ });
+
+ test('a normal text value still renders unchanged', () => {
+ const { html } = toHtml({ text: 'Hello world', level: 'h2' }, '');
+ expect(html).toBe('
hi
'); }); + + 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 + // wrapperhi
', style: malicious }, ''); + expect(html).not.toMatch(/onmouseover/); + expect(html).not.toMatch(/Hello world
'); + }); +}); diff --git a/craft/src/components/basic/TextBlock.tsx b/craft/src/components/basic/TextBlock.tsx index 5cb19f7..f6c6d26 100644 --- a/craft/src/components/basic/TextBlock.tsx +++ b/craft/src/components/basic/TextBlock.tsx @@ -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, '>'); + const escapedText = escapeHtml(props.text || ''); return { html: `${escapedText}
` }; }; diff --git a/craft/src/components/forms/ContactForm.toHtml.test.ts b/craft/src/components/forms/ContactForm.toHtml.test.ts index 4f50fec..42cd974 100644 --- a/craft/src/components/forms/ContactForm.toHtml.test.ts +++ b/craft/src/components/forms/ContactForm.toHtml.test.ts @@ -110,3 +110,19 @@ describe('ContactForm.toHtml accessibility (F2.1)', () => { expect(ids1).toEqual(ids2); }); }); + +describe('ContactForm.toHtml field type attribute sanitization', () => { + test('malicious field.type cannot break out of the input attribute; falls back to type="text"', () => { + const fields = [{ type: 'text">