PluginProbe
The Innovative Form Builder – IvyForms / 0.8.1
The Innovative Form Builder – IvyForms v0.8.1
1.5.1 1.5 1.4.1 1.4 trunk 0.1.2 0.2 0.2.1 0.3 0.3.1 0.4 0.5 0.6 0.6.1 0.6.1-backup 0.6.1.1 0.7 0.8 0.8.1 0.8.2 0.9 0.9.1 1.0 1.1 1.1.1 All 27 releases
ivyforms / frontend / src / views / _components / SafeHTML.vue

SafeHTML.vue in The Innovative Form Builder – IvyForms 0.8.1, at frontend/src/views/_components/SafeHTML.vue

275 lines 6.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <template>
2 <div ref="containerRef" class="ivyforms-safe-html"></div>
3 </template>
4
5 <script setup lang="ts">
6 import { ref, onMounted, watch, nextTick } from 'vue'
7 import DOMPurify, { type Config } from 'dompurify'
8
9 interface Props {
10 html: string
11 fieldType?: string
12 allowedTags?: string[]
13 allowedAttributes?: Record<string, string[]>
14 }
15
16 const props = withDefaults(defineProps<Props>(), {
17 fieldType: 'unknown',
18 allowedTags: undefined,
19 allowedAttributes: undefined,
20 })
21
22 const containerRef = ref<HTMLDivElement>()
23
24 /**
25 * Get DOMPurify configuration based on field type
26 */
27 const getDOMPurifyConfig = (): Config => {
28 const baseConfig: Config = {
29 RETURN_DOM_FRAGMENT: false,
30 RETURN_DOM: false,
31 ALLOWED_URI_REGEXP:
32 /^(?:(?:(?:f|ht)tps?|mailto|tel|callto|sms|cid|xmpp|data):|[^a-z]|[a-z+.-]+(?:[^a-z+.-:]|$))/i,
33 }
34
35 // Field-specific configurations
36 switch (props.fieldType) {
37 case 'signature':
38 return {
39 ...baseConfig,
40 ALLOWED_TAGS: ['img'],
41 ALLOWED_ATTR: ['src', 'alt', 'style', 'class', 'width', 'height'],
42 ALLOWED_URI_REGEXP: /^data:image\/(png|jpeg|jpg|gif|svg\+xml);base64,/i,
43 }
44
45 case 'confirmation':
46 // For confirmation messages that may contain signature images and formatted text
47 return {
48 ...baseConfig,
49 ALLOWED_TAGS: [
50 'p',
51 'br',
52 'strong',
53 'b',
54 'em',
55 'i',
56 'u',
57 'a',
58 'ul',
59 'ol',
60 'li',
61 'span',
62 'div',
63 'img', // Allow images for signatures
64 'h1',
65 'h2',
66 'h3',
67 'h4',
68 'h5',
69 'h6',
70 'blockquote',
71 ],
72 ALLOWED_ATTR: ['href', 'target', 'rel', 'class', 'style', 'src', 'alt', 'width', 'height'],
73 ALLOWED_URI_REGEXP: /^(?:(?:(?:f|ht)tps?|mailto|tel|data):)/i, // Allow data URIs for signature images
74 // Allow all CSS in style attributes
75 ALLOW_UNKNOWN_PROTOCOLS: false,
76 SANITIZE_DOM: true,
77 }
78
79 case 'rich-text':
80 return {
81 ...baseConfig,
82 ALLOWED_TAGS: [
83 'p',
84 'br',
85 'strong',
86 'b',
87 'em',
88 'i',
89 'u',
90 'a',
91 'ul',
92 'ol',
93 'li',
94 'h1',
95 'h2',
96 'h3',
97 'h4',
98 'h5',
99 'h6',
100 'blockquote',
101 'code',
102 'pre',
103 'span',
104 'div',
105 ],
106 ALLOWED_ATTR: ['href', 'target', 'rel', 'class', 'style'],
107 }
108
109 case 'chart':
110 case 'visualization':
111 return {
112 ...baseConfig,
113 ALLOWED_TAGS: ['div', 'svg', 'canvas', 'img'],
114 ALLOWED_ATTR: ['class', 'style', 'width', 'height', 'viewBox', 'src', 'alt', 'data-*'],
115 }
116
117 default:
118 // Custom configuration if provided
119 if (props.allowedTags || props.allowedAttributes) {
120 return {
121 ...baseConfig,
122 ALLOWED_TAGS: props.allowedTags,
123 ALLOWED_ATTR: props.allowedAttributes
124 ? Object.values(props.allowedAttributes).flat()
125 : undefined,
126 }
127 }
128
129 // Fallback: very restrictive
130 return {
131 ...baseConfig,
132 ALLOWED_TAGS: ['span', 'div'],
133 ALLOWED_ATTR: ['class'],
134 }
135 }
136 }
137
138 /**
139 * Sanitize HTML using DOMPurify with field-specific configuration
140 */
141 const sanitizeHTML = (html: string): string => {
142 if (!html || typeof html !== 'string') {
143 return ''
144 }
145
146 const config = getDOMPurifyConfig()
147
148 try {
149 // Store original style attributes to preserve them
150 const styleMap = new Map<Element, string>()
151
152 // Hook to save original style attributes before DOMPurify processes them
153 DOMPurify.addHook('beforeSanitizeAttributes', (node) => {
154 if (node instanceof Element && node.hasAttribute('style')) {
155 const originalStyle = node.getAttribute('style')
156 if (originalStyle) {
157 styleMap.set(node, originalStyle)
158 }
159 }
160 })
161
162 // Hook to restore original style attributes after DOMPurify sanitizes
163 DOMPurify.addHook('afterSanitizeAttributes', (node) => {
164 if (node instanceof Element && styleMap.has(node)) {
165 const originalStyle = styleMap.get(node)
166 if (originalStyle) {
167 // Restore the original style attribute
168 // This preserves all CSS including rgb(), rgba(), etc.
169 node.setAttribute('style', originalStyle)
170 }
171 }
172 })
173
174 // Sanitize the HTML
175 const clean = DOMPurify.sanitize(html, {
176 ...config,
177 // Allow data URIs and other safe protocols
178 ALLOW_DATA_ATTR: true,
179 // Keep comments for debugging (optional)
180 KEEP_CONTENT: true,
181 // Allow all CSS in style attributes
182 WHOLE_DOCUMENT: false,
183 // Force body context
184 FORCE_BODY: true,
185 }) as string
186
187 // Clean up hooks after sanitization
188 DOMPurify.removeHook('beforeSanitizeAttributes')
189 DOMPurify.removeHook('afterSanitizeAttributes')
190
191 return clean
192 } catch (error) {
193 console.error('SafeHTML: Error sanitizing HTML:', error)
194 DOMPurify.removeAllHooks()
195 return ''
196 }
197 }
198
199 /**
200 * Render sanitized HTML into the container
201 */
202 const renderHTML = () => {
203 if (!containerRef.value) return
204
205 const sanitized = sanitizeHTML(props.html)
206
207 // Set the sanitized HTML
208 containerRef.value.innerHTML = sanitized
209 }
210
211 // Render on mount and when HTML changes
212 onMounted(() => {
213 nextTick(() => {
214 renderHTML()
215 })
216 })
217
218 watch(
219 () => props.html,
220 () => {
221 nextTick(() => {
222 renderHTML()
223 })
224 },
225 )
226 </script>
227
228 <style scoped lang="scss">
229 .ivyforms-safe-html {
230 // Base styles for sanitized HTML content
231
232 :deep(img) {
233 max-width: 100%;
234 height: auto;
235 display: block;
236 }
237
238 :deep(a) {
239 color: var(--map-base-brand-symbol-0);
240 text-decoration: none;
241
242 &:hover {
243 text-decoration: underline;
244 }
245 }
246
247 :deep(code) {
248 background-color: var(--map-base-dusk-o05);
249 padding: 2px 6px;
250 border-radius: 4px;
251 font-family: monospace;
252 font-size: 0.9em;
253 }
254
255 :deep(pre) {
256 background-color: var(--map-base-dusk-o05);
257 padding: 12px;
258 border-radius: 8px;
259 overflow-x: auto;
260
261 code {
262 background: none;
263 padding: 0;
264 }
265 }
266
267 :deep(blockquote) {
268 border-left: 4px solid var(--map-base-dusk-stroke-0);
269 padding-left: 12px;
270 margin: 8px 0;
271 color: var(--map-base-text-1);
272 }
273 }
274 </style>
275