PluginProbe ʕ •ᴥ•ʔ
GenerateBlocks / 2.2.0
GenerateBlocks v2.2.0
trunk 1.0 1.0.1 1.0.2 1.1.0 1.1.1 1.1.2 1.2.0 1.3.0 1.3.1 1.3.2 1.3.3 1.3.4 1.3.5 1.4.0 1.4.1 1.4.2 1.4.3 1.4.4 1.5.0 1.5.1 1.5.2 1.5.3 1.5.4 1.6.0 1.7.0 1.7.1 1.7.2 1.7.3 1.8.0 1.8.1 1.8.2 1.8.3 1.9.0 1.9.1 2.0.0 2.0.1 2.0.2 2.1.0 2.1.1 2.1.2 2.2.0 2.2.1 2.3.0
generateblocks / includes / class-dynamic-tag-security.php
generateblocks / includes Last commit date
blocks 1 year ago dynamic-tags 6 months ago pattern-library 1 year ago utils 2 years ago class-do-css.php 2 years ago class-dynamic-content.php 6 months ago class-dynamic-tag-security.php 6 months ago class-enqueue-css.php 1 year ago class-legacy-attributes.php 4 years ago class-map-deprecated-attributes.php 2 years ago class-meta-handler.php 6 months ago class-plugin-update.php 1 year ago class-query-loop.php 2 years ago class-query-utils.php 6 months ago class-render-blocks.php 1 year ago class-rest.php 1 year ago class-settings.php 1 year ago dashboard.php 1 year ago defaults.php 1 year ago deprecated.php 1 year ago functions.php 6 months ago general.php 8 months ago
class-dynamic-tag-security.php
876 lines
1 <?php
2 /**
3 * Dynamic tag security utilities.
4 *
5 * @package GenerateBlocks
6 */
7
8 if ( ! defined( 'ABSPATH' ) ) {
9 exit; // Exit if accessed directly.
10 }
11
12 /**
13 * Class Dynamic_Tag_Security
14 */
15 class GenerateBlocks_Dynamic_Tag_Security extends GenerateBlocks_Singleton {
16 const DISALLOWED_KEYS = [
17 'post_password',
18 'password',
19 'user_pass',
20 'user_activation_key',
21 ];
22
23 /**
24 * Initialize all hooks.
25 *
26 * @return void
27 */
28 public function init() {
29 add_filter( 'wp_insert_post_data', [ $this, 'validate_content_before_save' ], 10, 2 );
30 add_action( 'init', [ $this, 'register_rest_validation_for_post_types' ], 999 );
31 add_filter( 'rest_pre_dispatch', [ $this, 'validate_autosave_rest_request' ], 10, 3 );
32 }
33
34 /**
35 * Validate dynamic tag usage in post content at save time.
36 *
37 * Prevents restricted meta keys from being saved inside GenerateBlocks dynamic tags.
38 *
39 * @since 2.2.0
40 *
41 * @param string $content The post content to validate.
42 * @return true|WP_Error True if valid, WP_Error if validation fails.
43 */
44 public static function validate_content( $content ) {
45 return self::validate_content_with_existing_signatures( $content );
46 }
47
48 /**
49 * Validate content while allowing previously existing violations.
50 *
51 * @since 2.2.0
52 *
53 * @param string $content The post content to validate.
54 * @param array $existing_signatures List of violation signatures already stored for this post.
55 * @return true|WP_Error
56 */
57 public static function validate_content_with_existing_signatures( $content, $existing_signatures = [] ) {
58 return self::validate_content_internal( $content, $existing_signatures );
59 }
60
61 /**
62 * Retrieve restricted reference signatures present in content.
63 *
64 * @since 2.2.0
65 *
66 * @param string $content Serialized post content.
67 * @return array<int, string>
68 */
69 public static function get_restricted_reference_signatures( $content ) {
70 $violations = self::get_violation_errors( $content );
71 $signatures = [];
72
73 foreach ( $violations as $signature => $details ) {
74 $signatures[ $signature ] = (int) ( $details['count'] ?? 0 );
75 }
76
77 return $signatures;
78 }
79
80 /**
81 * Determine if a given piece of content should be scanned for dynamic tag usage.
82 *
83 * @since 2.2.0
84 *
85 * @param string $content The post content to evaluate.
86 * @return bool True when validation should proceed.
87 */
88 public static function should_validate_content( $content ) {
89 $content = self::normalize_serialized_content( $content );
90
91 if ( '' === $content ) {
92 return false;
93 }
94
95 $has_dynamic_tag_syntax = false !== strpos( $content, '{{' );
96 $has_dynamic_attributes = false;
97
98 if ( class_exists( 'GenerateBlocks_Dynamic_Content' ) && method_exists( 'GenerateBlocks_Dynamic_Content', 'content_has_dynamic_attribute_markers' ) ) {
99 $has_dynamic_attributes = (bool) GenerateBlocks_Dynamic_Content::content_has_dynamic_attribute_markers( $content );
100 }
101
102 if ( ! $has_dynamic_tag_syntax ) {
103 if ( $has_dynamic_attributes ) {
104 return true;
105 }
106
107 return (bool) apply_filters( 'generateblocks_force_validate_content', false, $content );
108 }
109
110 $block_names = self::get_dynamic_tag_enabled_blocks();
111
112 if ( empty( $block_names ) ) {
113 return false;
114 }
115
116 foreach ( $block_names as $block_name ) {
117 if ( function_exists( 'has_block' ) && has_block( $block_name, $content ) ) {
118 return true;
119 }
120 }
121
122 return (bool) apply_filters( 'generateblocks_force_validate_content', false, $content );
123 }
124
125 /**
126 * Extract violation errors keyed by signature.
127 *
128 * @since 2.2.0
129 *
130 * @param string $content Serialized post content.
131 * @return array<string, array{error:WP_Error,count:int}>
132 */
133 protected static function get_violation_errors( $content ) {
134 $content = self::normalize_serialized_content( $content );
135
136 if ( '' === $content ) {
137 return [];
138 }
139
140 return self::scan_content_for_violations( $content );
141 }
142
143 /**
144 * Validate content while considering existing violations.
145 *
146 * @param string $content Content to validate.
147 * @param array $existing_signatures Previously stored violation signatures.
148 * @return true|WP_Error
149 */
150 protected static function validate_content_internal( $content, $existing_signatures = [] ) {
151 $content = self::normalize_serialized_content( $content );
152 $violations = self::get_violation_errors( $content );
153
154 if ( empty( $violations ) ) {
155 return true;
156 }
157
158 if ( ! empty( $existing_signatures ) ) {
159 $existing_counts = self::normalize_signature_counts( $existing_signatures );
160
161 foreach ( $violations as $signature => $details ) {
162 $count = (int) ( $details['count'] ?? 0 );
163 $allowance = isset( $existing_counts[ $signature ] ) ? (int) $existing_counts[ $signature ] : 0;
164
165 if ( $count > $allowance ) {
166 return $details['error'];
167 }
168 }
169
170 return true;
171 }
172
173 $first_violation = reset( $violations );
174
175 return $first_violation['error'];
176 }
177
178 /**
179 * Format a violation signature for comparison.
180 *
181 * @param string $type Type of violation (user_meta/post_meta/term_meta).
182 * @param string $field_name Field name involved.
183 * @return string
184 */
185 protected static function format_violation_signature( $type, $field_name ) {
186 $type = is_string( $type ) ? strtolower( trim( $type ) ) : '';
187 $field_name = is_string( $field_name ) ? strtolower( trim( $field_name ) ) : '';
188
189 if ( '' === $type || '' === $field_name ) {
190 return '';
191 }
192
193 return "{$type}:{$field_name}";
194 }
195
196 /**
197 * Normalize stored signature data into signature => count pairs.
198 *
199 * @param array $signatures Raw signature data.
200 * @return array<string,int>
201 */
202 protected static function normalize_signature_counts( $signatures ) {
203 if ( empty( $signatures ) || ! is_array( $signatures ) ) {
204 return [];
205 }
206
207 $normalized = [];
208
209 foreach ( $signatures as $key => $value ) {
210 if ( is_string( $key ) && is_numeric( $value ) ) {
211 $normalized[ $key ] = max( 0, (int) $value );
212 } elseif ( is_string( $value ) ) {
213 $normalized[ $value ] = isset( $normalized[ $value ] ) ? $normalized[ $value ] + 1 : 1;
214 }
215 }
216
217 return $normalized;
218 }
219
220 /**
221 * Store or increment a violation entry.
222 *
223 * @param array $violations Reference to violations array.
224 * @param string $signature Signature key.
225 * @param WP_Error $error Violation error.
226 * @param int $increment Optional increment amount.
227 * @return void
228 */
229 protected static function add_violation_entry( array &$violations, $signature, $error, $increment = 1 ) {
230 if ( ! isset( $violations[ $signature ] ) ) {
231 $violations[ $signature ] = [
232 'error' => $error,
233 'count' => 0,
234 ];
235 }
236
237 $violations[ $signature ]['count'] += max( 1, (int) $increment );
238 }
239
240 /**
241 * Scan content and collect violation errors.
242 *
243 * @param string $content Serialized block content.
244 * @return array<string, array{error:WP_Error,count:int}>
245 */
246 protected static function scan_content_for_violations( $content ) {
247 $content = self::normalize_serialized_content( $content );
248 $violations = [];
249 $rules = self::get_dynamic_tag_validation_rules();
250
251 foreach ( $rules as $rule_key => $rule ) {
252 if ( ! self::should_enforce_rule( $rule ) ) {
253 continue;
254 }
255
256 if ( ! empty( $rule['pattern'] ) && preg_match_all( $rule['pattern'], $content, $matches ) ) {
257 $field_matches = ! empty( $matches[1] ) ? $matches[1] : ( $matches[0] ?? [] );
258
259 foreach ( $field_matches as $field_name ) {
260 if ( isset( $rule['fixed_field'] ) ) {
261 $field_name = $rule['fixed_field'];
262 }
263
264 $result = call_user_func( $rule['validator'], $field_name );
265
266 if ( is_wp_error( $result ) ) {
267 $signature = self::format_violation_signature( $rule_key, $field_name );
268
269 if ( $signature ) {
270 self::add_violation_entry( $violations, $signature, $result );
271 }
272 }
273 }
274 }
275
276 if ( ! empty( $rule['link_tokens'] ) ) {
277 $link_violations = self::collect_meta_link_target_violations(
278 $content,
279 $rule_key,
280 array_fill_keys( $rule['link_tokens'], $rule['validator'] )
281 );
282
283 foreach ( $link_violations as $signature => $details ) {
284 self::add_violation_entry(
285 $violations,
286 $signature,
287 $details['error'],
288 $details['count'] ?? 1
289 );
290 }
291 }
292 }
293
294 if (
295 class_exists( 'GenerateBlocks_Dynamic_Content' ) &&
296 method_exists( 'GenerateBlocks_Dynamic_Content', 'get_dynamic_attribute_violation_items' )
297 ) {
298 $attribute_violations = GenerateBlocks_Dynamic_Content::get_dynamic_attribute_violation_items( $content );
299
300 foreach ( $attribute_violations as $violation ) {
301 $type = $violation['type'] ?? '';
302 $field_name = $violation['field'] ?? '';
303 $error = $violation['error'] ?? null;
304
305 if ( ! $error instanceof WP_Error ) {
306 continue;
307 }
308
309 $signature = self::format_violation_signature( $type, $field_name );
310
311 if ( $signature ) {
312 self::add_violation_entry( $violations, $signature, $error );
313 }
314 }
315 }
316
317 return $violations;
318 }
319
320 /**
321 * Retrieve dynamic tag validation rules.
322 *
323 * @return array
324 */
325 protected static function get_dynamic_tag_validation_rules() {
326 $rules = [
327 'user_meta' => [
328 'pattern' => '/\{\{(?:user_meta|author_meta)(?:\s+|\|)+[^}]*key:([^|}]+)[^}]*}}/i',
329 'validator' => [ self::class, 'validate_user_meta_field_name' ],
330 'link_tokens' => [ 'author_meta', 'author_email' ],
331 'bypass_cap' => 'list_users',
332 ],
333 'post_meta' => [
334 'pattern' => '/\{\{post_meta(?:\s+|\|)+[^}]*key:([^|}]+)[^}]*}}/i',
335 'validator' => [ self::class, 'validate_post_meta_field_name' ],
336 'link_tokens' => [ 'post_meta' ],
337 ],
338 'term_meta' => [
339 'pattern' => '/\{\{term_meta(?:\s+|\|)+[^}]*key:([^|}]+)[^}]*}}/i',
340 'validator' => [ self::class, 'validate_term_meta_field_name' ],
341 'link_tokens' => [ 'term_meta' ],
342 ],
343 ];
344
345 return apply_filters( 'generateblocks_dynamic_tag_validation_rules', $rules );
346 }
347
348 /**
349 * Determine if current user should validate user meta fields.
350 *
351 * @since 2.2.0
352 *
353 * @return bool
354 */
355 public static function should_validate_user_meta_fields() {
356 $rules = self::get_dynamic_tag_validation_rules();
357 $rule = $rules['user_meta'] ?? null;
358
359 if ( ! $rule ) {
360 return true;
361 }
362
363 return self::should_enforce_rule( $rule );
364 }
365
366 /**
367 * Determine whether the current user should be subject to a validation rule.
368 *
369 * @param array $rule Rule definition.
370 * @return bool
371 */
372 protected static function should_enforce_rule( $rule ) {
373 if ( empty( $rule['bypass_cap'] ) ) {
374 return true;
375 }
376
377 return ! current_user_can( $rule['bypass_cap'] );
378 }
379
380 /**
381 * Validate a single user meta field name against safe/disallowed lists.
382 *
383 * @since 2.2.0
384 *
385 * @param string $field_name The meta key to validate.
386 * @return true|WP_Error True if valid, WP_Error on violation.
387 */
388 public static function validate_user_meta_field_name( $field_name ) {
389 $field_name = is_string( $field_name ) ? trim( $field_name ) : '';
390
391 if ( '' === $field_name ) {
392 return true;
393 }
394
395 if ( is_protected_meta( $field_name, 'user' ) ) {
396 return new WP_Error(
397 'restricted_user_meta',
398 sprintf(
399 /* translators: %s: The restricted field name */
400 __( 'You do not have permission to use the field "%s" in dynamic tags. Fields starting with underscore are protected.', 'generateblocks' ),
401 esc_html( $field_name )
402 ),
403 [ 'status' => 403 ]
404 );
405 }
406
407 if ( in_array( $field_name, self::DISALLOWED_KEYS, true ) ) {
408 return new WP_Error(
409 'restricted_user_meta',
410 sprintf(
411 /* translators: %s: The restricted field name */
412 __( 'You do not have permission to use the field "%s" in dynamic tags. This field is restricted for security reasons.', 'generateblocks' ),
413 esc_html( $field_name )
414 ),
415 [ 'status' => 403 ]
416 );
417 }
418
419 if ( ! in_array( $field_name, self::get_safe_user_meta_keys(), true ) ) {
420 return new WP_Error(
421 'restricted_user_meta',
422 sprintf(
423 /* translators: %s: The restricted field name */
424 __( 'You do not have permission to use the field "%s" in dynamic tags. Only administrators can use custom user fields.', 'generateblocks' ),
425 esc_html( $field_name )
426 ),
427 [ 'status' => 403 ]
428 );
429 }
430
431 return true;
432 }
433
434 /**
435 * Validate a post meta key (blocks underscore-prefixed fields).
436 *
437 * @since 2.2.0
438 *
439 * @param string $field_name Meta key to validate.
440 * @return true|WP_Error True if valid key, WP_Error if restricted.
441 */
442 public static function validate_post_meta_field_name( $field_name ) {
443 $field_name = is_string( $field_name ) ? trim( $field_name ) : '';
444
445 if ( '' === $field_name ) {
446 return true;
447 }
448
449 if ( is_protected_meta( $field_name, 'post' ) ) {
450 return new WP_Error(
451 'restricted_post_meta',
452 sprintf(
453 /* translators: %s: The restricted field name */
454 __( 'You do not have permission to use the post meta field "%s" in dynamic tags. Fields starting with underscore are protected.', 'generateblocks' ),
455 esc_html( $field_name )
456 ),
457 [ 'status' => 403 ]
458 );
459 }
460
461 return true;
462 }
463
464 /**
465 * Validate a term meta key (blocks underscore-prefixed fields).
466 *
467 * @since 2.2.0
468 *
469 * @param string $field_name Meta key to validate.
470 * @return true|WP_Error True if valid key, WP_Error if restricted.
471 */
472 public static function validate_term_meta_field_name( $field_name ) {
473 $field_name = is_string( $field_name ) ? trim( $field_name ) : '';
474
475 if ( '' === $field_name ) {
476 return true;
477 }
478
479 if ( is_protected_meta( $field_name, 'term' ) ) {
480 return new WP_Error(
481 'restricted_term_meta',
482 sprintf(
483 /* translators: %s: The restricted field name */
484 __( 'You do not have permission to use the term meta field "%s" in dynamic tags. Fields starting with underscore are protected.', 'generateblocks' ),
485 esc_html( $field_name )
486 ),
487 [ 'status' => 403 ]
488 );
489 }
490
491 return true;
492 }
493
494 /**
495 * Collect link target violations for dynamic tags.
496 *
497 * @since 2.2.0
498 *
499 * @param string $content Serialized post content.
500 * @param string $rule_key The base rule key (user_meta/post_meta/term_meta).
501 * @param callable[] $validators Validators keyed by link token.
502 * @return array<string, array{error:WP_Error,count:int}>
503 */
504 protected static function collect_meta_link_target_violations( $content, $rule_key, $validators ) {
505 $content = self::normalize_serialized_content( $content );
506 $violations = [];
507
508 if ( '' === $content ) {
509 return $violations;
510 }
511
512 if ( empty( $validators ) || ! is_array( $validators ) ) {
513 return $violations;
514 }
515
516 if ( ! preg_match_all( '/\{\{([^}]+)\}\}/', $content, $tag_matches ) ) {
517 return $violations;
518 }
519
520 foreach ( $tag_matches[1] as $tag_body ) {
521 $parts = preg_split( '/(?:\s+|\|)+/', $tag_body, 2 );
522
523 if ( count( $parts ) < 2 ) {
524 continue;
525 }
526
527 $options_string = $parts[1];
528
529 if ( false === stripos( $options_string, 'link:' ) ) {
530 continue;
531 }
532
533 foreach ( explode( '|', $options_string ) as $option ) {
534 $option = trim( $option );
535
536 if ( '' === $option || stripos( $option, 'link:' ) !== 0 ) {
537 continue;
538 }
539
540 $link_value = trim( substr( $option, 5 ) );
541
542 if ( '' === $link_value ) {
543 continue;
544 }
545
546 $link_parts = array_map( 'trim', explode( ',', $link_value ) );
547 $target = strtolower( $link_parts[0] ?? '' );
548 $field_name = $link_parts[1] ?? '';
549
550 if ( 'author_email' === $target ) {
551 $field_name = 'user_email';
552 }
553
554 if ( ! isset( $validators[ $target ] ) || ! is_callable( $validators[ $target ] ) ) {
555 continue;
556 }
557
558 $result = call_user_func( $validators[ $target ], $field_name );
559
560 if ( is_wp_error( $result ) ) {
561 $signature = self::format_violation_signature( $rule_key, $field_name );
562
563 if ( $signature ) {
564 self::add_violation_entry( $violations, $signature, $result );
565 }
566 }
567 }
568 }
569
570 return $violations;
571 }
572
573 /**
574 * Get safe user meta keys accessible without list_users capability.
575 *
576 * @since 2.1.4
577 *
578 * @return array<string>
579 */
580 public static function get_safe_user_meta_keys() {
581 $safe_keys = [
582 'description',
583 'first_name',
584 'last_name',
585 'nickname',
586 'display_name',
587 'user_nicename',
588 'user_url',
589 'locale',
590 'show_admin_bar_front',
591 ];
592
593 $safe_keys = apply_filters( 'generateblocks_safe_user_meta_keys', $safe_keys );
594
595 return array_values( array_unique( array_filter( $safe_keys, 'is_string' ) ) );
596 }
597
598 /**
599 * Get the list of blocks that support GenerateBlocks dynamic tags.
600 *
601 * Mirrors the filter used when replacing tags so integrations stay in sync.
602 *
603 * @since 2.2.0
604 *
605 * @return array<int, string>
606 */
607 protected static function get_dynamic_tag_enabled_blocks() {
608 $blocks = [];
609
610 if ( class_exists( 'GenerateBlocks_Dynamic_Tags' ) ) {
611 $dynamic_tags = GenerateBlocks_Dynamic_Tags::get_instance();
612
613 if ( method_exists( $dynamic_tags, 'get_allowed_blocks' ) ) {
614 $blocks = $dynamic_tags->get_allowed_blocks();
615 }
616 }
617
618 if ( ! is_array( $blocks ) ) {
619 return [];
620 }
621
622 return array_values(
623 array_filter(
624 $blocks,
625 function( $block_name ) {
626 return is_string( $block_name ) && '' !== $block_name;
627 }
628 )
629 );
630 }
631
632 /**
633 * Fetch violation signatures from an existing post.
634 *
635 * @param int $post_id Post ID.
636 * @return array
637 */
638 protected static function get_existing_content_signatures( $post_id ) {
639 if ( ! $post_id ) {
640 return [];
641 }
642
643 $post = get_post( $post_id );
644
645 if ( ! $post || is_wp_error( $post ) ) {
646 return [];
647 }
648
649 return self::get_restricted_reference_signatures( $post->post_content );
650 }
651
652 /**
653 * Validate post content before database insert.
654 *
655 * This filter runs BEFORE the post is saved to the database, allowing us to
656 * prevent the save by triggering an error. Handles classic editor, quick edit,
657 * and other non-REST saves.
658 *
659 * @since 2.2.0
660 *
661 * @param array $data An array of slashed, sanitized, and processed post data.
662 * @param array $postarr An array of sanitized (and slashed) but otherwise unmodified post data.
663 * @return array The post data array (unmodified if validation passes).
664 */
665 public function validate_content_before_save( $data, $postarr ) {
666 // Admins already have unfiltered_html, so skip enforcement.
667 if ( current_user_can( 'manage_options' ) ) {
668 return $data;
669 }
670
671 // REST requests are validated via rest_pre_insert hooks.
672 if ( defined( 'REST_REQUEST' ) && REST_REQUEST ) {
673 return $data;
674 }
675
676 // Skip if no content.
677 if ( empty( $data['post_content'] ) ) {
678 return $data;
679 }
680
681 // Skip classic revisions but continue validating autosaves.
682 if ( ! empty( $postarr['ID'] ) ) {
683 $is_revision = wp_is_post_revision( $postarr['ID'] );
684 $is_autosave = wp_is_post_autosave( $postarr['ID'] );
685
686 if ( $is_revision && ! $is_autosave ) {
687 return $data;
688 }
689 }
690
691 if ( ! self::should_validate_content( $data['post_content'] ) ) {
692 return $data;
693 }
694
695 $existing_signatures = [];
696
697 if ( ! empty( $postarr['ID'] ) ) {
698 $existing_signatures = self::get_existing_content_signatures( (int) $postarr['ID'] );
699 }
700
701 // Validate the content.
702 $result = self::validate_content_with_existing_signatures( $data['post_content'], $existing_signatures );
703
704 if ( is_wp_error( $result ) ) {
705 // Block the save with a clear error message.
706 wp_die(
707 esc_html( $result->get_error_message() ),
708 esc_html( __( 'Content Validation Failed', 'generateblocks' ) ),
709 array(
710 'response' => 403,
711 'back_link' => true,
712 )
713 );
714 }
715
716 return $data;
717 }
718
719 /**
720 * Validate post content before REST API insert/update.
721 *
722 * This handles Gutenberg saves and autosaves via REST API.
723 *
724 * @since 2.2.0
725 *
726 * @param stdClass $prepared_post An object representing a single post prepared for inserting or updating the database.
727 * @param WP_REST_Request $request Request object.
728 * @return stdClass|WP_Error The prepared post object or WP_Error on validation failure.
729 */
730 public function validate_content_rest( $prepared_post, $request ) {
731 // Admins already have unfiltered_html, so skip enforcement.
732 if ( current_user_can( 'manage_options' ) ) {
733 return $prepared_post;
734 }
735
736 // Skip if no content.
737 $content = $prepared_post->post_content ?? '';
738 if ( empty( $content ) ) {
739 return $prepared_post;
740 }
741
742 if ( ! self::should_validate_content( $content ) ) {
743 return $prepared_post;
744 }
745
746 $post_id = 0;
747
748 if ( ! empty( $prepared_post->ID ) ) {
749 $post_id = (int) $prepared_post->ID;
750 } elseif ( $request instanceof WP_REST_Request && $request->get_param( 'id' ) ) {
751 $post_id = (int) $request->get_param( 'id' );
752 }
753
754 $existing_signatures = self::get_existing_content_signatures( $post_id );
755
756 // Validate the content.
757 $result = self::validate_content_with_existing_signatures( $content, $existing_signatures );
758
759 if ( is_wp_error( $result ) ) {
760 // Return the error - Gutenberg will display it in the editor.
761 return $result;
762 }
763
764 return $prepared_post;
765 }
766
767 /**
768 * Register REST API validation hooks for all public post types.
769 *
770 * This ensures validation runs for posts, pages, and custom post types that support the block editor.
771 *
772 * @since 2.2.0
773 * @return void
774 */
775 public function register_rest_validation_for_post_types() {
776 $post_types = get_post_types(
777 array(
778 'show_in_rest' => true,
779 ),
780 'names'
781 );
782
783 foreach ( $post_types as $post_type ) {
784 add_filter( "rest_pre_insert_{$post_type}", [ $this, 'validate_content_rest' ], 10, 2 );
785 }
786 }
787
788 /**
789 * Intercept Gutenberg autosave REST requests and validate their content.
790 *
791 * Autosaves use the /wp/v2/{post-type}/{id}/autosaves endpoint, which bypasses
792 * the regular rest_pre_insert_{post_type} filters. Hooking rest_pre_dispatch allows
793 * us to run the same validation logic and return a proper WP_Error response so
794 * the editor surfaces the issue immediately.
795 *
796 * @since 2.2.0
797 *
798 * @param mixed $response Response to replace the requested version with. Default false.
799 * @param WP_REST_Server $server Server instance.
800 * @param WP_REST_Request $request Request used to generate the response.
801 * @return mixed Either the original response or a WP_Error to halt dispatch.
802 */
803 public function validate_autosave_rest_request( $response, $server, $request ) {
804 if ( $response ) {
805 return $response;
806 }
807
808 if ( 'POST' !== $request->get_method() ) {
809 return $response;
810 }
811
812 if ( current_user_can( 'manage_options' ) ) {
813 return $response;
814 }
815
816 $route = $request->get_route();
817
818 // Only run on wp/v2 autosave endpoints.
819 if ( ! is_string( $route ) || ! preg_match( '#^/wp/v2/([a-z0-9_-]+)/(\d+)/autosaves/?$#', $route, $route_matches ) ) {
820 return $response;
821 }
822
823 $content_param = $request->get_param( 'content' );
824 $content = '';
825
826 if ( is_array( $content_param ) ) {
827 $content = isset( $content_param['raw'] ) ? (string) $content_param['raw'] : '';
828 } elseif ( is_string( $content_param ) ) {
829 $content = $content_param;
830 }
831
832 if ( '' === $content ) {
833 return $response;
834 }
835
836 if ( ! self::should_validate_content( $content ) ) {
837 return $response;
838 }
839
840 $post_id = (int) ( $route_matches[2] ?? 0 );
841 $existing_signatures = self::get_existing_content_signatures( $post_id );
842
843 $result = self::validate_content_with_existing_signatures( $content, $existing_signatures );
844
845 if ( is_wp_error( $result ) ) {
846 if ( ! $result->get_error_data() ) {
847 $result->add_data( array( 'status' => rest_authorization_required_code() ) );
848 }
849
850 return $result;
851 }
852
853 return $response;
854 }
855
856 /**
857 * Normalize serialized block content so validation sees unslashed values.
858 *
859 * @since 2.2.0
860 *
861 * @param mixed $content Possibly slashed content string.
862 * @return string Normalized content string.
863 */
864 public static function normalize_serialized_content( $content ) {
865 if ( ! is_string( $content ) ) {
866 return '';
867 }
868
869 $normalized = function_exists( 'wp_unslash' ) ? wp_unslash( $content ) : stripslashes( $content );
870
871 return trim( (string) $normalized );
872 }
873 }
874
875 GenerateBlocks_Dynamic_Tag_Security::get_instance()->init();
876