|
1 | 1 | // noinspection HtmlRequiredLangAttribute |
2 | 2 |
|
3 | 3 | import {ExternalLink} from "lucide-react" |
| 4 | +import {JSDOM} from "jsdom" |
| 5 | +import createDOMPurify from "dompurify" |
4 | 6 |
|
5 | 7 | interface ProjectPreviewProps { |
6 | 8 | url: string |
@@ -28,59 +30,43 @@ export async function ProjectPreview({url, title, renderUrl}: ProjectPreviewProp |
28 | 30 | if (response.ok) { |
29 | 31 | let html = await response.text() |
30 | 32 |
|
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 | + }) |
84 | 70 |
|
85 | 71 | // Remove existing CSP meta tags that might conflict |
86 | 72 | html = html.replace(/<meta[^>]*http-equiv\s*=\s*["']?Content-Security-Policy["']?[^>]*>/gi, '') |
@@ -146,7 +132,6 @@ export async function ProjectPreview({url, title, renderUrl}: ProjectPreviewProp |
146 | 132 | className="h-full w-full scale-[0.5] origin-top-left pointer-events-none overflow-hidden" |
147 | 133 | style={{width: "200%", height: "200%", overflow: "hidden"}} |
148 | 134 | sandbox="allow-same-origin" |
149 | | - scrolling="no" |
150 | 135 | /> |
151 | 136 | )} |
152 | 137 | </div> |
|
0 commit comments