PluginProbe
Bit Form – Contact Form, Payment Forms, Multi Step Forms, Calculator & Custom Form Builder / -3.0.1
Bit Form – Contact Form, Payment Forms, Multi Step Forms, Calculator & Custom Form Builder v-3.0.1
3.3.1 V-3.3.0 3.2.2 3.2.1 3.2.0 3.1.4 3.1.3 3.1.2 3.1.1 3.1.0 V3.0.3 V3.0.2 -3.0.1 V_3.0.0 1.1.1 1.1.8 1.2 1.3 1.4 1.4.18 1.5.2 1.9 2.0 2.10.0 2.10.1 All 138 releases
bit-form / includes / Core / Util / EscapingHelper.php

EscapingHelper.php in Bit Form – Contact Form, Payment Forms, Multi Step Forms, Calculator & Custom Form Builder -3.0.1, at includes/Core/Util/EscapingHelper.php

519 lines 16.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace BitCode\BitForm\Core\Util;
4
5 if (!defined('ABSPATH')) {
6 exit;
7 }
8
9 /**
10 * Form-aware HTML escaping for Bit Form output.
11 *
12 * The renderer assembles markup from many small pieces, each escaped at the
13 * leaf (esc_attr/esc_url/esc_html/wp_kses_post). The whole-form wp_kses() at
14 * echo time exists to satisfy WordPress.Security.EscapeOutput.OutputNotEscaped
15 * without stripping legitimate form constructs.
16 *
17 * Scope of allowed tags/attrs: every tag and attribute observed in the
18 * renderer code path, plus a per-render extension that absorbs builder-defined
19 * customAttributes keys collected from $formContent.
20 */
21 final class EscapingHelper
22 {
23 /**
24 * Universal attributes we allow on every form/structural tag.
25 */
26 private static function commonAttrs()
27 {
28 return [
29 'id' => true,
30 'class' => true,
31 'style' => true,
32 'title' => true,
33 'role' => true,
34 'tabindex' => true,
35 'hidden' => true,
36 'contenteditable' => true,
37 'inert' => true,
38 'dir' => true,
39 'lang' => true,
40 ];
41 }
42
43 /**
44 * data-* keys actually emitted by the renderer (free + Pro).
45 * wp_kses has no wildcard support, so we enumerate.
46 */
47 private static function dataAttrs()
48 {
49 return array_fill_keys([
50 'data-cl',
51 'data-step',
52 'data-list',
53 'data-list-index',
54 'data-index',
55 'data-value',
56 'data-oopt',
57 'data-bf-other-inp',
58 'data-num-value',
59 'data-indx',
60 'data-parent-field-name',
61 'data-sitekey',
62 'data-theme',
63 'data-size',
64 'data-appearance',
65 'data-language',
66 'data-before-interactive-callback',
67 'data-after-interactive-callback',
68 'data-bf-show-picker',
69 'data-bx',
70 'data-ck-icn',
71 ], true);
72 }
73
74 /**
75 * aria-* keys actually emitted by the renderer.
76 */
77 private static function ariaAttrs()
78 {
79 return array_fill_keys([
80 'aria-label',
81 'aria-labelledby',
82 'aria-describedby',
83 'aria-hidden',
84 'aria-live',
85 'aria-required',
86 'aria-invalid',
87 'aria-expanded',
88 'aria-controls',
89 'aria-checked',
90 'aria-selected',
91 'aria-disabled',
92 'aria-pressed',
93 'aria-current',
94 'aria-owns',
95 'aria-haspopup',
96 ], true);
97 }
98
99 /**
100 * Form-specific tags + attributes layered on top of wp_kses_allowed_html('post').
101 */
102 private static function formExtras()
103 {
104 $common = self::commonAttrs();
105 $data = self::dataAttrs();
106 $aria = self::ariaAttrs();
107 $shared = array_merge($common, $data, $aria);
108
109 $svgChildAttrs = array_merge($common, [
110 'd' => true,
111 'points' => true,
112 'x' => true,
113 'y' => true,
114 'x1' => true,
115 'y1' => true,
116 'x2' => true,
117 'y2' => true,
118 'cx' => true,
119 'cy' => true,
120 'r' => true,
121 'rx' => true,
122 'ry' => true,
123 'fill' => true,
124 'fill-rule' => true,
125 'fillrule' => true,
126 'stroke' => true,
127 'stroke-width' => true,
128 'stroke-linecap' => true,
129 'stroke-linejoin' => true,
130 'stroke-dasharray'=> true,
131 'href' => true,
132 'xlink:href' => true,
133 'transform' => true,
134 'viewbox' => true,
135 'data-ck-icn' => true,
136 ]);
137
138 return [
139 // Form tags
140 'form' => array_merge($shared, [
141 'action' => true,
142 'method' => true,
143 'enctype' => true,
144 'novalidate' => true,
145 'target' => true,
146 'name' => true,
147 'autocomplete' => true,
148 ]),
149 'input' => array_merge($shared, [
150 'name' => true,
151 'type' => true,
152 'value' => true,
153 'required' => true,
154 'disabled' => true,
155 'readonly' => true,
156 'placeholder' => true,
157 'min' => true,
158 'max' => true,
159 'step' => true,
160 'inputmode' => true,
161 'list' => true,
162 'accept' => true,
163 'multiple' => true,
164 'checked' => true,
165 'autocomplete' => true,
166 'autofocus' => true,
167 'maxlength' => true,
168 'minlength' => true,
169 'pattern' => true,
170 'size' => true,
171 'src' => true,
172 'alt' => true,
173 'width' => true,
174 'height' => true,
175 'capture' => true,
176 'form' => true,
177 'formaction' => true,
178 'formmethod' => true,
179 'formnovalidate' => true,
180 'formtarget' => true,
181 'dirname' => true,
182 ]),
183 'textarea' => array_merge($shared, [
184 'name' => true,
185 'placeholder' => true,
186 'required' => true,
187 'disabled' => true,
188 'readonly' => true,
189 'rows' => true,
190 'cols' => true,
191 'autocomplete'=> true,
192 'maxlength' => true,
193 'minlength' => true,
194 'wrap' => true,
195 ]),
196 'select' => array_merge($shared, [
197 'name' => true,
198 'disabled' => true,
199 'readonly' => true,
200 'multiple' => true,
201 'required' => true,
202 'size' => true,
203 'autocomplete' => true,
204 ]),
205 'option' => array_merge($shared, [
206 'value' => true,
207 'selected' => true,
208 'disabled' => true,
209 'label' => true,
210 ]),
211 'optgroup' => array_merge($shared, [
212 'label' => true,
213 'disabled' => true,
214 ]),
215 'datalist' => $shared,
216 'fieldset' => array_merge($shared, ['disabled' => true, 'name' => true, 'form' => true]),
217 'legend' => $shared,
218 'output' => array_merge($shared, ['for' => true, 'name' => true, 'form' => true]),
219 'progress' => array_merge($shared, ['value' => true, 'max' => true]),
220 'meter' => array_merge($shared, ['value' => true, 'min' => true, 'max' => true, 'low' => true, 'high' => true, 'optimum' => true]),
221 'button' => array_merge($shared, [
222 'type' => true,
223 'name' => true,
224 'value' => true,
225 'disabled' => true,
226 'autofocus' => true,
227 'form' => true,
228 'formaction' => true,
229 'formmethod' => true,
230 'formnovalidate' => true,
231 'formtarget' => true,
232 ]),
233 'label' => array_merge($shared, [
234 'for' => true,
235 ]),
236
237 // SVG
238 'svg' => array_merge($shared, [
239 'xmlns' => true,
240 'xmlns:xlink' => true,
241 'viewbox' => true,
242 'width' => true,
243 'height' => true,
244 'fill' => true,
245 'stroke' => true,
246 'stroke-width' => true,
247 'stroke-linecap' => true,
248 'stroke-linejoin' => true,
249 'enable-background' => true,
250 'xml:space' => true,
251 'preserveaspectratio' => true,
252 'version' => true,
253 'baseprofile' => true,
254 ]),
255 'g' => $svgChildAttrs,
256 'path' => $svgChildAttrs,
257 'polyline' => $svgChildAttrs,
258 'polygon' => $svgChildAttrs,
259 'line' => $svgChildAttrs,
260 'circle' => $svgChildAttrs,
261 'ellipse' => $svgChildAttrs,
262 'rect' => array_merge($svgChildAttrs, ['width' => true, 'height' => true]),
263 'use' => $svgChildAttrs,
264 'symbol' => $svgChildAttrs,
265 'defs' => $svgChildAttrs,
266 'desc' => $svgChildAttrs,
267 'animatetransform' => array_merge($common, [
268 'attributename' => true,
269 'attributetype' => true,
270 'type' => true,
271 'dur' => true,
272 'from' => true,
273 'to' => true,
274 'repeatcount' => true,
275 'begin' => true,
276 'end' => true,
277 'values' => true,
278 'keytimes' => true,
279 'calcmode' => true,
280 'additive' => true,
281 'fill' => true,
282 ]),
283 'animate' => array_merge($common, [
284 'attributename' => true,
285 'attributetype' => true,
286 'dur' => true,
287 'from' => true,
288 'to' => true,
289 'repeatcount' => true,
290 'begin' => true,
291 'end' => true,
292 'values' => true,
293 'keytimes' => true,
294 'calcmode' => true,
295 'fill' => true,
296 ]),
297
298 // Media + structural extras (override 'post' attrs to add data-*/aria-* + extras)
299 'div' => $shared,
300 'span' => $shared,
301 'section' => $shared,
302 'ul' => $shared,
303 'ol' => $shared,
304 'li' => $shared,
305 'p' => $shared,
306 'a' => array_merge($shared, [
307 'href' => true,
308 'target' => true,
309 'rel' => true,
310 'download' => true,
311 'name' => true,
312 ]),
313 'img' => array_merge($shared, [
314 'src' => true,
315 'alt' => true,
316 'width' => true,
317 'height' => true,
318 'srcset' => true,
319 'sizes' => true,
320 'loading' => true,
321 'decoding' => true,
322 ]),
323 'picture' => $shared,
324 'source' => array_merge($common, ['src' => true, 'srcset' => true, 'media' => true, 'type' => true, 'sizes' => true]),
325 'iframe' => array_merge($shared, ['src' => true, 'name' => true, 'width' => true, 'height' => true, 'allow' => true, 'allowfullscreen' => true, 'sandbox' => true, 'referrerpolicy' => true, 'loading' => true]),
326 'canvas' => array_merge($shared, ['width' => true, 'height' => true]),
327 'h1' => $shared,
328 'h2' => $shared,
329 'h3' => $shared,
330 'h4' => $shared,
331 'h5' => $shared,
332 'h6' => $shared,
333 'br' => $common,
334 'hr' => $common,
335
336 // bf_globals + show-picker bridge are emitted via wp_add_inline_script,
337 // so they never travel through kses. The remaining inline <script> tag
338 // surfaced by kses is TurnstileField's interaction-only callback block,
339 // which defines globally-named functions Cloudflare's widget invokes.
340 // Allow only the id attribute; content is server-controlled.
341 'script' => ['id' => true],
342 ];
343 }
344
345 /**
346 * Returns the default allowed HTML tags map for form output.
347 * Filterable via 'bitforms_allowed_html_tags'.
348 *
349 * @return array<string, array<string, bool>> wp_kses-compatible map
350 */
351 public static function getAllowedHtmlTags()
352 {
353 $base = wp_kses_allowed_html('post');
354 $extras = self::formExtras();
355
356 foreach ($extras as $tag => $attrs) {
357 $base[$tag] = isset($base[$tag]) && is_array($base[$tag])
358 ? array_merge($base[$tag], $attrs)
359 : $attrs;
360 }
361
362 /**
363 * Filter the form-output allowlist.
364 *
365 * @param array $base wp_kses allowed HTML map
366 * @param object|null $formContent Decoded form content (fields, buttons, layout)
367 */
368 $allowed = apply_filters('bitforms_allowed_html_tags', $base, null);
369 return is_array($allowed) ? $allowed : $base;
370 }
371
372 /**
373 * Sanitizes assembled form HTML using a form-aware allowlist that includes
374 * builder-defined customAttributes keys collected from the form definition.
375 *
376 * NOTE: prefer calling `wp_kses($html, EscapingHelper::getFormAllowedHtml($formContent))`
377 * directly at echo sites — Plugin Check / PHPCS only recognizes the literal
378 * `wp_kses` / `esc_*` tokens as escape functions. This wrapper exists for
379 * non-PCP-scanned callers.
380 *
381 * @param string $html Assembled form HTML
382 * @param object|null $formContent Decoded form content (object with ->fields, ->buttons, etc.)
383 * @return string Sanitized HTML
384 */
385 public static function ksesFormOutput($html, $formContent = null)
386 {
387 return wp_kses($html, self::getFormAllowedHtml($formContent));
388 }
389
390 /**
391 * Back-compat wrapper.
392 */
393 public static function ksesFormOutputDefault($html)
394 {
395 return wp_kses($html, self::getAllowedHtmlTags());
396 }
397
398 /**
399 * Build allowed-HTML map extended with customAttribute keys from $formContent.
400 * Public so echo sites can pass the result directly to `wp_kses()` and satisfy
401 * Plugin Check's `WordPress.Security.EscapeOutput.OutputNotEscaped` sniff.
402 *
403 * @param object|null $formContent
404 * @return array
405 */
406 public static function getFormAllowedHtml($formContent = null)
407 {
408 $base = wp_kses_allowed_html('post');
409 $extras = self::formExtras();
410
411 foreach ($extras as $tag => $attrs) {
412 $base[$tag] = isset($base[$tag]) && is_array($base[$tag])
413 ? array_merge($base[$tag], $attrs)
414 : $attrs;
415 }
416
417 $customKeys = self::collectCustomAttributeKeys($formContent);
418
419 if (!empty($customKeys)) {
420 // Broadcast to every tag we render through (bounded set).
421 $broadcastTags = [
422 'div', 'span', 'section', 'ul', 'ol', 'li', 'p', 'a',
423 'form', 'input', 'textarea', 'select', 'option', 'optgroup',
424 'datalist', 'fieldset', 'legend', 'output', 'progress', 'meter',
425 'button', 'label', 'img', 'picture', 'iframe', 'canvas',
426 'svg', 'g', 'path', 'polyline', 'polygon', 'line', 'circle',
427 'ellipse', 'rect', 'use', 'symbol',
428 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
429 ];
430 foreach ($broadcastTags as $tag) {
431 if (!isset($base[$tag]) || !is_array($base[$tag])) {
432 continue;
433 }
434 foreach ($customKeys as $key) {
435 $base[$tag][$key] = true;
436 }
437 }
438 }
439
440 /**
441 * @param array $base wp_kses allowed HTML map
442 * @param object|null $formContent Decoded form content
443 */
444 $allowed = apply_filters('bitforms_allowed_html_tags', $base, $formContent);
445 return is_array($allowed) ? $allowed : $base;
446 }
447
448 /**
449 * Walk $formContent->fields and ->buttons, collect every custom-attribute key,
450 * filter out unsafe shapes (event handlers, malformed names, reserved keys).
451 *
452 * @return string[] Unique safe attribute keys
453 */
454 private static function collectCustomAttributeKeys($formContent)
455 {
456 if (!is_object($formContent)) {
457 return [];
458 }
459
460 $keys = [];
461
462 if (isset($formContent->fields) && (is_object($formContent->fields) || is_array($formContent->fields))) {
463 foreach ((array) $formContent->fields as $field) {
464 self::collectKeysFromCustomAttrs($field, $keys);
465 }
466 }
467
468 if (isset($formContent->buttons)) {
469 self::collectKeysFromCustomAttrs($formContent->buttons, $keys);
470 }
471
472 return array_keys($keys);
473 }
474
475 private static function collectKeysFromCustomAttrs($field, array &$keys)
476 {
477 if (!is_object($field) || !isset($field->customAttributes)) {
478 return;
479 }
480 $customAttributes = $field->customAttributes;
481 if (!is_object($customAttributes) && !is_array($customAttributes)) {
482 return;
483 }
484 foreach ((array) $customAttributes as $element => $attrList) {
485 if (!is_array($attrList) && !is_object($attrList)) {
486 continue;
487 }
488 foreach ((array) $attrList as $attr) {
489 if (!is_object($attr) || !isset($attr->key)) {
490 continue;
491 }
492 $key = (string) $attr->key;
493 if (self::isSafeAttributeKey($key)) {
494 $keys[strtolower($key)] = true;
495 }
496 }
497 }
498 }
499
500 /**
501 * Reject event-handler attributes, namespace-injection attempts, and any key
502 * not matching a conservative HTML attribute-name shape.
503 */
504 public static function isSafeAttributeKey($key)
505 {
506 if (!is_string($key) || '' === $key) {
507 return false;
508 }
509 if (preg_match('/^on/i', $key)) {
510 return false;
511 }
512 $reserved = ['xmlns', 'xmlns:xlink', 'xlink:href'];
513 if (in_array(strtolower($key), $reserved, true)) {
514 return false;
515 }
516 return (bool) preg_match('/^[A-Za-z_:][A-Za-z0-9_:.\-]*$/', $key);
517 }
518 }
519