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 +112 -32 1.29.02.7.0 View file →
@@ -118,8 +118,16 @@
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]
122 130 ]
123 131 ];
124 132
125 133 /**
@@ -363,15 +371,29 @@
363 371 $result['errors'][] = 'Invalid @context value. Must be "https://schema.org"';
364 372 $result['valid'] = false;
365 373 }
366 374
367 - // 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.
368 380 if (!isset($schema_data['@type'])) {
369 381 $result['errors'][] = 'Missing required @type field';
370 382 $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;
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 + }
374 396 }
375 397
376 398 return $result;
377 399 }
@@ -534,8 +556,26 @@
534 556 private function validate_data_formats(array $schema_data, string $schema_type): array {
535 557 $result = ['valid' => true, 'errors' => [], 'warnings' => []];
536 558
537 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 +
538 578 if (is_string($value)) {
539 579 // Validate URLs
540 580 if (in_array($field, ['url', 'sameAs', 'logo', 'image'], true) && !empty($value)) {
541 581 if (!$this->is_valid_url($value)) {
@@ -721,14 +761,25 @@
721 761 $result['errors'][] = 'Invalid user or user not logged in';
722 762 return $result;
723 763 }
724 764
725 - // 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 +
726 777 switch ($operation) {
727 778 case 'generate':
728 779 case 'validate':
729 780 case 'optimize':
730 - if (!user_can($user_id, 'edit_posts')) {
781 + if (!$has_schema_cap && !user_can($user_id, 'edit_posts')) {
731 782 $result['errors'][] = 'Insufficient permissions for schema generation/validation';
732 783 return $result;
733 784 }
734 785 break;
@@ -733,9 +784,9 @@
733 784 }
734 785 break;
735 786
736 787 case 'deploy':
737 - if (!user_can($user_id, 'publish_posts')) {
788 + if (!$has_schema_cap && !user_can($user_id, 'publish_posts')) {
738 789 $result['errors'][] = 'Insufficient permissions for schema deployment';
739 790 return $result;
740 791 }
741 792 break;
@@ -741,9 +792,9 @@
741 792 break;
742 793
743 794 case 'manage_settings':
744 795 case 'bulk_operations':
745 - if (!user_can($user_id, 'manage_options')) {
796 + if (!$has_schema_cap && !user_can($user_id, 'manage_options')) {
746 797 $result['errors'][] = 'Insufficient permissions for schema management';
747 798 return $result;
748 799 }
749 800 break;
@@ -808,10 +859,14 @@
808 859 $result['errors'][] = "Invalid context ID: {$context_id}";
809 860 return $result;
810 861 }
811 862
812 - // SECURITY: Check context ownership
813 - 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)) {
814 869 $result['errors'][] = "Access denied: You don't have permission to modify this {$context_type}";
815 870 return $result;
816 871 }
817 872 } else {
@@ -816,10 +871,18 @@
816 871 }
817 872 } else {
818 873 $context_id = null; // Site context doesn't use ID
819 874
820 - // SECURITY: Check site-level permissions for site context
821 - 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'))) {
822 885 $result['errors'][] = 'Access denied: You need administrator privileges for site-level schema operations';
823 886 return $result;
824 887 }
825 888 }
@@ -842,25 +905,23 @@
842 905 * @param int $user_id User ID
843 906 * @return bool Whether user has permission
844 907 */
845 908 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 ((int) $post->post_author === (int) $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;
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);
863 924 }
864 925
865 926 /**
866 927 * Validate JSON depth to prevent JSON bomb attacks
@@ -896,14 +957,33 @@
896 957 * @return array Sanitized options
897 958 */
898 959 public function sanitize_options(array $options): array {
899 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.
900 971 $allowed_options = [
901 - 'deployment_method' => ['json_ld', 'microdata', 'rdfa'],
902 - 'validation_level' => ['strict', 'moderate', 'basic'],
972 + 'deployment_method' => ['json_ld'],
973 + 'validation_level' => ['strict', 'moderate', 'lenient'],
903 974 'include_meta' => 'boolean',
904 975 'minify_output' => 'boolean',
905 - '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',
906 986 ];
907 987
908 988 foreach ($options as $key => $value) {
909 989 $sanitized_key = sanitize_key($key);