PluginProbe
The Innovative Form Builder – IvyForms / 1.0
The Innovative Form Builder – IvyForms v1.0
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 1.0, at frontend/src/views/_components/SafeHTML.vue

247 lines 5.4 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 // Server-side HtmlSanitizer already cleaned this HTML. Do not attach hooks that re-apply raw
150 // `style` (breaks concurrent sanitizes and bypasses attribute handling).
151 const clean = DOMPurify.sanitize(html, {
152 ...config,
153 // Allow data URIs and other safe protocols
154 ALLOW_DATA_ATTR: true,
155 // Keep comments for debugging (optional)
156 KEEP_CONTENT: true,
157 // Allow all CSS in style attributes
158 WHOLE_DOCUMENT: false,
159 // Force body context
160 FORCE_BODY: true,
161 }) as string
162
163 return clean
164 } catch (error) {
165 console.error('SafeHTML: Error sanitizing HTML:', error)
166 DOMPurify.removeAllHooks()
167 return ''
168 }
169 }
170
171 /**
172 * Render sanitized HTML into the container
173 */
174 const renderHTML = () => {
175 if (!containerRef.value) return
176
177 const sanitized = sanitizeHTML(props.html)
178
179 // Set the sanitized HTML
180 containerRef.value.innerHTML = sanitized
181 }
182
183 // Render on mount and when HTML changes
184 onMounted(() => {
185 nextTick(() => {
186 renderHTML()
187 })
188 })
189
190 watch(
191 () => props.html,
192 () => {
193 nextTick(() => {
194 renderHTML()
195 })
196 },
197 )
198 </script>
199
200 <style scoped lang="scss">
201 .ivyforms-safe-html {
202 // Base styles for sanitized HTML content
203
204 :deep(img) {
205 max-width: 100%;
206 height: auto;
207 display: block;
208 }
209
210 :deep(a) {
211 color: var(--map-base-brand-symbol-0);
212 text-decoration: none;
213
214 &:hover {
215 text-decoration: underline;
216 }
217 }
218
219 :deep(code) {
220 background-color: var(--map-base-dusk-o05);
221 padding: 2px 6px;
222 border-radius: 4px;
223 font-family: monospace;
224 font-size: 0.9em;
225 }
226
227 :deep(pre) {
228 background-color: var(--map-base-dusk-o05);
229 padding: 12px;
230 border-radius: 8px;
231 overflow-x: auto;
232
233 code {
234 background: none;
235 padding: 0;
236 }
237 }
238
239 :deep(blockquote) {
240 border-left: 4px solid var(--map-base-dusk-stroke-0);
241 padding-left: 12px;
242 margin: 8px 0;
243 color: var(--map-base-text-1);
244 }
245 }
246 </style>
247