Skip to content

Commit 51ed60d

Browse files
Implement HTML sanitization using DOMPurify in ProjectPreview component
1 parent 84904ed commit 51ed60d

3 files changed

Lines changed: 665 additions & 54 deletions

File tree

components/project-preview.tsx

Lines changed: 39 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
// noinspection HtmlRequiredLangAttribute
22

33
import {ExternalLink} from "lucide-react"
4+
import {JSDOM} from "jsdom"
5+
import createDOMPurify from "dompurify"
46

57
interface ProjectPreviewProps {
68
url: string
@@ -28,59 +30,43 @@ export async function ProjectPreview({url, title, renderUrl}: ProjectPreviewProp
2830
if (response.ok) {
2931
let html = await response.text()
3032

31-
// Remove script tags to make it static (no JavaScript execution)
32-
// Use iterative replacement to handle nested or overlapping patterns
33-
let previousHtml = ''
34-
while (previousHtml !== html) {
35-
previousHtml = html
36-
html = html.replace(/<script[\s\S]*?<\/script>/gi, '')
37-
html = html.replace(/<script[^>]*>/gi, '')
38-
}
39-
40-
// Remove noscript tags (they're not needed in a static preview)
41-
previousHtml = ''
42-
while (previousHtml !== html) {
43-
previousHtml = html
44-
html = html.replace(/<noscript[\s\S]*?<\/noscript>/gi, '')
45-
}
46-
47-
// Remove preload links for scripts (since we removed the scripts)
48-
html = html.replace(/<link[^>]*rel\s*=\s*["']?preload["'][^>]*as\s*=\s*["']?script["'][^>]*>/gi, '')
49-
html = html.replace(/<link[^>]*as\s*=\s*["']?script["'][^>]*rel\s*=\s*["']?preload["'][^>]*>/gi, '')
50-
51-
// Remove modulepreload links as well
52-
html = html.replace(/<link[^>]*rel\s*=\s*["']?modulepreload["'][^>]*>/gi, '')
53-
54-
// Remove inline event handlers (onclick, onload, onerror, etc.)
55-
// Use iterative replacement to handle overlapping patterns like "ononclick"
56-
previousHtml = ''
57-
while (previousHtml !== html) {
58-
previousHtml = html
59-
html = html.replace(/\son\w+\s*=\s*["'][^"']*["']/gi, ' ')
60-
html = html.replace(/\son\w+\s*=\s*[^\s>]+/gi, ' ')
61-
}
62-
63-
// Remove javascript: URIs
64-
previousHtml = ''
65-
while (previousHtml !== html) {
66-
previousHtml = html
67-
html = html.replace(/\s(href|src|action|formaction|data)\s*=\s*["']?\s*javascript:/gi, ' data-blocked-$1="javascript:')
68-
}
69-
70-
// Remove data: URIs that could contain HTML/SVG with scripts
71-
previousHtml = ''
72-
while (previousHtml !== html) {
73-
previousHtml = html
74-
html = html.replace(/\s(href|src|action|formaction)\s*=\s*["']?\s*data:text\/html/gi, ' data-blocked-$1="data:text/html')
75-
}
76-
77-
// Remove potentially dangerous tags (object, embed, applet)
78-
previousHtml = ''
79-
while (previousHtml !== html) {
80-
previousHtml = html
81-
html = html.replace(/<(object|embed|applet)[\s\S]*?<\/\1>/gi, '')
82-
html = html.replace(/<(object|embed|applet)[^>]*>/gi, '')
83-
}
33+
// Use DOMPurify for proper HTML sanitization
34+
const window = new JSDOM('').window
35+
const DOMPurify = createDOMPurify(window as any)
36+
37+
// Configure DOMPurify to be strict but allow necessary elements
38+
html = DOMPurify.sanitize(html, {
39+
ALLOWED_TAGS: [
40+
'html', 'head', 'body', 'title', 'meta', 'link', 'style',
41+
'div', 'span', 'p', 'a', 'img', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
42+
'ul', 'ol', 'li', 'table', 'thead', 'tbody', 'tr', 'td', 'th',
43+
'header', 'footer', 'nav', 'section', 'article', 'aside', 'main',
44+
'form', 'input', 'button', 'label', 'select', 'option', 'textarea',
45+
'strong', 'em', 'b', 'i', 'u', 'small', 'mark', 'del', 'ins', 'sub', 'sup',
46+
'br', 'hr', 'pre', 'code', 'blockquote', 'figure', 'figcaption',
47+
'video', 'audio', 'source', 'track', 'canvas', 'svg', 'path', 'circle',
48+
'rect', 'line', 'polyline', 'polygon', 'g', 'text', 'tspan'
49+
],
50+
ALLOWED_ATTR: [
51+
'href', 'src', 'alt', 'title', 'class', 'id', 'style', 'width', 'height',
52+
'data-*', 'aria-*', 'role', 'rel', 'target', 'type', 'name', 'value',
53+
'placeholder', 'disabled', 'readonly', 'checked', 'selected',
54+
'colspan', 'rowspan', 'cellpadding', 'cellspacing', 'border',
55+
'viewBox', 'd', 'fill', 'stroke', 'stroke-width', 'transform',
56+
'xmlns', 'xmlns:xlink', 'x', 'y', 'cx', 'cy', 'r', 'rx', 'ry',
57+
'x1', 'x2', 'y1', 'y2', 'points'
58+
],
59+
FORBID_TAGS: ['script', 'noscript', 'object', 'embed', 'applet', 'iframe', 'base'],
60+
FORBID_ATTR: ['onerror', 'onload', 'onclick', 'onmouseover', 'onfocus', 'onblur'],
61+
ALLOW_DATA_ATTR: true,
62+
ALLOW_ARIA_ATTR: true,
63+
WHOLE_DOCUMENT: true,
64+
RETURN_DOM: false,
65+
RETURN_DOM_FRAGMENT: false,
66+
SANITIZE_DOM: true,
67+
KEEP_CONTENT: true,
68+
IN_PLACE: false
69+
})
8470

8571
// Remove existing CSP meta tags that might conflict
8672
html = html.replace(/<meta[^>]*http-equiv\s*=\s*["']?Content-Security-Policy["']?[^>]*>/gi, '')
@@ -146,7 +132,6 @@ export async function ProjectPreview({url, title, renderUrl}: ProjectPreviewProp
146132
className="h-full w-full scale-[0.5] origin-top-left pointer-events-none overflow-hidden"
147133
style={{width: "200%", height: "200%", overflow: "hidden"}}
148134
sandbox="allow-same-origin"
149-
scrolling="no"
150135
/>
151136
)}
152137
</div>

0 commit comments

Comments
 (0)