sitesmith: harden HtmlBlock with DOMPurify + add Vitest setup

Closes XSS hole in HtmlBlock by sanitizing user/AI-supplied markup
through DOMPurify before passing to dangerouslySetInnerHTML. Adds
Vitest + jsdom for unit testing with 5 passing tests covering script
stripping, on-event handler removal, javascript: URL blocking, iframe
allowlist, and form/input stripping.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-23 14:13:42 -07:00
parent 606c9b78c8
commit bd15a33984
5 changed files with 3270 additions and 25 deletions

View File

@@ -0,0 +1,23 @@
import { describe, test, expect } from 'vitest';
import { purifyHtml } from './HtmlBlock';
describe('purifyHtml', () => {
test('strips script tags', () => {
expect(purifyHtml('<p>ok</p><script>alert(1)</script>')).not.toContain('<script');
});
test('strips on-event handlers', () => {
const out = purifyHtml('<a onclick="bad()" href="/x">x</a>');
expect(out).not.toContain('onclick');
expect(out).toContain('href="/x"');
});
test('blocks javascript: URLs', () => {
expect(purifyHtml('<a href="javascript:void(0)">x</a>')).not.toContain('javascript:');
});
test('allows YouTube iframe', () => {
const out = purifyHtml('<iframe src="https://www.youtube.com/embed/abc" allowfullscreen></iframe>');
expect(out).toContain('youtube.com/embed/abc');
});
test('strips form/input', () => {
expect(purifyHtml('<form><input name="x"></form>')).not.toContain('<form');
});
});

View File

@@ -1,34 +1,52 @@
import React, { CSSProperties } from 'react';
import React, { CSSProperties, useMemo } from 'react';
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
import DOMPurify from 'dompurify';
interface HtmlBlockProps {
code: string;
style?: CSSProperties;
aiName?: string;
node_id?: string;
}
export const HtmlBlock: UserComponent<HtmlBlockProps> = ({
code = '',
style = {},
}) => {
const {
connectors: { connect, drag },
selected,
} = useNode((node) => ({
selected: node.events.selected,
}));
const PURIFY_CONFIG = {
ALLOWED_TAGS: [
'a','p','br','hr','div','span','section','article',
'header','footer','main','aside','nav',
'ul','ol','li',
'h1','h2','h3','h4','h5','h6',
'em','strong','b','i','u','s',
'blockquote','code','pre',
'img','figure','figcaption',
'iframe',
],
ALLOWED_ATTR: [
'href','src','alt','title','target','rel',
'width','height','class',
'allowfullscreen','allow','frameborder',
],
ALLOWED_URI_REGEXP: /^(?:(?:https?|mailto|tel|data:image\/[a-z]+;base64,):|[^a-z]|[a-z+.-]+(?:[^a-z+.\-:]|$))/i,
FORBID_TAGS: ['script','style','object','embed','link','meta','form','input','button','select','textarea'],
FORBID_ATTR: [/^on/i],
};
return (
<div
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
style={{
minHeight: '40px',
outline: selected ? '2px solid #3b82f6' : 'none',
...style,
}}
dangerouslySetInnerHTML={{ __html: code }}
/>
);
export function purifyHtml(input: string): string {
return DOMPurify.sanitize(input || '', PURIFY_CONFIG as any) as unknown as string;
}
export const HtmlBlock: UserComponent<HtmlBlockProps> = ({ code = '', style = {} }) => {
const { connectors: { connect, drag }, selected } = useNode((node) => ({ selected: node.events.selected }));
const clean = useMemo(() => purifyHtml(code), [code]);
const setRef = (ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); };
return React.createElement('div', {
ref: setRef,
style: {
minHeight: '40px',
outline: selected ? '2px solid #3b82f6' : 'none',
...style,
},
dangerouslySetInnerHTML: { __html: clean },
});
};
/* ---------- Settings panel ---------- */