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

259 lines 5.8 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 'gdpr':
110 case 'agreement':
111 // For GDPR agreement text - allow links and basic formatting
112 return {
113 ...baseConfig,
114 ALLOWED_TAGS: ['a', 'strong', 'b', 'em', 'i', 'u', 'br', 'p', 'span'],
115 ALLOWED_ATTR: ['href', 'target', 'rel', 'title', 'class'],
116 // Ensure external links have proper security attributes
117 ADD_ATTR: ['rel'],
118 SANITIZE_DOM: true,
119 }
120
121 case 'chart':
122 case 'visualization':
123 return {
124 ...baseConfig,
125 ALLOWED_TAGS: ['div', 'svg', 'canvas', 'img'],
126 ALLOWED_ATTR: ['class', 'style', 'width', 'height', 'viewBox', 'src', 'alt', 'data-*'],
127 }
128
129 default:
130 // Custom configuration if provided
131 if (props.allowedTags || props.allowedAttributes) {
132 return {
133 ...baseConfig,
134 ALLOWED_TAGS: props.allowedTags,
135 ALLOWED_ATTR: props.allowedAttributes
136 ? Object.values(props.allowedAttributes).flat()
137 : undefined,
138 }
139 }
140
141 // Fallback: very restrictive
142 return {
143 ...baseConfig,
144 ALLOWED_TAGS: ['span', 'div'],
145 ALLOWED_ATTR: ['class'],
146 }
147 }
148 }
149
150 /**
151 * Sanitize HTML using DOMPurify with field-specific configuration
152 */
153 const sanitizeHTML = (html: string): string => {
154 if (!html || typeof html !== 'string') {
155 return ''
156 }
157
158 const config = getDOMPurifyConfig()
159
160 try {
161 // Server-side HtmlSanitizer already cleaned this HTML. Do not attach hooks that re-apply raw
162 // `style` (breaks concurrent sanitizes and bypasses attribute handling).
163 const clean = DOMPurify.sanitize(html, {
164 ...config,
165 // Allow data URIs and other safe protocols
166 ALLOW_DATA_ATTR: true,
167 // Keep comments for debugging (optional)
168 KEEP_CONTENT: true,
169 // Allow all CSS in style attributes
170 WHOLE_DOCUMENT: false,
171 // Force body context
172 FORCE_BODY: true,
173 }) as string
174
175 return clean
176 } catch (error) {
177 console.error('SafeHTML: Error sanitizing HTML:', error)
178 DOMPurify.removeAllHooks()
179 return ''
180 }
181 }
182
183 /**
184 * Render sanitized HTML into the container
185 */
186 const renderHTML = () => {
187 if (!containerRef.value) return
188
189 const sanitized = sanitizeHTML(props.html)
190
191 // Set the sanitized HTML
192 containerRef.value.innerHTML = sanitized
193 }
194
195 // Render on mount and when HTML changes
196 onMounted(() => {
197 nextTick(() => {
198 renderHTML()
199 })
200 })
201
202 watch(
203 () => props.html,
204 () => {
205 nextTick(() => {
206 renderHTML()
207 })
208 },
209 )
210 </script>
211
212 <style scoped lang="scss">
213 .ivyforms-safe-html {
214 // Base styles for sanitized HTML content
215
216 :deep(img) {
217 max-width: 100%;
218 height: auto;
219 display: block;
220 }
221
222 :deep(a) {
223 color: var(--map-base-brand-symbol-0);
224 text-decoration: none;
225
226 &:hover {
227 text-decoration: underline;
228 }
229 }
230
231 :deep(code) {
232 background-color: var(--map-base-dusk-o05);
233 padding: 2px 6px;
234 border-radius: 4px;
235 font-family: monospace;
236 font-size: 0.9em;
237 }
238
239 :deep(pre) {
240 background-color: var(--map-base-dusk-o05);
241 padding: 12px;
242 border-radius: 8px;
243 overflow-x: auto;
244
245 code {
246 background: none;
247 padding: 0;
248 }
249 }
250
251 :deep(blockquote) {
252 border-left: 4px solid var(--map-base-dusk-stroke-0);
253 padding-left: 12px;
254 margin: 8px 0;
255 color: var(--map-base-text-1);
256 }
257 }
258 </style>
259