PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.7.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.7.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 1.0.2 1.1.0 1.10.0 All 48 releases
← All changes | includes/seo/class-schema-input-validator.php +179 -71 1.0.22.7.0 View file →
@@ -108,8 +108,26 @@
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]
112 130 ]
113 131 ];
114 132
115 133 /**
@@ -131,16 +149,8 @@
131 149 */
132 150 private array $allowed_protocols = ['http', 'https', 'mailto', 'tel'];
133 151
134 152 /**
135 - * Rate limiting storage
136 - *
137 - * @since 1.0.0
138 - * @var array
139 - */
140 - private static array $rate_limits = [];
141 -
142 - /**
143 153 * Maximum allowed JSON depth to prevent JSON bomb attacks
144 154 *
145 155 * @since 1.0.0
146 156 * @var int
@@ -361,15 +371,29 @@
361 371 $result['errors'][] = 'Invalid @context value. Must be "https://schema.org"';
362 372 $result['valid'] = false;
363 373 }
364 374
365 - // Check for required @type
375 + // Check for required @type.
376 + // `@type` may be an array — "@type": ["Product","Offer"] is valid
377 + // JSON-LD. Comparing an array against a string emitted an "Array to
378 + // string conversion" warning and always failed (#468), so match if the
379 + // expected type appears anywhere in the list.
366 380 if (!isset($schema_data['@type'])) {
367 381 $result['errors'][] = 'Missing required @type field';
368 382 $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;
383 + } else {
384 + $declared_types = is_array($schema_data['@type'])
385 + ? array_map('strval', $schema_data['@type'])
386 + : [(string) $schema_data['@type']];
387 +
388 + if (!in_array($schema_type, $declared_types, true)) {
389 + $result['errors'][] = sprintf(
390 + "Schema @type '%s' does not match expected type '%s'",
391 + implode(', ', $declared_types),
392 + $schema_type
393 + );
394 + $result['valid'] = false;
395 + }
372 396 }
373 397
374 398 return $result;
375 399 }
@@ -444,19 +468,19 @@
444 468 * @return string Sanitized value
445 469 */
446 470 private function sanitize_string_value(string $value, string $field_name = ''): string {
447 471 // Handle URLs differently to preserve valid URL structure
448 - if (in_array($field_name, ['url', 'sameAs', 'logo', 'image', 'mainEntityOfPage'])) {
472 + if (in_array($field_name, ['url', 'sameAs', 'logo', 'image', 'mainEntityOfPage'], true)) {
449 473 return esc_url_raw($value);
450 474 }
451 475
452 476 // Handle email fields
453 - if (in_array($field_name, ['email'])) {
477 + if (in_array($field_name, ['email'], true)) {
454 478 return sanitize_email($value);
455 479 }
456 480
457 481 // Handle description fields that may contain basic HTML
458 - if (in_array($field_name, ['description', 'text', 'articleBody'])) {
482 + if (in_array($field_name, ['description', 'text', 'articleBody'], true)) {
459 483 // Allow basic HTML but strip dangerous tags
460 484 $allowed_html = [
461 485 'p' => [],
462 486 'br' => [],
@@ -470,14 +494,16 @@
470 494 // For other fields, remove all HTML tags
471 495 $value = wp_strip_all_tags($value);
472 496 }
473 497
474 - // Sanitize for database storage
498 + // Sanitize for database storage. Note: escaping is intentionally NOT done
499 + // here. This value is stored and later emitted as JSON-LD inside a
500 + // <script type="application/ld+json"> block, where wp_json_encode() is the
501 + // correct encoder. Running esc_html() on input would persist HTML entities
502 + // (e.g. "Ben & Jerry's" -> "Ben &amp; Jerry&#039;s") into the structured
503 + // data. Escape at the output boundary, not at storage.
475 504 $value = sanitize_text_field($value);
476 505
477 - // Additional XSS protection for output
478 - $value = esc_html($value);
479 -
480 506 return trim($value);
481 507 }
482 508
483 509 /**
@@ -530,11 +556,29 @@
530 556 private function validate_data_formats(array $schema_data, string $schema_type): array {
531 557 $result = ['valid' => true, 'errors' => [], 'warnings' => []];
532 558
533 559 foreach ($schema_data as $field => $value) {
560 + // sameAs is a list, so its members never reached the string branch
561 + // below and free text entered in a social-profile field saved
562 + // cleanly, then shipped as invalid structured data (#480).
563 + if (is_array($value) && in_array($field, ['url', 'sameAs', 'logo', 'image'], true)) {
564 + foreach ($value as $item) {
565 + if (!is_string($item) || '' === trim($item)) {
566 + continue;
567 + }
568 +
569 + if (!$this->is_valid_url($item)) {
570 + $result['errors'][] = "Invalid URL format for field: {$field} ({$item})";
571 + $result['valid'] = false;
572 + }
573 + }
574 +
575 + continue;
576 + }
577 +
534 578 if (is_string($value)) {
535 579 // Validate URLs
536 - if (in_array($field, ['url', 'sameAs', 'logo', 'image']) && !empty($value)) {
580 + if (in_array($field, ['url', 'sameAs', 'logo', 'image'], true) && !empty($value)) {
537 581 if (!$this->is_valid_url($value)) {
538 582 $result['errors'][] = "Invalid URL format for field: {$field}";
539 583 $result['valid'] = false;
540 584 }
@@ -540,9 +584,9 @@
540 584 }
541 585 }
542 586
543 587 // Validate email addresses
544 - if (in_array($field, ['email']) && !empty($value)) {
588 + if (in_array($field, ['email'], true) && !empty($value)) {
545 589 if (!is_email($value)) {
546 590 $result['errors'][] = "Invalid email format for field: {$field}";
547 591 $result['valid'] = false;
548 592 }
@@ -548,9 +592,9 @@
548 592 }
549 593 }
550 594
551 595 // Validate dates
552 - if (in_array($field, ['datePublished', 'dateModified']) && !empty($value)) {
596 + if (in_array($field, ['datePublished', 'dateModified'], true) && !empty($value)) {
553 597 if (!$this->is_valid_date($value)) {
554 598 $result['warnings'][] = "Invalid date format for field: {$field}. Use ISO 8601 format.";
555 599 }
556 600 }
@@ -602,9 +646,9 @@
602 646 }
603 647
604 648 // Check allowed protocols
605 649 $parsed = wp_parse_url($url);
606 - if (!isset($parsed['scheme']) || !in_array($parsed['scheme'], $this->allowed_protocols)) {
650 + if (!isset($parsed['scheme']) || !in_array($parsed['scheme'], $this->allowed_protocols, true)) {
607 651 return false;
608 652 }
609 653
610 654 return true;
@@ -647,34 +691,58 @@
647 691 * @param int $limit Rate limit (requests per hour)
648 692 * @return bool Whether request is allowed
649 693 */
650 694 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
695 + // Persist the window in a transient (object cache / options) so the limit
696 + // is enforced ACROSS requests. A per-request static array — as used
697 + // previously — always starts empty on a fresh PHP process and therefore
698 + // never throttled anything.
699 + $key = 'thinkrank_schema_rl_' . $user_id . '_' . sanitize_key($action);
654 700
655 - // Initialize if not exists
656 - if (!isset(self::$rate_limits[$key])) {
657 - self::$rate_limits[$key] = [];
658 - }
701 + // Serialize the read-modify-write with a MySQL named lock so concurrent
702 + // requests can't each read the same timestamp list, individually pass the
703 + // limit check, and overwrite one another — which would let bursts slip
704 + // past the configured limit. GET_LOCK is DB-level, so it serializes the
705 + // critical section regardless of where the transient is stored.
706 + global $wpdb;
707 + $lock_name = substr('tr_schema_rl_' . md5($key), 0, 64);
708 + $have_lock = ($wpdb instanceof \wpdb)
709 + ? (int) $wpdb->get_var($wpdb->prepare('SELECT GET_LOCK(%s, %d)', $lock_name, 3)) === 1
710 + : false;
659 711
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;
712 + try {
713 + $current_time = time();
714 + $window_start = $current_time - HOUR_IN_SECONDS; // 1 hour window
715 +
716 + $timestamps = get_transient($key);
717 + if (!is_array($timestamps)) {
718 + $timestamps = [];
665 719 }
666 - );
667 720
668 - // Check if limit exceeded
669 - if (count(self::$rate_limits[$key]) >= $limit) {
670 - return false;
671 - }
721 + // Drop entries outside the window.
722 + $timestamps = array_values(array_filter(
723 + $timestamps,
724 + static function ($timestamp) use ($window_start) {
725 + return (int) $timestamp > $window_start;
726 + }
727 + ));
672 728
673 - // Add current request
674 - self::$rate_limits[$key][] = $current_time;
729 + // Check if limit exceeded.
730 + if (count($timestamps) >= $limit) {
731 + set_transient($key, $timestamps, HOUR_IN_SECONDS);
732 + return false;
733 + }
675 734
676 - return true;
735 + // Record this request.
736 + $timestamps[] = $current_time;
737 + set_transient($key, $timestamps, HOUR_IN_SECONDS);
738 +
739 + return true;
740 + } finally {
741 + if ($have_lock) {
742 + $wpdb->query($wpdb->prepare('SELECT RELEASE_LOCK(%s)', $lock_name));
743 + }
744 + }
677 745 }
678 746
679 747 /**
680 748 * Validate user permissions for schema operations
@@ -693,14 +761,25 @@
693 761 $result['errors'][] = 'Invalid user or user not logged in';
694 762 return $result;
695 763 }
696 764
697 - // Check operation-specific permissions
765 + // Check operation-specific permissions.
766 + //
767 + // #457 loosened the route permission_callbacks to the delegable
768 + // `thinkrank_schema` capability, but these handler-level checks still
769 + // demanded edit_posts / publish_posts / manage_options — so a role
770 + // granted Schema access could generate and validate but was denied on
771 + // deploy, bulk operations and everything site-context. That is exactly
772 + // the symptom #457 set out to fix (#470). A holder of thinkrank_schema
773 + // satisfies any schema operation; the built-in caps remain as the
774 + // fallback for roles that never went through the Role Manager.
775 + $has_schema_cap = user_can($user_id, 'thinkrank_schema');
776 +
698 777 switch ($operation) {
699 778 case 'generate':
700 779 case 'validate':
701 780 case 'optimize':
702 - if (!user_can($user_id, 'edit_posts')) {
781 + if (!$has_schema_cap && !user_can($user_id, 'edit_posts')) {
703 782 $result['errors'][] = 'Insufficient permissions for schema generation/validation';
704 783 return $result;
705 784 }
706 785 break;
@@ -705,9 +784,9 @@
705 784 }
706 785 break;
707 786
708 787 case 'deploy':
709 - if (!user_can($user_id, 'publish_posts')) {
788 + if (!$has_schema_cap && !user_can($user_id, 'publish_posts')) {
710 789 $result['errors'][] = 'Insufficient permissions for schema deployment';
711 790 return $result;
712 791 }
713 792 break;
@@ -713,9 +792,9 @@
713 792 break;
714 793
715 794 case 'manage_settings':
716 795 case 'bulk_operations':
717 - if (!user_can($user_id, 'manage_options')) {
796 + if (!$has_schema_cap && !user_can($user_id, 'manage_options')) {
718 797 $result['errors'][] = 'Insufficient permissions for schema management';
719 798 return $result;
720 799 }
721 800 break;
@@ -780,10 +859,14 @@
780 859 $result['errors'][] = "Invalid context ID: {$context_id}";
781 860 return $result;
782 861 }
783 862
784 - // SECURITY: Check context ownership
785 - if ($user_id && !$this->validate_context_ownership($post, $user_id)) {
863 + // SECURITY: Check context ownership.
864 + // Fails closed on a missing user — a security helper that waves the
865 + // check through when it cannot identify the caller is the wrong way
866 + // round. Every caller passes a real ID, so this only tightens an
867 + // unreachable path.
868 + if (!$user_id || !$this->validate_context_ownership($post, $user_id)) {
786 869 $result['errors'][] = "Access denied: You don't have permission to modify this {$context_type}";
787 870 return $result;
788 871 }
789 872 } else {
@@ -788,10 +871,18 @@
788 871 }
789 872 } else {
790 873 $context_id = null; // Site context doesn't use ID
791 874
792 - // SECURITY: Check site-level permissions for site context
793 - if ($user_id && !current_user_can('manage_options')) {
875 + // SECURITY: Check site-level permissions for site context.
876 + // user_can($user_id, …) rather than current_user_can() so this
877 + // agrees with the rest of the validator outside a REST request,
878 + // where the current user and $user_id can differ (cron, CLI).
879 + //
880 + // Accepts the delegable `thinkrank_schema` capability as well as
881 + // manage_options: /schema/settings already lets a delegated role
882 + // edit site schema settings, so blocking site-context generate and
883 + // deploy for the same role was inconsistent (#470).
884 + if (!$user_id || (!user_can($user_id, 'thinkrank_schema') && !user_can($user_id, 'manage_options'))) {
794 885 $result['errors'][] = 'Access denied: You need administrator privileges for site-level schema operations';
795 886 return $result;
796 887 }
797 888 }
@@ -814,25 +905,23 @@
814 905 * @param int $user_id User ID
815 906 * @return bool Whether user has permission
816 907 */
817 908 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;
909 + // `edit_post` is a meta capability: map_meta_cap() already resolves
910 + // authorship, published state, and edit_others_posts for this specific
911 + // post. It is the whole check.
912 + //
913 + // Two fallbacks used to sit under it and between them defeated the
914 + // function. One granted access on authorship alone, which hands a
915 + // Contributor back a post they lost edit rights to once it published.
916 + // The other granted access to anyone holding the post type's *general*
917 + // edit_posts capability — a cap every Author and Contributor has, that
918 + // says nothing about this post — so ownership validation returned true
919 + // for every post on the site (#326).
920 + //
921 + // user_can() rather than current_user_can() so the method honours the
922 + // $user_id it was handed, matching validate_user_permissions().
923 + return user_can($user_id, 'edit_post', $post->ID);
835 924 }
836 925
837 926 /**
838 927 * Validate JSON depth to prevent JSON bomb attacks
@@ -868,14 +957,33 @@
868 957 * @return array Sanitized options
869 958 */
870 959 public function sanitize_options(array $options): array {
871 960 $sanitized = [];
961 + // Anything omitted here is dropped before the manager sees it, which is
962 + // why apply_content_schema_settings_from_options() and the per-request
963 + // schema-type opt-in were unreachable from REST (#470). The list now
964 + // covers every option the generate path actually reads.
965 + //
966 + // `validation_level` previously allowed 'basic' and rejected 'lenient',
967 + // disagreeing with Schema_Settings_Config, validate_settings() and the
968 + // update-settings ability, which all use 'lenient'.
969 + // `deployment_method` no longer advertises microdata/rdfa, which
970 + // determine_deployment_method() hardcodes away to json_ld anyway.
872 971 $allowed_options = [
873 - 'deployment_method' => ['json_ld', 'microdata', 'rdfa'],
874 - 'validation_level' => ['strict', 'moderate', 'basic'],
972 + 'deployment_method' => ['json_ld'],
973 + 'validation_level' => ['strict', 'moderate', 'lenient'],
875 974 'include_meta' => 'boolean',
876 975 'minify_output' => 'boolean',
877 - 'cache_duration' => 'integer'
976 + 'cache_duration' => 'integer',
977 + 'rich_snippets_optimization' => 'boolean',
978 + 'knowledge_graph' => 'boolean',
979 + 'auto_generate_schema' => 'boolean',
980 + 'enable_article_schema' => 'boolean',
981 + 'enable_faq_schema' => 'boolean',
982 + 'enable_howto_schema' => 'boolean',
983 + 'enable_product_schema' => 'boolean',
984 + 'enable_local_business' => 'boolean',
985 + 'enable_breadcrumbs_schema' => 'boolean',
878 986 ];
879 987
880 988 foreach ($options as $key => $value) {
881 989 $sanitized_key = sanitize_key($key);