PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.10.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.10.0
2.10.0 2.9.0 2.8.0 2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 1.26.0 1.25.0 trunk 1.0.0 1.0.1 All 51 releases
← All changes | includes/seo/class-schema-input-validator.php +211 -71 1.0.1 → 2.10.0 View file →
@@ -108,8 +108,58 @@
108 108 'HowTo' => [
109 109 'required_fields' => ['@type', 'name'],
110 110 'optional_fields' => ['description', 'totalTime', 'prepTime', 'difficulty', 'estimatedCost', 'supply', 'tool', 'step', 'yield', 'image', 'video'],
111 111 'max_length' => ['name' => 100, 'description' => 160]
112 + ],
113 + 'BreadcrumbList' => [
114 + 'required_fields' => ['@type', 'itemListElement'],
115 + 'optional_fields' => ['name', 'description', 'numberOfItems'],
116 + 'max_length' => ['name' => 100, 'description' => 160]
117 + ],
118 + 'VideoObject' => [
119 + 'required_fields' => ['@type', 'name', 'thumbnailUrl', 'uploadDate'],
120 + 'optional_fields' => ['description', 'contentUrl', 'embedUrl', 'duration', 'url'],
121 + 'max_length' => ['name' => 110, 'description' => 160]
122 + ],
123 + // Offered by the metabox Schema Type dropdown and registered in
124 + // Schema_Factory, but missing here — so a Review could be generated and
125 + // never deployed (#462). Field list mirrors Schema_Factory::Review.
126 + 'Review' => [
127 + 'required_fields' => ['@type', 'itemReviewed', 'reviewRating', 'author'],
128 + 'optional_fields' => ['reviewBody', 'datePublished', 'publisher', 'name', 'url'],
129 + 'max_length' => ['name' => 110, 'reviewBody' => 500]
130 + ],
131 + // The WebPage family. #624 made all four selectable in the metabox and
132 + // taught the generate/validate/optimize/preview routes, Schema_Builder,
133 + // Schema_Factory and Schema_Graph about them — but not this array, and
134 + // deploy_schema_markup() gates every entry on it. So each one generated
135 + // cleanly, validated at a score of 100, reported deployment_ready, then
136 + // failed deployment with "Invalid schema type: AboutPage" and no row
137 + // written. Exactly the #462 Review bug, one array and two releases
138 + // later, which is why SchemaInputValidatorCoverageTest now scans the
139 + // dropdown instead of trusting this list to be extended by hand.
140 + //
141 + // `name` and `url` are the two populate_webpage_schema() always sets;
142 + // the rest are conditional, so they are optional here.
143 + 'WebPage' => [
144 + 'required_fields' => ['@type', 'name', 'url'],
145 + 'optional_fields' => ['description', 'datePublished', 'dateModified', 'isPartOf', 'breadcrumb', 'primaryImageOfPage', 'inLanguage', 'mainEntity'],
146 + 'max_length' => ['name' => 110, 'description' => 160]
147 + ],
148 + 'AboutPage' => [
149 + 'required_fields' => ['@type', 'name', 'url'],
150 + 'optional_fields' => ['description', 'datePublished', 'dateModified', 'isPartOf', 'breadcrumb', 'primaryImageOfPage', 'inLanguage', 'mainEntity'],
151 + 'max_length' => ['name' => 110, 'description' => 160]
152 + ],
153 + 'ContactPage' => [
154 + 'required_fields' => ['@type', 'name', 'url'],
155 + 'optional_fields' => ['description', 'datePublished', 'dateModified', 'isPartOf', 'breadcrumb', 'primaryImageOfPage', 'inLanguage', 'mainEntity'],
156 + 'max_length' => ['name' => 110, 'description' => 160]
157 + ],
158 + 'ProfilePage' => [
159 + 'required_fields' => ['@type', 'name', 'url'],
160 + 'optional_fields' => ['description', 'datePublished', 'dateModified', 'isPartOf', 'breadcrumb', 'primaryImageOfPage', 'inLanguage', 'mainEntity'],
161 + 'max_length' => ['name' => 110, 'description' => 160]
112 162 ]
113 163 ];
114 164
115 165 /**
@@ -131,16 +181,8 @@
131 181 */
132 182 private array $allowed_protocols = ['http', 'https', 'mailto', 'tel'];
133 183
134 184 /**
135 - * Rate limiting storage
136 - *
137 - * @since 1.0.0
138 - * @var array
139 - */
140 - private static array $rate_limits = [];
141 -
142 - /**
143 185 * Maximum allowed JSON depth to prevent JSON bomb attacks
144 186 *
145 187 * @since 1.0.0
146 188 * @var int
@@ -361,15 +403,29 @@
361 403 $result['errors'][] = 'Invalid @context value. Must be "https://schema.org"';
362 404 $result['valid'] = false;
363 405 }
364 406
365 - // Check for required @type
407 + // Check for required @type.
408 + // `@type` may be an array — "@type": ["Product","Offer"] is valid
409 + // JSON-LD. Comparing an array against a string emitted an "Array to
410 + // string conversion" warning and always failed (#468), so match if the
411 + // expected type appears anywhere in the list.
366 412 if (!isset($schema_data['@type'])) {
367 413 $result['errors'][] = 'Missing required @type field';
368 414 $result['valid'] = false;
369 - } elseif ($schema_data['@type'] !== $schema_type) {
370 - $result['errors'][] = "Schema @type '{$schema_data['@type']}' does not match expected type '{$schema_type}'";
371 - $result['valid'] = false;
415 + } else {
416 + $declared_types = is_array($schema_data['@type'])
417 + ? array_map('strval', $schema_data['@type'])
418 + : [(string) $schema_data['@type']];
419 +
420 + if (!in_array($schema_type, $declared_types, true)) {
421 + $result['errors'][] = sprintf(
422 + "Schema @type '%s' does not match expected type '%s'",
423 + implode(', ', $declared_types),
424 + $schema_type
425 + );
426 + $result['valid'] = false;
427 + }
372 428 }
373 429
374 430 return $result;
375 431 }
@@ -444,19 +500,19 @@
444 500 * @return string Sanitized value
445 501 */
446 502 private function sanitize_string_value(string $value, string $field_name = ''): string {
447 503 // Handle URLs differently to preserve valid URL structure
448 - if (in_array($field_name, ['url', 'sameAs', 'logo', 'image', 'mainEntityOfPage'])) {
504 + if (in_array($field_name, ['url', 'sameAs', 'logo', 'image', 'mainEntityOfPage'], true)) {
449 505 return esc_url_raw($value);
450 506 }
451 507
452 508 // Handle email fields
453 - if (in_array($field_name, ['email'])) {
509 + if (in_array($field_name, ['email'], true)) {
454 510 return sanitize_email($value);
455 511 }
456 512
457 513 // Handle description fields that may contain basic HTML
458 - if (in_array($field_name, ['description', 'text', 'articleBody'])) {
514 + if (in_array($field_name, ['description', 'text', 'articleBody'], true)) {
459 515 // Allow basic HTML but strip dangerous tags
460 516 $allowed_html = [
461 517 'p' => [],
462 518 'br' => [],
@@ -470,14 +526,16 @@
470 526 // For other fields, remove all HTML tags
471 527 $value = wp_strip_all_tags($value);
472 528 }
473 529
474 - // Sanitize for database storage
530 + // Sanitize for database storage. Note: escaping is intentionally NOT done
531 + // here. This value is stored and later emitted as JSON-LD inside a
532 + // <script type="application/ld+json"> block, where wp_json_encode() is the
533 + // correct encoder. Running esc_html() on input would persist HTML entities
534 + // (e.g. "Ben & Jerry's" -> "Ben &amp; Jerry&#039;s") into the structured
535 + // data. Escape at the output boundary, not at storage.
475 536 $value = sanitize_text_field($value);
476 537
477 - // Additional XSS protection for output
478 - $value = esc_html($value);
479 -
480 538 return trim($value);
481 539 }
482 540
483 541 /**
@@ -530,11 +588,29 @@
530 588 private function validate_data_formats(array $schema_data, string $schema_type): array {
531 589 $result = ['valid' => true, 'errors' => [], 'warnings' => []];
532 590
533 591 foreach ($schema_data as $field => $value) {
592 + // sameAs is a list, so its members never reached the string branch
593 + // below and free text entered in a social-profile field saved
594 + // cleanly, then shipped as invalid structured data (#480).
595 + if (is_array($value) && in_array($field, ['url', 'sameAs', 'logo', 'image'], true)) {
596 + foreach ($value as $item) {
597 + if (!is_string($item) || '' === trim($item)) {
598 + continue;
599 + }
600 +
601 + if (!$this->is_valid_url($item)) {
602 + $result['errors'][] = "Invalid URL format for field: {$field} ({$item})";
603 + $result['valid'] = false;
604 + }
605 + }
606 +
607 + continue;
608 + }
609 +
534 610 if (is_string($value)) {
535 611 // Validate URLs
536 - if (in_array($field, ['url', 'sameAs', 'logo', 'image']) && !empty($value)) {
612 + if (in_array($field, ['url', 'sameAs', 'logo', 'image'], true) && !empty($value)) {
537 613 if (!$this->is_valid_url($value)) {
538 614 $result['errors'][] = "Invalid URL format for field: {$field}";
539 615 $result['valid'] = false;
540 616 }
@@ -540,9 +616,9 @@
540 616 }
541 617 }
542 618
543 619 // Validate email addresses
544 - if (in_array($field, ['email']) && !empty($value)) {
620 + if (in_array($field, ['email'], true) && !empty($value)) {
545 621 if (!is_email($value)) {
546 622 $result['errors'][] = "Invalid email format for field: {$field}";
547 623 $result['valid'] = false;
548 624 }
@@ -548,9 +624,9 @@
548 624 }
549 625 }
550 626
551 627 // Validate dates
552 - if (in_array($field, ['datePublished', 'dateModified']) && !empty($value)) {
628 + if (in_array($field, ['datePublished', 'dateModified'], true) && !empty($value)) {
553 629 if (!$this->is_valid_date($value)) {
554 630 $result['warnings'][] = "Invalid date format for field: {$field}. Use ISO 8601 format.";
555 631 }
556 632 }
@@ -602,9 +678,9 @@
602 678 }
603 679
604 680 // Check allowed protocols
605 681 $parsed = wp_parse_url($url);
606 - if (!isset($parsed['scheme']) || !in_array($parsed['scheme'], $this->allowed_protocols)) {
682 + if (!isset($parsed['scheme']) || !in_array($parsed['scheme'], $this->allowed_protocols, true)) {
607 683 return false;
608 684 }
609 685
610 686 return true;
@@ -647,34 +723,58 @@
647 723 * @param int $limit Rate limit (requests per hour)
648 724 * @return bool Whether request is allowed
649 725 */
650 726 public function check_rate_limit(int $user_id, string $action, int $limit = 100): bool {
651 - $key = "rate_limit_{$user_id}_{$action}";
652 - $current_time = time();
653 - $window_start = $current_time - 3600; // 1 hour window
727 + // Persist the window in a transient (object cache / options) so the limit
728 + // is enforced ACROSS requests. A per-request static array — as used
729 + // previously — always starts empty on a fresh PHP process and therefore
730 + // never throttled anything.
731 + $key = 'thinkrank_schema_rl_' . $user_id . '_' . sanitize_key($action);
654 732
655 - // Initialize if not exists
656 - if (!isset(self::$rate_limits[$key])) {
657 - self::$rate_limits[$key] = [];
658 - }
733 + // Serialize the read-modify-write with a MySQL named lock so concurrent
734 + // requests can't each read the same timestamp list, individually pass the
735 + // limit check, and overwrite one another — which would let bursts slip
736 + // past the configured limit. GET_LOCK is DB-level, so it serializes the
737 + // critical section regardless of where the transient is stored.
738 + global $wpdb;
739 + $lock_name = substr('tr_schema_rl_' . md5($key), 0, 64);
740 + $have_lock = ($wpdb instanceof \wpdb)
741 + ? (int) $wpdb->get_var($wpdb->prepare('SELECT GET_LOCK(%s, %d)', $lock_name, 3)) === 1
742 + : false;
659 743
660 - // Clean old entries
661 - self::$rate_limits[$key] = array_filter(
662 - self::$rate_limits[$key],
663 - function($timestamp) use ($window_start) {
664 - return $timestamp > $window_start;
744 + try {
745 + $current_time = time();
746 + $window_start = $current_time - HOUR_IN_SECONDS; // 1 hour window
747 +
748 + $timestamps = get_transient($key);
749 + if (!is_array($timestamps)) {
750 + $timestamps = [];
665 751 }
666 - );
667 752
668 - // Check if limit exceeded
669 - if (count(self::$rate_limits[$key]) >= $limit) {
670 - return false;
671 - }
753 + // Drop entries outside the window.
754 + $timestamps = array_values(array_filter(
755 + $timestamps,
756 + static function ($timestamp) use ($window_start) {
757 + return (int) $timestamp > $window_start;
758 + }
759 + ));
672 760
673 - // Add current request
674 - self::$rate_limits[$key][] = $current_time;
761 + // Check if limit exceeded.
762 + if (count($timestamps) >= $limit) {
763 + set_transient($key, $timestamps, HOUR_IN_SECONDS);
764 + return false;
765 + }
675 766
676 - return true;
767 + // Record this request.
768 + $timestamps[] = $current_time;
769 + set_transient($key, $timestamps, HOUR_IN_SECONDS);
770 +
771 + return true;
772 + } finally {
773 + if ($have_lock) {
774 + $wpdb->query($wpdb->prepare('SELECT RELEASE_LOCK(%s)', $lock_name));
775 + }
776 + }
677 777 }
678 778
679 779 /**
680 780 * Validate user permissions for schema operations
@@ -693,14 +793,25 @@
693 793 $result['errors'][] = 'Invalid user or user not logged in';
694 794 return $result;
695 795 }
696 796
697 - // Check operation-specific permissions
797 + // Check operation-specific permissions.
798 + //
799 + // #457 loosened the route permission_callbacks to the delegable
800 + // `thinkrank_schema` capability, but these handler-level checks still
801 + // demanded edit_posts / publish_posts / manage_options — so a role
802 + // granted Schema access could generate and validate but was denied on
803 + // deploy, bulk operations and everything site-context. That is exactly
804 + // the symptom #457 set out to fix (#470). A holder of thinkrank_schema
805 + // satisfies any schema operation; the built-in caps remain as the
806 + // fallback for roles that never went through the Role Manager.
807 + $has_schema_cap = user_can($user_id, 'thinkrank_schema');
808 +
698 809 switch ($operation) {
699 810 case 'generate':
700 811 case 'validate':
701 812 case 'optimize':
702 - if (!user_can($user_id, 'edit_posts')) {
813 + if (!$has_schema_cap && !user_can($user_id, 'edit_posts')) {
703 814 $result['errors'][] = 'Insufficient permissions for schema generation/validation';
704 815 return $result;
705 816 }
706 817 break;
@@ -705,9 +816,9 @@
705 816 }
706 817 break;
707 818
708 819 case 'deploy':
709 - if (!user_can($user_id, 'publish_posts')) {
820 + if (!$has_schema_cap && !user_can($user_id, 'publish_posts')) {
710 821 $result['errors'][] = 'Insufficient permissions for schema deployment';
711 822 return $result;
712 823 }
713 824 break;
@@ -713,9 +824,9 @@
713 824 break;
714 825
715 826 case 'manage_settings':
716 827 case 'bulk_operations':
717 - if (!user_can($user_id, 'manage_options')) {
828 + if (!$has_schema_cap && !user_can($user_id, 'manage_options')) {
718 829 $result['errors'][] = 'Insufficient permissions for schema management';
719 830 return $result;
720 831 }
721 832 break;
@@ -780,10 +891,14 @@
780 891 $result['errors'][] = "Invalid context ID: {$context_id}";
781 892 return $result;
782 893 }
783 894
784 - // SECURITY: Check context ownership
785 - if ($user_id && !$this->validate_context_ownership($post, $user_id)) {
895 + // SECURITY: Check context ownership.
896 + // Fails closed on a missing user — a security helper that waves the
897 + // check through when it cannot identify the caller is the wrong way
898 + // round. Every caller passes a real ID, so this only tightens an
899 + // unreachable path.
900 + if (!$user_id || !$this->validate_context_ownership($post, $user_id)) {
786 901 $result['errors'][] = "Access denied: You don't have permission to modify this {$context_type}";
787 902 return $result;
788 903 }
789 904 } else {
@@ -788,10 +903,18 @@
788 903 }
789 904 } else {
790 905 $context_id = null; // Site context doesn't use ID
791 906
792 - // SECURITY: Check site-level permissions for site context
793 - if ($user_id && !current_user_can('manage_options')) {
907 + // SECURITY: Check site-level permissions for site context.
908 + // user_can($user_id, …) rather than current_user_can() so this
909 + // agrees with the rest of the validator outside a REST request,
910 + // where the current user and $user_id can differ (cron, CLI).
911 + //
912 + // Accepts the delegable `thinkrank_schema` capability as well as
913 + // manage_options: /schema/settings already lets a delegated role
914 + // edit site schema settings, so blocking site-context generate and
915 + // deploy for the same role was inconsistent (#470).
916 + if (!$user_id || (!user_can($user_id, 'thinkrank_schema') && !user_can($user_id, 'manage_options'))) {
794 917 $result['errors'][] = 'Access denied: You need administrator privileges for site-level schema operations';
795 918 return $result;
796 919 }
797 920 }
@@ -814,25 +937,23 @@
814 937 * @param int $user_id User ID
815 938 * @return bool Whether user has permission
816 939 */
817 940 private function validate_context_ownership(\WP_Post $post, int $user_id): bool {
818 - // Check if user can edit this specific post
819 - if (current_user_can('edit_post', $post->ID)) {
820 - return true;
821 - }
822 -
823 - // Check if user is the post author
824 - if ($post->post_author == $user_id) {
825 - return true;
826 - }
827 -
828 - // Check if user has general edit capabilities for this post type
829 - $post_type_object = get_post_type_object($post->post_type);
830 - if ($post_type_object && current_user_can($post_type_object->cap->edit_posts)) {
831 - return true;
832 - }
833 -
834 - return false;
941 + // `edit_post` is a meta capability: map_meta_cap() already resolves
942 + // authorship, published state, and edit_others_posts for this specific
943 + // post. It is the whole check.
944 + //
945 + // Two fallbacks used to sit under it and between them defeated the
946 + // function. One granted access on authorship alone, which hands a
947 + // Contributor back a post they lost edit rights to once it published.
948 + // The other granted access to anyone holding the post type's *general*
949 + // edit_posts capability — a cap every Author and Contributor has, that
950 + // says nothing about this post — so ownership validation returned true
951 + // for every post on the site (#326).
952 + //
953 + // user_can() rather than current_user_can() so the method honours the
954 + // $user_id it was handed, matching validate_user_permissions().
955 + return user_can($user_id, 'edit_post', $post->ID);
835 956 }
836 957
837 958 /**
838 959 * Validate JSON depth to prevent JSON bomb attacks
@@ -868,14 +989,33 @@
868 989 * @return array Sanitized options
869 990 */
870 991 public function sanitize_options(array $options): array {
871 992 $sanitized = [];
993 + // Anything omitted here is dropped before the manager sees it, which is
994 + // why apply_content_schema_settings_from_options() and the per-request
995 + // schema-type opt-in were unreachable from REST (#470). The list now
996 + // covers every option the generate path actually reads.
997 + //
998 + // `validation_level` previously allowed 'basic' and rejected 'lenient',
999 + // disagreeing with Schema_Settings_Config, validate_settings() and the
1000 + // update-settings ability, which all use 'lenient'.
1001 + // `deployment_method` no longer advertises microdata/rdfa, which
1002 + // determine_deployment_method() hardcodes away to json_ld anyway.
872 1003 $allowed_options = [
873 - 'deployment_method' => ['json_ld', 'microdata', 'rdfa'],
874 - 'validation_level' => ['strict', 'moderate', 'basic'],
1004 + 'deployment_method' => ['json_ld'],
1005 + 'validation_level' => ['strict', 'moderate', 'lenient'],
875 1006 'include_meta' => 'boolean',
876 1007 'minify_output' => 'boolean',
877 - 'cache_duration' => 'integer'
1008 + 'cache_duration' => 'integer',
1009 + 'rich_snippets_optimization' => 'boolean',
1010 + 'knowledge_graph' => 'boolean',
1011 + 'auto_generate_schema' => 'boolean',
1012 + 'enable_article_schema' => 'boolean',
1013 + 'enable_faq_schema' => 'boolean',
1014 + 'enable_howto_schema' => 'boolean',
1015 + 'enable_product_schema' => 'boolean',
1016 + 'enable_local_business' => 'boolean',
1017 + 'enable_breadcrumbs_schema' => 'boolean',
878 1018 ];
879 1019
880 1020 foreach ($options as $key => $value) {
881 1021 $sanitized_key = sanitize_key($key);