PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.14.1
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.14.1
2.14.2 2.14.1 2.14.0 2.13.0 2.12.0 2.11.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 All 57 releases
← All changes | includes/seo/class-schema-input-validator.php +206 -41 1.27.0 → 2.14.1 View file →
@@ -118,8 +118,48 @@
118 118 'VideoObject' => [
119 119 'required_fields' => ['@type', 'name', 'thumbnailUrl', 'uploadDate'],
120 120 'optional_fields' => ['description', 'contentUrl', 'embedUrl', 'duration', 'url'],
121 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]
122 162 ]
123 163 ];
124 164
125 165 /**
@@ -226,9 +266,9 @@
226 266 $result['errors'] = array_merge($result['errors'], $field_validation['errors']);
227 267 }
228 268
229 269 // 6. Validate data types and formats
230 - $format_validation = $this->validate_data_formats($sanitized_data, $schema_type);
270 + $format_validation = $this->validate_data_formats($sanitized_data, $schema_type, $schema_data);
231 271 if (!$format_validation['valid']) {
232 272 $result['errors'] = array_merge($result['errors'], $format_validation['errors']);
233 273 }
234 274 $result['warnings'] = array_merge($result['warnings'], $format_validation['warnings']);
@@ -363,15 +403,29 @@
363 403 $result['errors'][] = 'Invalid @context value. Must be "https://schema.org"';
364 404 $result['valid'] = false;
365 405 }
366 406
367 - // 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.
368 412 if (!isset($schema_data['@type'])) {
369 413 $result['errors'][] = 'Missing required @type field';
370 414 $result['valid'] = false;
371 - } elseif ($schema_data['@type'] !== $schema_type) {
372 - $result['errors'][] = "Schema @type '{$schema_data['@type']}' does not match expected type '{$schema_type}'";
373 - $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 + }
374 428 }
375 429
376 430 return $result;
377 431 }
@@ -446,19 +500,19 @@
446 500 * @return string Sanitized value
447 501 */
448 502 private function sanitize_string_value(string $value, string $field_name = ''): string {
449 503 // Handle URLs differently to preserve valid URL structure
450 - if (in_array($field_name, ['url', 'sameAs', 'logo', 'image', 'mainEntityOfPage'])) {
504 + if (in_array($field_name, ['url', 'sameAs', 'logo', 'image', 'mainEntityOfPage'], true)) {
451 505 return esc_url_raw($value);
452 506 }
453 507
454 508 // Handle email fields
455 - if (in_array($field_name, ['email'])) {
509 + if (in_array($field_name, ['email'], true)) {
456 510 return sanitize_email($value);
457 511 }
458 512
459 513 // Handle description fields that may contain basic HTML
460 - if (in_array($field_name, ['description', 'text', 'articleBody'])) {
514 + if (in_array($field_name, ['description', 'text', 'articleBody'], true)) {
461 515 // Allow basic HTML but strip dangerous tags
462 516 $allowed_html = [
463 517 'p' => [],
464 518 'br' => [],
@@ -528,17 +582,48 @@
528 582 * @since 1.0.0
529 583 *
530 584 * @param array $schema_data Schema data
531 585 * @param string $schema_type Schema type
586 + * @param array $raw_data Schema data as submitted, before sanitization.
532 587 * @return array Validation result
533 588 */
534 - private function validate_data_formats(array $schema_data, string $schema_type): array {
589 + private function validate_data_formats(array $schema_data, string $schema_type, array $raw_data = []): array {
535 590 $result = ['valid' => true, 'errors' => [], 'warnings' => []];
536 591
537 592 foreach ($schema_data as $field => $value) {
593 + // sameAs is a list, so its members never reached the string branch
594 + // below and free text entered in a social-profile field saved
595 + // cleanly, then shipped as invalid structured data (#480).
596 + if (is_array($value) && in_array($field, ['url', 'sameAs', 'logo', 'image'], true)) {
597 + // Validated after sanitization, but reported as entered:
598 + // esc_url_raw() turns "not a url" into "http://not%20a%20url",
599 + // which the user never typed (#949 review).
600 + $items = $this->url_candidates($value);
601 + $raw_items = is_array($raw_data[$field] ?? null) ? $this->url_candidates($raw_data[$field]) : [];
602 + if (count($raw_items) !== count($items)) {
603 + // A non-string member sanitized into a string would shift
604 + // the positions; fall back rather than name the wrong one.
605 + $raw_items = [];
606 + }
607 +
608 + foreach ($items as $index => $item) {
609 + if ('' === trim($item)) {
610 + continue;
611 + }
612 +
613 + if (!$this->is_valid_url($item)) {
614 + $shown = $raw_items[$index] ?? $item;
615 + $result['errors'][] = "Invalid URL format for field: {$field} ({$shown})";
616 + $result['valid'] = false;
617 + }
618 + }
619 +
620 + continue;
621 + }
622 +
538 623 if (is_string($value)) {
539 624 // Validate URLs
540 - if (in_array($field, ['url', 'sameAs', 'logo', 'image']) && !empty($value)) {
625 + if (in_array($field, ['url', 'sameAs', 'logo', 'image'], true) && !empty($value)) {
541 626 if (!$this->is_valid_url($value)) {
542 627 $result['errors'][] = "Invalid URL format for field: {$field}";
543 628 $result['valid'] = false;
544 629 }
@@ -544,9 +629,9 @@
544 629 }
545 630 }
546 631
547 632 // Validate email addresses
548 - if (in_array($field, ['email']) && !empty($value)) {
633 + if (in_array($field, ['email'], true) && !empty($value)) {
549 634 if (!is_email($value)) {
550 635 $result['errors'][] = "Invalid email format for field: {$field}";
551 636 $result['valid'] = false;
552 637 }
@@ -552,9 +637,9 @@
552 637 }
553 638 }
554 639
555 640 // Validate dates
556 - if (in_array($field, ['datePublished', 'dateModified']) && !empty($value)) {
641 + if (in_array($field, ['datePublished', 'dateModified'], true) && !empty($value)) {
557 642 if (!$this->is_valid_date($value)) {
558 643 $result['warnings'][] = "Invalid date format for field: {$field}. Use ISO 8601 format.";
559 644 }
560 645 }
@@ -564,8 +649,47 @@
564 649 return $result;
565 650 }
566 651
567 652 /**
653 + * The URL strings inside an array-valued URL field.
654 + *
655 + * The field is either a list (`sameAs`, several `image` URLs) or a single
656 + * node such as the `ImageObject` Schema_Builder emits for a configured
657 + * logo. Checking every member of a node URL-checked its `@type` and
658 + * `width`, so "ImageObject" failed as an invalid URL and the deploy route
659 + * skipped — then retired — the site's Organization (#949). Only a node's
660 + * `url` / `contentUrl` are URLs; a list may hold strings or such nodes.
661 + *
662 + * @since 2.14.1
663 + *
664 + * @param array $value Array-valued URL field.
665 + * @return string[] URL strings to validate.
666 + */
667 + private function url_candidates(array $value): array {
668 + $node_urls = static function (array $node): array {
669 + return array_values(array_filter(
670 + [$node['url'] ?? null, $node['contentUrl'] ?? null],
671 + 'is_string'
672 + ));
673 + };
674 +
675 + if (array_values($value) !== $value) {
676 + return $node_urls($value);
677 + }
678 +
679 + $urls = [];
680 + foreach ($value as $item) {
681 + if (is_string($item)) {
682 + $urls[] = $item;
683 + } elseif (is_array($item)) {
684 + $urls = array_merge($urls, $node_urls($item));
685 + }
686 + }
687 +
688 + return $urls;
689 + }
690 +
691 + /**
568 692 * Validate content lengths
569 693 *
570 694 * @since 1.0.0
571 695 *
@@ -606,9 +730,9 @@
606 730 }
607 731
608 732 // Check allowed protocols
609 733 $parsed = wp_parse_url($url);
610 - if (!isset($parsed['scheme']) || !in_array($parsed['scheme'], $this->allowed_protocols)) {
734 + if (!isset($parsed['scheme']) || !in_array($parsed['scheme'], $this->allowed_protocols, true)) {
611 735 return false;
612 736 }
613 737
614 738 return true;
@@ -721,14 +845,25 @@
721 845 $result['errors'][] = 'Invalid user or user not logged in';
722 846 return $result;
723 847 }
724 848
725 - // Check operation-specific permissions
849 + // Check operation-specific permissions.
850 + //
851 + // #457 loosened the route permission_callbacks to the delegable
852 + // `thinkrank_schema` capability, but these handler-level checks still
853 + // demanded edit_posts / publish_posts / manage_options — so a role
854 + // granted Schema access could generate and validate but was denied on
855 + // deploy, bulk operations and everything site-context. That is exactly
856 + // the symptom #457 set out to fix (#470). A holder of thinkrank_schema
857 + // satisfies any schema operation; the built-in caps remain as the
858 + // fallback for roles that never went through the Role Manager.
859 + $has_schema_cap = user_can($user_id, 'thinkrank_schema');
860 +
726 861 switch ($operation) {
727 862 case 'generate':
728 863 case 'validate':
729 864 case 'optimize':
730 - if (!user_can($user_id, 'edit_posts')) {
865 + if (!$has_schema_cap && !user_can($user_id, 'edit_posts')) {
731 866 $result['errors'][] = 'Insufficient permissions for schema generation/validation';
732 867 return $result;
733 868 }
734 869 break;
@@ -733,9 +868,9 @@
733 868 }
734 869 break;
735 870
736 871 case 'deploy':
737 - if (!user_can($user_id, 'publish_posts')) {
872 + if (!$has_schema_cap && !user_can($user_id, 'publish_posts')) {
738 873 $result['errors'][] = 'Insufficient permissions for schema deployment';
739 874 return $result;
740 875 }
741 876 break;
@@ -741,9 +876,9 @@
741 876 break;
742 877
743 878 case 'manage_settings':
744 879 case 'bulk_operations':
745 - if (!user_can($user_id, 'manage_options')) {
880 + if (!$has_schema_cap && !user_can($user_id, 'manage_options')) {
746 881 $result['errors'][] = 'Insufficient permissions for schema management';
747 882 return $result;
748 883 }
749 884 break;
@@ -808,10 +943,14 @@
808 943 $result['errors'][] = "Invalid context ID: {$context_id}";
809 944 return $result;
810 945 }
811 946
812 - // SECURITY: Check context ownership
813 - if ($user_id && !$this->validate_context_ownership($post, $user_id)) {
947 + // SECURITY: Check context ownership.
948 + // Fails closed on a missing user — a security helper that waves the
949 + // check through when it cannot identify the caller is the wrong way
950 + // round. Every caller passes a real ID, so this only tightens an
951 + // unreachable path.
952 + if (!$user_id || !$this->validate_context_ownership($post, $user_id)) {
814 953 $result['errors'][] = "Access denied: You don't have permission to modify this {$context_type}";
815 954 return $result;
816 955 }
817 956 } else {
@@ -816,10 +955,18 @@
816 955 }
817 956 } else {
818 957 $context_id = null; // Site context doesn't use ID
819 958
820 - // SECURITY: Check site-level permissions for site context
821 - if ($user_id && !current_user_can('manage_options')) {
959 + // SECURITY: Check site-level permissions for site context.
960 + // user_can($user_id, …) rather than current_user_can() so this
961 + // agrees with the rest of the validator outside a REST request,
962 + // where the current user and $user_id can differ (cron, CLI).
963 + //
964 + // Accepts the delegable `thinkrank_schema` capability as well as
965 + // manage_options: /schema/settings already lets a delegated role
966 + // edit site schema settings, so blocking site-context generate and
967 + // deploy for the same role was inconsistent (#470).
968 + if (!$user_id || (!user_can($user_id, 'thinkrank_schema') && !user_can($user_id, 'manage_options'))) {
822 969 $result['errors'][] = 'Access denied: You need administrator privileges for site-level schema operations';
823 970 return $result;
824 971 }
825 972 }
@@ -842,25 +989,23 @@
842 989 * @param int $user_id User ID
843 990 * @return bool Whether user has permission
844 991 */
845 992 private function validate_context_ownership(\WP_Post $post, int $user_id): bool {
846 - // Check if user can edit this specific post
847 - if (current_user_can('edit_post', $post->ID)) {
848 - return true;
849 - }
850 -
851 - // Check if user is the post author
852 - if ($post->post_author == $user_id) {
853 - return true;
854 - }
855 -
856 - // Check if user has general edit capabilities for this post type
857 - $post_type_object = get_post_type_object($post->post_type);
858 - if ($post_type_object && current_user_can($post_type_object->cap->edit_posts)) {
859 - return true;
860 - }
861 -
862 - return false;
993 + // `edit_post` is a meta capability: map_meta_cap() already resolves
994 + // authorship, published state, and edit_others_posts for this specific
995 + // post. It is the whole check.
996 + //
997 + // Two fallbacks used to sit under it and between them defeated the
998 + // function. One granted access on authorship alone, which hands a
999 + // Contributor back a post they lost edit rights to once it published.
1000 + // The other granted access to anyone holding the post type's *general*
1001 + // edit_posts capability — a cap every Author and Contributor has, that
1002 + // says nothing about this post — so ownership validation returned true
1003 + // for every post on the site (#326).
1004 + //
1005 + // user_can() rather than current_user_can() so the method honours the
1006 + // $user_id it was handed, matching validate_user_permissions().
1007 + return user_can($user_id, 'edit_post', $post->ID);
863 1008 }
864 1009
865 1010 /**
866 1011 * Validate JSON depth to prevent JSON bomb attacks
@@ -896,14 +1041,34 @@
896 1041 * @return array Sanitized options
897 1042 */
898 1043 public function sanitize_options(array $options): array {
899 1044 $sanitized = [];
1045 + // Anything omitted here is dropped before the manager sees it, which is
1046 + // why apply_content_schema_settings_from_options() and the per-request
1047 + // schema-type opt-in were unreachable from REST (#470). The list now
1048 + // covers every option the generate path actually reads.
1049 + //
1050 + // `validation_level` previously allowed 'basic' and rejected 'lenient',
1051 + // disagreeing with Schema_Settings_Config, validate_settings() and the
1052 + // update-settings ability, which all use 'lenient'.
1053 + // `deployment_method` no longer advertises microdata/rdfa, which
1054 + // determine_deployment_method() hardcodes away to json_ld anyway.
900 1055 $allowed_options = [
901 - 'deployment_method' => ['json_ld', 'microdata', 'rdfa'],
902 - 'validation_level' => ['strict', 'moderate', 'basic'],
1056 + 'deployment_method' => ['json_ld'],
1057 + 'validation_level' => ['strict', 'moderate', 'lenient'],
903 1058 'include_meta' => 'boolean',
904 1059 'minify_output' => 'boolean',
905 - 'cache_duration' => 'integer'
1060 + 'cache_duration' => 'integer',
1061 + 'rich_snippets_optimization' => 'boolean',
1062 + 'knowledge_graph' => 'boolean',
1063 + 'auto_generate_schema' => 'boolean',
1064 + 'enable_article_schema' => 'boolean',
1065 + 'enable_faq_schema' => 'boolean',
1066 + 'enable_howto_schema' => 'boolean',
1067 + 'enable_product_schema' => 'boolean',
1068 + 'enable_local_business' => 'boolean',
1069 + 'enable_breadcrumbs_schema' => 'boolean',
1070 + 'enable_accordion_faq_schema' => 'boolean',
906 1071 ];
907 1072
908 1073 foreach ($options as $key => $value) {
909 1074 $sanitized_key = sanitize_key($key);