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 +416 -8 2.11.1 → 2.12.8 View file →
@@ -196,8 +196,167 @@
196 196 return is_array( $get_form_config ) ? $get_form_config : [];
197 197 }
198 198
199 199 /**
200 + * Build the set of block ids that legitimately belong to a form.
201 + *
202 + * Walks the form's block markup and collects the `block_id` of every SureForms
203 + * (`srfm/*`) block, recursing into `innerBlocks` (so repeater/container children are
204 + * included) and expanding `core/block` reusable/synced pattern references into their
205 + * `wp_block` post (so pattern-embedded fields are included too). A cycle guard on the
206 + * reference ids prevents infinite recursion.
207 + *
208 + * Used by {@see self::strip_unknown_field_keys()}, which DROPS submitted keys whose
209 + * block id is not part of the form rather than rejecting the submission — see the
210 + * note there for why rejecting made cached forms unsubmittable.
211 + *
212 + * Note: this is deliberately NOT `prepared_validation_data()`
213 + * — that map only holds blocks with extra validation config (dropdowns, payments,
214 + * textarea min-length) and omits plain inputs, so it is not a field allowlist.
215 + *
216 + * @param int|mixed $form_id The form post id.
217 + * @since 2.12.3
218 + * @return array<string,true> Map of known block id => true. Empty when the form's
219 + * blocks could not be derived (callers should fail open).
220 + */
221 + public static function get_known_field_block_ids( $form_id ) {
222 + $form_id = Helper::get_integer_value( $form_id );
223 + if ( $form_id <= 0 ) {
224 + return [];
225 + }
226 +
227 + $walked = self::get_field_identifiers( $form_id );
228 +
229 + /**
230 + * Filter the set of block ids considered valid for a form during submission.
231 + *
232 + * Extensions that inject legitimate fields not present in the form's own block
233 + * markup (for example dynamically generated keys) can add their block ids here so
234 + * those submissions are not dropped as unknown.
235 + *
236 + * Expects a map of `block_id => true`. A plain list of ids is accepted and
237 + * converted; any other return value is ignored in favour of the walked set.
238 + *
239 + * @since 2.12.3
240 + * @param array<string,true> $ids Map of known block id => true.
241 + * @param int $form_id The form post id.
242 + */
243 + $ids = apply_filters( 'srfm_known_field_block_ids', $walked['ids'], $form_id );
244 +
245 + // Coerce defensively. A callback returning a list (`[ 'aaa', 'bbb' ]`) rather
246 + // than a map would otherwise make every real field look unknown, and a non-array
247 + // return would drop the whole allowlist — so fall back to the walked set instead
248 + // of silently turning the check off.
249 + if ( ! is_array( $ids ) ) {
250 + return $walked['ids'];
251 + }
252 +
253 + return wp_is_numeric_array( $ids ) ? array_fill_keys( array_map( 'strval', $ids ), true ) : $ids;
254 + }
255 +
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 + /**
293 + * Remove submitted field keys the form does not define.
294 + *
295 + * SECURITY INVARIANT — every submitted key must be checked against the form's own
296 + * definition. The `-lbl-` substring proves only that a key LOOKS like a SureForms
297 + * field, not that this form actually defines it, so shape alone is never sufficient:
298 + * only keys the form declares may reach storage, email or export.
299 + *
300 + * Unknown keys are dropped rather than rejected. Rejecting looked safer but behaved
301 + * badly: the allowlist is derived from `post_content` at submit time while the
302 + * visitor's HTML was rendered earlier, so full-page caching or an editor-side
303 + * `block_id` reassignment would make an otherwise valid form unsubmittable behind an
304 + * error the visitor cannot act on. Dropping meets the same security goal — the
305 + * invented key never reaches storage, email or export — without that failure mode.
306 + *
307 + * Repeater rows arrive as `repeaterKey[index][childKey]`, which PHP collapses into a
308 + * single top-level key holding nested arrays. Those child keys are copied verbatim by
309 + * Pro's `process_repeater_field()` and label-decoded downstream, so they are walked
310 + * here too; the allowlist already contains repeater children because the collector
311 + * recurses into `innerBlocks`.
312 + *
313 + * @param array<mixed> $form_data The submitted form data (sanitized).
314 + * @param int|mixed $form_id The ID of the form being submitted.
315 + * @since 2.12.3
316 + * @return array<mixed> The form data with unknown field keys removed.
317 + */
318 + public static function strip_unknown_field_keys( $form_data, $form_id ) {
319 + if ( ! is_array( $form_data ) ) {
320 + return [];
321 + }
322 +
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 );
326 +
327 + // Fail open. The sets are empty only when the form's blocks could not be derived
328 + // (no/empty post_content, a parse failure, or a structure this walk does not
329 + // recognise). Enforcing on an empty set would strip every field.
330 + if ( empty( $known_block_ids ) && empty( $known_slugs ) ) {
331 + return $form_data;
332 + }
333 +
334 + foreach ( $form_data as $key => $value ) {
335 + if ( ! is_string( $key ) || false === strpos( $key, '-lbl-' ) ) {
336 + continue;
337 + }
338 +
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 ) ) {
345 + unset( $form_data[ $key ] );
346 + continue;
347 + }
348 +
349 + // A known key whose value is an array is a repeater: walk its rows.
350 + if ( is_array( $value ) ) {
351 + $form_data[ $key ] = self::strip_unknown_repeater_keys( $value, $known_block_ids, $known_slugs );
352 + }
353 + }
354 +
355 + return $form_data;
356 + }
357 +
358 + /**
200 359 * Validate form data for a given form.
201 360 *
202 361 * This function checks each field in the submitted form data (including uploaded files)
203 362 * and applies the 'srfm_validate_form_data' filter to validate each field according to
@@ -233,16 +392,16 @@
233 392 continue;
234 393 }
235 394
236 395 $get_name_with_id = explode( '-lbl-', $key );
237 - // Extract the part after the last '-' in the key, if it matches the pattern.
238 - // Example: $get_name_with_id[0] = "srfm-email-c867d9d9".
239 - // $extracted_id = "c867d9d9".
240 - $extracted_id = '';
241 - if ( is_string( $key ) && preg_match( '/-([a-zA-Z0-9]+)$/', $get_name_with_id[0], $matches ) ) {
242 - $extracted_id = $matches[1];
243 - // Now $extracted_id contains "c867d9d9" for "srfm-email-c867d9d9".
244 - }
396 + // Extract the block id, i.e. the segment right before the first '-lbl-'.
397 + // Example: $get_name_with_id[0] = "srfm-email-c867d9d9" => "c867d9d9".
398 + //
399 + // Uses the shared Helper so submission agrees with every downstream consumer
400 + // of a field key (entries export, uniqueness check, smart tags). A local
401 + // regex here would define a second, subtly different notion of "the block
402 + // id" and the two could disagree on a legacy id.
403 + $extracted_id = is_string( $key ) ? Helper::get_block_id_from_key( $key ) : '';
245 404
246 405 // $get_slug will be the slug after the first hyphen in the second part.
247 406 // Example: $get_name_with_id[1] = "email" or "field-email", $get_slug = "email".
248 407 $get_slug = isset( $get_name_with_id[1] ) ? preg_replace( '/^[^-]+-/', '', $get_name_with_id[1] ) : '';
@@ -294,13 +453,262 @@
294 453 : __( 'Please enter at least %s characters.', 'sureforms' );
295 454 $not_valid_fields[ $key ] = sprintf( $min_chars_message, $min_length );
296 455 }
297 456 }
457 +
458 + // Email field RFC 5321 length limits (local part / domain), overridable via filter.
459 + // Only the main email value is in form data (the confirm input has no `name`),
460 + // so the server validates that value; the client mirrors this for both inputs.
461 + // Split on the LAST @ per RFC 5321 so the local part may contain a quoted @.
462 + $at_pos = is_string( $value ) && '' !== $value ? strrpos( $value, '@' ) : false;
463 + if ( 'srfm-email' === $get_field_name && is_string( $value ) && false !== $at_pos ) {
464 + $email_limits = self::get_email_char_limits();
465 + $local_max = $email_limits['local'];
466 + $domain_max = $email_limits['domain'];
467 + $local_len = mb_strlen( substr( $value, 0, $at_pos ) );
468 + $domain_len = mb_strlen( substr( $value, $at_pos + 1 ) );
469 +
470 + $dynamic_messages = Translatable::dynamic_validation_messages();
471 + if ( $local_max > 0 && $local_len > $local_max ) {
472 + $local_message = isset( $dynamic_messages['srfm_email_local_max_length'] ) && is_string( $dynamic_messages['srfm_email_local_max_length'] ) && '' !== $dynamic_messages['srfm_email_local_max_length']
473 + ? $dynamic_messages['srfm_email_local_max_length']
474 + /* translators: %s: maximum characters allowed before the @ symbol. */
475 + : __( 'The part before @ may not exceed %s characters.', 'sureforms' );
476 + $not_valid_fields[ $key ] = sprintf( $local_message, $local_max );
477 + } elseif ( $domain_max > 0 && $domain_len > $domain_max ) {
478 + $domain_message = isset( $dynamic_messages['srfm_email_domain_max_length'] ) && is_string( $dynamic_messages['srfm_email_domain_max_length'] ) && '' !== $dynamic_messages['srfm_email_domain_max_length']
479 + ? $dynamic_messages['srfm_email_domain_max_length']
480 + /* translators: %s: maximum characters allowed after the @ symbol. */
481 + : __( 'The part after @ may not exceed %s characters.', 'sureforms' );
482 + $not_valid_fields[ $key ] = sprintf( $domain_message, $domain_max );
483 + }
484 + }
298 485 }
299 486
300 487 // Return the array of invalid fields and their error messages.
301 488 // Example: [ 'srfm-email-c867d9d9-lbl-email' => 'This field is required.' ].
302 489 return $not_valid_fields;
490 + }
491 +
492 + /**
493 + * Resolve the Email field character limits (RFC 5321), split on the last @.
494 + *
495 + * Single source of truth shared by the server validation and the limits localized to the
496 + * frontend script, so a filter override applies consistently to both.
497 + *
498 + * @return array{local:int,domain:int} Resolved limits. A value of 0 disables that check.
499 + * @since 2.12.1
500 + */
501 + public static function get_email_char_limits() {
502 + /**
503 + * Filters the Email field character limits (RFC 5321).
504 + *
505 + * @param array $limits {
506 + * Character limits for the email value, split on the last @.
507 + *
508 + * @type int $local Max characters before the @. 0 disables the check. Default 64.
509 + * @type int $domain Max characters after the @. 0 disables the check. Default 255.
510 + * }
511 + * @since 2.12.1
512 + */
513 + $email_limits = apply_filters(
514 + 'srfm_email_field_char_limits',
515 + [
516 + 'local' => 64,
517 + 'domain' => 255,
518 + ]
519 + );
520 +
521 + return [
522 + 'local' => isset( $email_limits['local'] ) ? absint( $email_limits['local'] ) : 64,
523 + 'domain' => isset( $email_limits['domain'] ) ? absint( $email_limits['domain'] ) : 255,
524 + ];
525 + }
526 +
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 + /**
621 + * Remove unknown child field keys from repeater rows.
622 + *
623 + * @param array<mixed> $rows The repeater's submitted rows.
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.
626 + * @since 2.12.3
627 + * @return array<mixed> The rows with unknown child keys removed.
628 + */
629 + private static function strip_unknown_repeater_keys( $rows, $known_block_ids, $known_slugs = [] ) {
630 + foreach ( $rows as $index => $row ) {
631 + if ( ! is_array( $row ) ) {
632 + continue;
633 + }
634 +
635 + foreach ( array_keys( $row ) as $child_key ) {
636 + if ( ! is_string( $child_key ) || false === strpos( $child_key, '-lbl-' ) ) {
637 + continue;
638 + }
639 +
640 + if ( ! self::field_key_belongs_to_form( $child_key, $known_block_ids, $known_slugs ) ) {
641 + unset( $row[ $child_key ] );
642 + }
643 + }
644 +
645 + $rows[ $index ] = $row;
646 + }
647 +
648 + return $rows;
649 + }
650 +
651 + /**
652 + * Recursively collect SureForms block ids from a parsed block tree.
653 + *
654 + * @param array<mixed> $blocks Parsed blocks from parse_blocks().
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).
657 + * @param array<int,true> $visited Expanded reusable-block post ids, guards cycles.
658 + * @param int $depth Current recursion depth, guards pathological trees.
659 + * @since 2.12.3
660 + * @return void
661 + */
662 + private static function collect_field_block_ids( $blocks, &$ids, &$slugs, &$visited, $depth = 0 ) {
663 + if ( ! is_array( $blocks ) || $depth > 50 ) {
664 + return;
665 + }
666 +
667 + foreach ( $blocks as $block ) {
668 + if ( ! is_array( $block ) ) {
669 + continue;
670 + }
671 +
672 + $attrs = isset( $block['attrs'] ) && is_array( $block['attrs'] ) ? $block['attrs'] : [];
673 + $block_name = isset( $block['blockName'] ) && is_string( $block['blockName'] ) ? $block['blockName'] : '';
674 +
675 + // Stored raw, deliberately. The lookup side derives the id from the submitted
676 + // key via Helper::get_block_id_from_key(), which does not sanitise — putting
677 + // sanitize_text_field() only on this side would file any id the sanitiser
678 + // alters under a different string than the one looked up, making a legitimate
679 + // field permanently unsubmittable. These are map keys used for comparison
680 + // only; nothing is echoed from here.
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 + }
692 + }
693 +
694 + // Expand reusable/synced patterns so fields living inside a pattern count as
695 + // part of the form.
696 + if ( 'core/block' === $block_name && ! empty( $attrs['ref'] ) && is_scalar( $attrs['ref'] ) ) {
697 + $ref = absint( $attrs['ref'] );
698 + if ( $ref > 0 && ! isset( $visited[ $ref ] ) ) {
699 + $visited[ $ref ] = true;
700 + $ref_post = get_post( $ref );
701 + if ( $ref_post instanceof \WP_Post && 'wp_block' === $ref_post->post_type && '' !== $ref_post->post_content ) {
702 + self::collect_field_block_ids( parse_blocks( $ref_post->post_content ), $ids, $slugs, $visited, $depth + 1 );
703 + }
704 + }
705 + }
706 +
707 + if ( ! empty( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) ) {
708 + self::collect_field_block_ids( $block['innerBlocks'], $ids, $slugs, $visited, $depth + 1 );
709 + }
710 + }
303 711 }
304 712
305 713 /**
306 714 * Process payment block configuration.