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:
3189
craft/package-lock.json
generated
Normal file
3189
craft/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -8,20 +8,27 @@
|
|||||||
"build": "tsc && vite build",
|
"build": "tsc && vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"test": "playwright test tests/site-builder.spec.ts --reporter=list",
|
"test": "playwright test tests/site-builder.spec.ts --reporter=list",
|
||||||
"test:headed": "playwright test tests/site-builder.spec.ts --reporter=list --headed"
|
"test:headed": "playwright test tests/site-builder.spec.ts --reporter=list --headed",
|
||||||
|
"test:unit": "vitest run",
|
||||||
|
"test:unit:watch": "vitest"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@craftjs/core": "^0.2.10",
|
"@craftjs/core": "^0.2.10",
|
||||||
"@craftjs/layers": "^0.2.7",
|
"@craftjs/layers": "^0.2.7",
|
||||||
|
"dompurify": "^3.4.5",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1"
|
"react-dom": "^18.3.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "^1.59.1",
|
"@playwright/test": "^1.59.1",
|
||||||
|
"@types/dompurify": "^3.0.5",
|
||||||
"@types/react": "^18.3.12",
|
"@types/react": "^18.3.12",
|
||||||
"@types/react-dom": "^18.3.1",
|
"@types/react-dom": "^18.3.1",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
"@vitest/ui": "^4.1.7",
|
||||||
|
"jsdom": "^29.1.1",
|
||||||
"typescript": "^5.6.3",
|
"typescript": "^5.6.3",
|
||||||
"vite": "^6.0.5"
|
"vite": "^6.0.5",
|
||||||
|
"vitest": "^4.1.7"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
23
craft/src/components/basic/HtmlBlock.test.ts
Normal file
23
craft/src/components/basic/HtmlBlock.test.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,34 +1,52 @@
|
|||||||
import React, { CSSProperties } from 'react';
|
import React, { CSSProperties, useMemo } from 'react';
|
||||||
import { useNode, UserComponent } from '@craftjs/core';
|
import { useNode, UserComponent } from '@craftjs/core';
|
||||||
import { cssPropsToString } from '../../utils/style-helpers';
|
import DOMPurify from 'dompurify';
|
||||||
|
|
||||||
interface HtmlBlockProps {
|
interface HtmlBlockProps {
|
||||||
code: string;
|
code: string;
|
||||||
style?: CSSProperties;
|
style?: CSSProperties;
|
||||||
|
aiName?: string;
|
||||||
|
node_id?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const HtmlBlock: UserComponent<HtmlBlockProps> = ({
|
const PURIFY_CONFIG = {
|
||||||
code = '',
|
ALLOWED_TAGS: [
|
||||||
style = {},
|
'a','p','br','hr','div','span','section','article',
|
||||||
}) => {
|
'header','footer','main','aside','nav',
|
||||||
const {
|
'ul','ol','li',
|
||||||
connectors: { connect, drag },
|
'h1','h2','h3','h4','h5','h6',
|
||||||
selected,
|
'em','strong','b','i','u','s',
|
||||||
} = useNode((node) => ({
|
'blockquote','code','pre',
|
||||||
selected: node.events.selected,
|
'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 (
|
export function purifyHtml(input: string): string {
|
||||||
<div
|
return DOMPurify.sanitize(input || '', PURIFY_CONFIG as any) as unknown as string;
|
||||||
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
|
}
|
||||||
style={{
|
|
||||||
minHeight: '40px',
|
export const HtmlBlock: UserComponent<HtmlBlockProps> = ({ code = '', style = {} }) => {
|
||||||
outline: selected ? '2px solid #3b82f6' : 'none',
|
const { connectors: { connect, drag }, selected } = useNode((node) => ({ selected: node.events.selected }));
|
||||||
...style,
|
const clean = useMemo(() => purifyHtml(code), [code]);
|
||||||
}}
|
const setRef = (ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); };
|
||||||
dangerouslySetInnerHTML={{ __html: code }}
|
return React.createElement('div', {
|
||||||
/>
|
ref: setRef,
|
||||||
);
|
style: {
|
||||||
|
minHeight: '40px',
|
||||||
|
outline: selected ? '2px solid #3b82f6' : 'none',
|
||||||
|
...style,
|
||||||
|
},
|
||||||
|
dangerouslySetInnerHTML: { __html: clean },
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ---------- Settings panel ---------- */
|
/* ---------- Settings panel ---------- */
|
||||||
|
|||||||
8
craft/vitest.config.ts
Normal file
8
craft/vitest.config.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
globals: true,
|
||||||
|
environment: 'jsdom',
|
||||||
|
include: ['src/**/*.test.ts', 'src/**/*.test.tsx'],
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user