PluginProbe
Bit Form – Contact Form, Payment Forms, Multi Step Forms, Calculator & Custom Form Builder / 3.1.2
Bit Form – Contact Form, Payment Forms, Multi Step Forms, Calculator & Custom Form Builder v3.1.2
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 2.10.2 All 137 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.1.2, at includes/Core/Util/EscapingHelper.php

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