PluginProbe
SureForms – Contact Form Builder, AI Forms, Payment Form, Survey & Quiz / 2.12.8
SureForms – Contact Form Builder, AI Forms, Payment Form, Survey & Quiz v2.12.8
2.12.8 2.12.7 2.12.6 2.12.5 2.12.4 2.12.3 2.12.2 2.12.1 2.12.0 2.11.1 2.11.0 2.10.1 2.10.0 2.9.1 2.9.0 2.8.2 2.8.1 2.7.0 2.7.1 2.8.0 trunk 0.0.10 0.0.11 0.0.12 0.0.13 All 98 releases
← All changes | inc/field-validation.php +162 -31 2.12.5 → 2.12.8 View file →
@@ -218,31 +218,15 @@
218 218 * @return array<string,true> Map of known block id => true. Empty when the form's
219 219 * blocks could not be derived (callers should fail open).
220 220 */
221 221 public static function get_known_field_block_ids( $form_id ) {
222 - static $cache = [];
223 -
224 222 $form_id = Helper::get_integer_value( $form_id );
225 223 if ( $form_id <= 0 ) {
226 224 return [];
227 225 }
228 226
229 - // Only the block walk is memoised. The filtered result deliberately is not: a
230 - // third party returning a malformed value would otherwise poison the set for the
231 - // rest of the request, and because the lookup short-circuits on isset() the walk
232 - // would never be retried.
233 - if ( ! isset( $cache[ $form_id ] ) ) {
234 - $ids = [];
235 - $post = get_post( $form_id );
227 + $walked = self::get_field_identifiers( $form_id );
236 228
237 - if ( $post instanceof \WP_Post && ! empty( $post->post_content ) && function_exists( 'parse_blocks' ) ) {
238 - $visited = [];
239 - self::collect_field_block_ids( parse_blocks( $post->post_content ), $ids, $visited );
240 - }
241 -
242 - $cache[ $form_id ] = $ids;
243 - }
244 -
245 229 /**
246 230 * Filter the set of block ids considered valid for a form during submission.
247 231 *
248 232 * Extensions that inject legitimate fields not present in the form's own block
@@ -255,9 +239,9 @@
255 239 * @since 2.12.3
256 240 * @param array<string,true> $ids Map of known block id => true.
257 241 * @param int $form_id The form post id.
258 242 */
259 - $ids = apply_filters( 'srfm_known_field_block_ids', $cache[ $form_id ], $form_id );
243 + $ids = apply_filters( 'srfm_known_field_block_ids', $walked['ids'], $form_id );
260 244
261 245 // Coerce defensively. A callback returning a list (`[ 'aaa', 'bbb' ]`) rather
262 246 // than a map would otherwise make every real field look unknown, and a non-array
263 247 // return would drop the whole allowlist — so fall back to the walked set instead
@@ -262,9 +246,9 @@
262 246 // than a map would otherwise make every real field look unknown, and a non-array
263 247 // return would drop the whole allowlist — so fall back to the walked set instead
264 248 // of silently turning the check off.
265 249 if ( ! is_array( $ids ) ) {
266 - return $cache[ $form_id ];
250 + return $walked['ids'];
267 251 }
268 252
269 253 return wp_is_numeric_array( $ids ) ? array_fill_keys( array_map( 'strval', $ids ), true ) : $ids;
270 254 }
@@ -269,8 +253,44 @@
269 253 return wp_is_numeric_array( $ids ) ? array_fill_keys( array_map( 'strval', $ids ), true ) : $ids;
270 254 }
271 255
272 256 /**
257 + * The slugs of every SureForms field the form defines, block_id => true style.
258 + *
259 + * A slug is stable across renders where a block_id is not, so it is the identifier
260 + * used to keep a submitted field whose block_id drifted (full-page cache, an editor
261 + * rebuild). Mirrors get_known_field_block_ids(): one memoised walk, an unmemoised
262 + * filter pass.
263 + *
264 + * @param int|mixed $form_id The form post id.
265 + * @since 2.12.7
266 + * @return array<string,true> Map of known slug => true.
267 + */
268 + public static function get_known_field_slugs( $form_id ) {
269 + $form_id = Helper::get_integer_value( $form_id );
270 + if ( $form_id <= 0 ) {
271 + return [];
272 + }
273 +
274 + $walked = self::get_field_identifiers( $form_id );
275 +
276 + /**
277 + * Filter the set of field slugs considered valid for a form during submission.
278 + *
279 + * @since 2.12.7
280 + * @param array<string,true> $slugs Map of known slug => true.
281 + * @param int $form_id The form post id.
282 + */
283 + $slugs = apply_filters( 'srfm_known_field_slugs', $walked['slugs'], $form_id );
284 +
285 + if ( ! is_array( $slugs ) ) {
286 + return $walked['slugs'];
287 + }
288 +
289 + return wp_is_numeric_array( $slugs ) ? array_fill_keys( array_map( 'strval', $slugs ), true ) : $slugs;
290 + }
291 +
292 + /**
273 293 * Remove submitted field keys the form does not define.
274 294 *
275 295 * SECURITY INVARIANT — every submitted key must be checked against the form's own
276 296 * definition. The `-lbl-` substring proves only that a key LOOKS like a SureForms
@@ -299,14 +319,16 @@
299 319 if ( ! is_array( $form_data ) ) {
300 320 return [];
301 321 }
302 322
303 - $known_block_ids = self::get_known_field_block_ids( Helper::get_integer_value( $form_id ) );
323 + $form_id_int = Helper::get_integer_value( $form_id );
324 + $known_block_ids = self::get_known_field_block_ids( $form_id_int );
325 + $known_slugs = self::get_known_field_slugs( $form_id_int );
304 326
305 - // Fail open. The set is empty only when the form's blocks could not be derived
327 + // Fail open. The sets are empty only when the form's blocks could not be derived
306 328 // (no/empty post_content, a parse failure, or a structure this walk does not
307 329 // recognise). Enforcing on an empty set would strip every field.
308 - if ( empty( $known_block_ids ) ) {
330 + if ( empty( $known_block_ids ) && empty( $known_slugs ) ) {
309 331 return $form_data;
310 332 }
311 333
312 334 foreach ( $form_data as $key => $value ) {
@@ -313,9 +335,14 @@
313 335 if ( ! is_string( $key ) || false === strpos( $key, '-lbl-' ) ) {
314 336 continue;
315 337 }
316 338
317 - if ( ! isset( $known_block_ids[ Helper::get_block_id_from_key( $key ) ] ) ) {
339 + // Keep the field when EITHER its block_id OR its slug matches the form. The
340 + // block_id can drift between the (possibly full-page-cached) HTML the visitor
341 + // submitted and the current post_content; the slug does not, so requiring only
342 + // a block_id match silently deleted real submissions (#1517643). A key with
343 + // neither a known block_id nor a known slug is genuinely foreign and dropped.
344 + if ( ! self::field_key_belongs_to_form( $key, $known_block_ids, $known_slugs ) ) {
318 345 unset( $form_data[ $key ] );
319 346 continue;
320 347 }
321 348
@@ -320,9 +347,9 @@
320 347 }
321 348
322 349 // A known key whose value is an array is a repeater: walk its rows.
323 350 if ( is_array( $value ) ) {
324 - $form_data[ $key ] = self::strip_unknown_repeater_keys( $value, $known_block_ids );
351 + $form_data[ $key ] = self::strip_unknown_repeater_keys( $value, $known_block_ids, $known_slugs );
325 352 }
326 353 }
327 354
328 355 return $form_data;
@@ -497,16 +524,110 @@
497 524 ];
498 525 }
499 526
500 527 /**
528 + * The walked allowlists for one form, memoised for the request.
529 + *
530 + * One cache, not one per getter. Both public getters need the same walk, and each
531 + * holding its own `static $cache` meant parse_blocks() plus the recursive collect
532 + * ran twice for every submission -- once for the ids and again for the slugs --
533 + * on a form whose markup can be large.
534 + *
535 + * Only the walk is memoised. The filtered results deliberately are not: a third
536 + * party returning a malformed value would otherwise poison the set for the rest of
537 + * the request, and because the lookup short-circuits on isset() the walk would
538 + * never be retried.
539 + *
540 + * @param int $form_id The form post id, already normalised by the callers.
541 + * @since 2.12.8
542 + * @return array{ids:array<string,true>,slugs:array<string,true>}
543 + */
544 + private static function get_field_identifiers( $form_id ) {
545 + static $cache = [];
546 +
547 + if ( ! isset( $cache[ $form_id ] ) ) {
548 + $cache[ $form_id ] = self::walk_field_identifiers( $form_id );
549 + }
550 +
551 + return $cache[ $form_id ];
552 + }
553 +
554 + /**
555 + * Walk a form's blocks once, returning both the block-id and slug allowlists.
556 + *
557 + * @param int $form_id The form post id.
558 + * @since 2.12.7
559 + * @return array{ids:array<string,true>,slugs:array<string,true>}
560 + */
561 + private static function walk_field_identifiers( $form_id ) {
562 + $ids = [];
563 + $slugs = [];
564 + $post = get_post( $form_id );
565 +
566 + if ( $post instanceof \WP_Post && ! empty( $post->post_content ) && function_exists( 'parse_blocks' ) ) {
567 + $visited = [];
568 + self::collect_field_block_ids( parse_blocks( $post->post_content ), $ids, $slugs, $visited );
569 + }
570 +
571 + return [
572 + 'ids' => $ids,
573 + 'slugs' => $slugs,
574 + ];
575 + }
576 +
577 + /**
578 + * Whether a submitted field key belongs to the form, by block_id or by slug.
579 + *
580 + * @param string $key Submitted field key.
581 + * @param array<string,true> $known_block_ids Allowlisted block ids.
582 + * @param array<string,true> $known_slugs Allowlisted field slugs.
583 + * @since 2.12.7
584 + * @return bool
585 + */
586 + private static function field_key_belongs_to_form( $key, $known_block_ids, $known_slugs ) {
587 + if ( isset( $known_block_ids[ Helper::get_block_id_from_key( $key ) ] ) ) {
588 + return true;
589 + }
590 +
591 + $slug = self::get_slug_from_key( $key );
592 +
593 + return '' !== $slug && isset( $known_slugs[ $slug ] );
594 + }
595 +
596 + /**
597 + * Extract a field's slug from its submitted key.
598 + *
599 + * A key is `srfm-<type>-<block_id>-lbl-<base64 label>-<slug>`. Helper::encode() is
600 + * padding-stripped standard base64 and never contains a hyphen, so the slug is
601 + * everything after the first hyphen that follows `-lbl-` (the slug itself may
602 + * contain hyphens, which is why only the first is used as the boundary).
603 + *
604 + * @param string $key Submitted field key.
605 + * @since 2.12.7
606 + * @return string The slug, or '' when it cannot be derived.
607 + */
608 + private static function get_slug_from_key( $key ) {
609 + if ( ! is_string( $key ) || false === strpos( $key, '-lbl-' ) ) {
610 + return '';
611 + }
612 +
613 + $parts = explode( '-lbl-', $key );
614 + $after = $parts[1] ?? '';
615 + $pos = strpos( $after, '-' );
616 +
617 + return false === $pos ? '' : substr( $after, $pos + 1 );
618 + }
619 +
620 + /**
501 621 * Remove unknown child field keys from repeater rows.
502 622 *
503 623 * @param array<mixed> $rows The repeater's submitted rows.
504 624 * @param array<string,true> $known_block_ids Map of block ids belonging to the form.
625 + * @param array<string,true> $known_slugs Map of field slugs belonging to the form.
505 626 * @since 2.12.3
506 627 * @return array<mixed> The rows with unknown child keys removed.
507 628 */
508 - private static function strip_unknown_repeater_keys( $rows, $known_block_ids ) {
629 + private static function strip_unknown_repeater_keys( $rows, $known_block_ids, $known_slugs = [] ) {
509 630 foreach ( $rows as $index => $row ) {
510 631 if ( ! is_array( $row ) ) {
511 632 continue;
512 633 }
@@ -515,9 +636,9 @@
515 636 if ( ! is_string( $child_key ) || false === strpos( $child_key, '-lbl-' ) ) {
516 637 continue;
517 638 }
518 639
519 - if ( ! isset( $known_block_ids[ Helper::get_block_id_from_key( $child_key ) ] ) ) {
640 + if ( ! self::field_key_belongs_to_form( $child_key, $known_block_ids, $known_slugs ) ) {
520 641 unset( $row[ $child_key ] );
521 642 }
522 643 }
523 644
@@ -531,14 +652,15 @@
531 652 * Recursively collect SureForms block ids from a parsed block tree.
532 653 *
533 654 * @param array<mixed> $blocks Parsed blocks from parse_blocks().
534 655 * @param array<string,true> $ids Accumulator of block id => true (by reference).
656 + * @param array<string,true> $slugs Accumulator of field slug => true (by reference).
535 657 * @param array<int,true> $visited Expanded reusable-block post ids, guards cycles.
536 658 * @param int $depth Current recursion depth, guards pathological trees.
537 659 * @since 2.12.3
538 660 * @return void
539 661 */
540 - private static function collect_field_block_ids( $blocks, &$ids, &$visited, $depth = 0 ) {
662 + private static function collect_field_block_ids( $blocks, &$ids, &$slugs, &$visited, $depth = 0 ) {
541 663 if ( ! is_array( $blocks ) || $depth > 50 ) {
542 664 return;
543 665 }
544 666
@@ -555,10 +677,19 @@
555 677 // sanitize_text_field() only on this side would file any id the sanitiser
556 678 // alters under a different string than the one looked up, making a legitimate
557 679 // field permanently unsubmittable. These are map keys used for comparison
558 680 // only; nothing is echoed from here.
559 - if ( 0 === strpos( $block_name, 'srfm/' ) && ! empty( $attrs['block_id'] ) && is_string( $attrs['block_id'] ) ) {
560 - $ids[ $attrs['block_id'] ] = true;
681 + if ( 0 === strpos( $block_name, 'srfm/' ) ) {
682 + if ( ! empty( $attrs['block_id'] ) && is_string( $attrs['block_id'] ) ) {
683 + $ids[ $attrs['block_id'] ] = true;
684 + }
685 + // Also index by slug. The block_id is rebuilt when the editor recreates a
686 + // field and can differ between the cached HTML a visitor submitted and the
687 + // current post_content, but the slug is the stable field identifier — so a
688 + // slug match keeps a legitimately-submitted field whose block_id drifted.
689 + if ( ! empty( $attrs['slug'] ) && is_string( $attrs['slug'] ) ) {
690 + $slugs[ $attrs['slug'] ] = true;
691 + }
561 692 }
562 693
563 694 // Expand reusable/synced patterns so fields living inside a pattern count as
564 695 // part of the form.
@@ -567,15 +698,15 @@
567 698 if ( $ref > 0 && ! isset( $visited[ $ref ] ) ) {
568 699 $visited[ $ref ] = true;
569 700 $ref_post = get_post( $ref );
570 701 if ( $ref_post instanceof \WP_Post && 'wp_block' === $ref_post->post_type && '' !== $ref_post->post_content ) {
571 - self::collect_field_block_ids( parse_blocks( $ref_post->post_content ), $ids, $visited, $depth + 1 );
702 + self::collect_field_block_ids( parse_blocks( $ref_post->post_content ), $ids, $slugs, $visited, $depth + 1 );
572 703 }
573 704 }
574 705 }
575 706
576 707 if ( ! empty( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) ) {
577 - self::collect_field_block_ids( $block['innerBlocks'], $ids, $visited, $depth + 1 );
708 + self::collect_field_block_ids( $block['innerBlocks'], $ids, $slugs, $visited, $depth + 1 );
578 709 }
579 710 }
580 711 }
581 712