| @@ -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 | |