PluginProbe
The Innovative Form Builder – IvyForms / 1.5
The Innovative Form Builder – IvyForms v1.5
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 / backend / src / Common / Sanitizer / HtmlSanitizer.php

HtmlSanitizer.php in The Innovative Form Builder – IvyForms 1.5, at backend/src/Common/Sanitizer/HtmlSanitizer.php

435 lines 13.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 /**
4 * @copyright © Melograno Venture Studio. All rights reserved.
5 * @licence See COPYING.md for license details.
6 */
7
8 namespace IvyForms\Common\Sanitizer;
9
10 class HtmlSanitizer
11 {
12 /**
13 * Normalize mixed API values to a string for HTML sanitization (avoids PHP 8 TypeError).
14 *
15 * @param mixed $value
16 */
17 private static function toHtmlString($value): string
18 {
19 if ($value === null || $value === '') {
20 return '';
21 }
22 if (is_string($value)) {
23 return $value;
24 }
25 if (is_scalar($value)) {
26 return (string) $value;
27 }
28
29 return '';
30 }
31
32 /**
33 * @param array<mixed> $value
34 */
35 private static function implodeCssClassArray(array $value): string
36 {
37 $parts = [];
38 foreach ($value as $classPart) {
39 if (is_string($classPart) || is_int($classPart) || is_float($classPart)) {
40 $trimmed = trim((string) $classPart);
41 if ($trimmed !== '') {
42 $parts[] = $trimmed;
43 }
44 }
45 }
46
47 return implode(' ', $parts);
48 }
49
50 /**
51 * Normalize cssClasses from REST (string, list of strings, or other scalar).
52 *
53 * @param mixed $value
54 */
55 private static function toCssClassesString($value): string
56 {
57 if ($value === null || $value === '' || $value === false) {
58 return '';
59 }
60 if (is_string($value)) {
61 return $value;
62 }
63 if (is_array($value)) {
64 return self::implodeCssClassArray($value);
65 }
66 if (is_scalar($value)) {
67 return (string) $value;
68 }
69
70 return '';
71 }
72
73 /**
74 * @return array{string, array<string, string>}
75 */
76 private static function replaceDataImgSrcWithPlaceholders(string $content): array
77 {
78 $placeholders = [];
79 $index = 0;
80 $replaced = preg_replace_callback(
81 '/src\s*=\s*["\']?(data:image\/[^"\'>\s]+)["\']?/i',
82 static function (array $matches) use (&$placeholders, &$index): string {
83 $placeholder = 'IMGDATA' . $index;
84 $placeholders[$placeholder] = $matches[1];
85 $index++;
86
87 return 'data-img-src="' . $placeholder . '"';
88 },
89 $content
90 );
91
92 return [$replaced ?? '', $placeholders];
93 }
94
95 /**
96 * @return array{string, array<string, string>}
97 */
98 private static function replaceInlineStyleAttributesWithPlaceholders(string $content): array
99 {
100 $stylePlaceholders = [];
101 $placeholderIndex = 0;
102
103 $styleToPlaceholder = static function (array $matches) use (&$stylePlaceholders, &$placeholderIndex): string {
104 $placeholder = '___STYLE_PLACEHOLDER_' . $placeholderIndex . '___';
105 $stylePlaceholders[$placeholder] = CssSanitizer::sanitizeCssValue($matches[2]);
106 $placeholderIndex++;
107
108 return $matches[1] . ' data-style-placeholder="' . $placeholder . '"';
109 };
110
111 $content = preg_replace_callback(
112 '/(<[^>]+)\s+style="([^"]*)"/i',
113 $styleToPlaceholder,
114 $content
115 ) ?? '';
116 $content = preg_replace_callback(
117 "/(<[^>]+)\s+style='([^']*)'/i",
118 $styleToPlaceholder,
119 $content
120 ) ?? '';
121
122 return [$content, $stylePlaceholders];
123 }
124
125 /**
126 * @return array<string, array<string, bool>>
127 */
128 public static function buildEditorWpKsesAllowedTags(): array
129 {
130 $allowedTags = wp_kses_allowed_html('post');
131
132 $tagsWithStyle = [
133 'span',
134 'p',
135 'div',
136 'h1',
137 'h2',
138 'h3',
139 'h4',
140 'h5',
141 'h6',
142 'li',
143 'ul',
144 'ol',
145 'blockquote',
146 'strong',
147 'em',
148 'u',
149 'a',
150 ];
151
152 foreach ($tagsWithStyle as $tag) {
153 if (!isset($allowedTags[$tag])) {
154 $allowedTags[$tag] = [];
155 }
156 if (!is_array($allowedTags[$tag])) {
157 $allowedTags[$tag] = [];
158 }
159 $allowedTags[$tag]['data-style-placeholder'] = true;
160 $allowedTags[$tag]['class'] = true;
161 }
162
163 if (!isset($allowedTags['a'])) {
164 $allowedTags['a'] = [];
165 }
166 $allowedTags['a']['href'] = true;
167 $allowedTags['a']['target'] = true;
168 $allowedTags['a']['rel'] = true;
169 $allowedTags['a']['class'] = true;
170 $allowedTags['a']['data-style-placeholder'] = true;
171
172 if (!isset($allowedTags['img'])) {
173 $allowedTags['img'] = [];
174 }
175 $allowedTags['img']['src'] = true;
176 $allowedTags['img']['alt'] = true;
177 $allowedTags['img']['class'] = true;
178 $allowedTags['img']['style'] = true;
179 $allowedTags['img']['width'] = true;
180 $allowedTags['img']['height'] = true;
181 $allowedTags['img']['data-img-src'] = true;
182
183 return $allowedTags;
184 }
185
186 /**
187 * @param array<string, string> $dataUrlPlaceholders
188 */
189 private static function restoreEditorImgPlaceholders(string $html, array $dataUrlPlaceholders): string
190 {
191 $out = preg_replace_callback(
192 '/<img[^>]*data-img-src=["\']?(IMGDATA\d+)["\']?[^>]*>/i',
193 static function (array $matches) use ($dataUrlPlaceholders): string {
194 $imgTag = $matches[0];
195 $placeholderMatch = [];
196 if (!preg_match('/data-img-src=["\']?(IMGDATA\d+)["\']?/i', $imgTag, $placeholderMatch)) {
197 return $matches[0];
198 }
199 $placeholder = $placeholderMatch[1];
200 if (!isset($dataUrlPlaceholders[$placeholder])) {
201 return $matches[0];
202 }
203 $imgTag = preg_replace('/\s*data-img-src=["\']?IMGDATA\d+["\']?/i', '', $imgTag);
204 if (!preg_match('/\ssrc=["\']?[^"\'>]+["\']?/i', $imgTag)) {
205 $imgTag = str_replace('>', ' src="' . $dataUrlPlaceholders[$placeholder] . '">', $imgTag);
206 }
207
208 return $imgTag;
209 },
210 $html
211 );
212
213 return $out ?? $html;
214 }
215
216 /**
217 * @param array<string, string> $stylePlaceholders
218 */
219 private static function restoreEditorStylePlaceholders(string $html, array $stylePlaceholders): string
220 {
221 $out = preg_replace_callback(
222 '/data-style-placeholder="(___STYLE_PLACEHOLDER_\d+___)"/i',
223 static function (array $matches) use ($stylePlaceholders): string {
224 $placeholder = $matches[1];
225 if (!isset($stylePlaceholders[$placeholder])) {
226 return '';
227 }
228 $sanitizedStyle = $stylePlaceholders[$placeholder];
229 if (!empty($sanitizedStyle)) {
230 return 'style="' . $sanitizedStyle . '"';
231 }
232
233 return '';
234 },
235 $html
236 );
237
238 return $out ?? $html;
239 }
240
241 /**
242 * Sanitize HTML editor content with support for inline styles (colors, formatting)
243 *
244 * @param mixed $content
245 *
246 * @return string
247 */
248 public static function sanitizeEditorContent($content): string
249 {
250 $content = self::toHtmlString($content);
251 [$content, $dataUrlPlaceholders] = self::replaceDataImgSrcWithPlaceholders($content);
252 [$content, $stylePlaceholders] = self::replaceInlineStyleAttributesWithPlaceholders($content);
253 $allowedTags = self::buildEditorWpKsesAllowedTags();
254 $result = wp_kses($content, $allowedTags);
255 $result = self::restoreEditorImgPlaceholders($result, $dataUrlPlaceholders);
256 $result = self::restoreEditorStylePlaceholders($result, $stylePlaceholders);
257
258 return $result;
259 }
260
261 /**
262 * Sanitize agreement text allowing basic HTML tags (links, formatting)
263 *
264 * @param mixed $content The agreement text to sanitize
265 *
266 * @return string The sanitized agreement text
267 */
268 public static function sanitizeAgreementText($content): string
269 {
270 return self::sanitizeBasicHtml(self::toHtmlString($content));
271 }
272
273 /**
274 * Sanitize field description allowing basic HTML tags (links, formatting)
275 *
276 * @param mixed $content The field description to sanitize
277 *
278 * @return string The sanitized field description
279 */
280 public static function sanitizeFieldDescription($content): string
281 {
282 return self::sanitizeBasicHtml(self::toHtmlString($content));
283 }
284
285 /**
286 * Sanitize basic HTML content (links, formatting)
287 *
288 * @param string $content The HTML content to sanitize
289 *
290 * @return string The sanitized HTML content
291 */
292 private static function sanitizeBasicHtml(string $content): string
293 {
294 // Define allowed HTML tags for agreement text and field descriptions.
295 // Keep in sync with SafeHTML field-type "field-description" (href, title, class; rel/target enforced on save).
296 $formattingAttrs = [
297 'class' => true,
298 'title' => true,
299 ];
300 $allowedTags = [
301 'a' => [
302 'href' => true,
303 'target' => true,
304 'rel' => true,
305 'title' => true,
306 'class' => true,
307 ],
308 'strong' => $formattingAttrs,
309 'b' => $formattingAttrs,
310 'em' => $formattingAttrs,
311 'i' => $formattingAttrs,
312 'u' => $formattingAttrs,
313 'br' => $formattingAttrs,
314 'p' => $formattingAttrs,
315 'span' => $formattingAttrs,
316 ];
317
318 // Keep in sync with BASIC_HTML_ALLOWED_URI_REGEXP (http, https, mailto; relative URLs have no scheme).
319 $allowedProtocols = ['http', 'https', 'mailto'];
320
321 // Sanitize with allowed tags and protocols
322 $sanitized = wp_kses($content, $allowedTags, $allowedProtocols);
323
324 // Ensure external links open in a new tab with rel="noopener noreferrer"
325 $sanitized = preg_replace_callback(
326 '/<a\s+([^>]*href=["\']https?:\/\/[^"\']+["\'][^>]*)>/i',
327 function ($matches) {
328 $attrs = $matches[1];
329 if (stripos($attrs, 'target=') === false) {
330 $attrs .= ' target="_blank"';
331 }
332 if (stripos($attrs, 'rel=') === false) {
333 $attrs .= ' rel="noopener noreferrer"';
334 }
335 return '<a ' . $attrs . '>';
336 },
337 $sanitized
338 );
339
340 return $sanitized;
341 }
342
343 /**
344 * Get allowed HTML tags and attributes for wp_kses sanitization.
345 * Includes support for signature images with data: URIs.
346 *
347 * @return array<string, array<string, bool>>
348 */
349 public static function getAllowedHtmlTags(): array
350 {
351 return [
352 'img' => [
353 'src' => true,
354 'alt' => true,
355 'style' => true,
356 'class' => true,
357 'width' => true,
358 'height' => true,
359 ],
360 'a' => [
361 'href' => true,
362 'title' => true,
363 'target' => true,
364 'rel' => true,
365 ],
366 'p' => ['class' => true, 'style' => true],
367 'br' => [],
368 'strong' => [],
369 'b' => [],
370 'em' => [],
371 'i' => [],
372 'u' => [],
373 'span' => ['class' => true, 'style' => true],
374 'div' => ['class' => true, 'style' => true],
375 'ul' => ['class' => true],
376 'ol' => ['class' => true],
377 'li' => ['class' => true],
378 'h1' => ['class' => true, 'style' => true],
379 'h2' => ['class' => true, 'style' => true],
380 'h3' => ['class' => true, 'style' => true],
381 'h4' => ['class' => true, 'style' => true],
382 'h5' => ['class' => true, 'style' => true],
383 'h6' => ['class' => true, 'style' => true],
384 'blockquote' => ['class' => true, 'style' => true],
385 ];
386 }
387
388 /**
389 * Get allowed protocols for wp_kses sanitization.
390 * Includes 'data' protocol for signature images.
391 *
392 * @return array<int, string>
393 */
394 public static function getAllowedProtocols(): array
395 {
396 return ['http', 'https', 'mailto', 'data'];
397 }
398
399 /**
400 * Sanitize HTML content using wp_kses with support for signature images.
401 *
402 * @param mixed $content
403 * @return string
404 */
405 public static function sanitizeHtmlContent($content): string
406 {
407 $content = self::toHtmlString($content);
408
409 return wp_kses($content, self::getAllowedHtmlTags(), self::getAllowedProtocols());
410 }
411
412 /**
413 * Sanitize a space-separated string of CSS classes
414 *
415 * Uses sanitize_html_class for each individual class name,
416 * which is more appropriate than sanitize_text_field for CSS classes.
417 *
418 * @param mixed $classString Space-separated CSS class names, or a list of class names
419 * @return string Sanitized space-separated CSS class names
420 */
421 public static function sanitizeCssClasses($classString): string
422 {
423 $classString = trim(self::toCssClassesString($classString));
424 if ($classString === '') {
425 return '';
426 }
427
428 $classes = preg_split('/\s+/', $classString, -1, PREG_SPLIT_NO_EMPTY) ?: [];
429 $sanitized = array_map('sanitize_html_class', $classes);
430 $sanitized = array_filter($sanitized);
431
432 return implode(' ', $sanitized);
433 }
434 }
435