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/api/class-schema-endpoint.php +244 -74 2.0.1 → 2.14.1 View file →
@@ -23,8 +23,9 @@
23 23 use ThinkRank\SEO\Schema_Management_System;
24 24 use ThinkRank\SEO\Schema_Input_Validator;
25 25 use ThinkRank\API\Traits\Rate_Limiter;
26 26 use ThinkRank\API\Traits\Context_Authorization;
27 +use ThinkRank\API\Traits\CSRF_Protection;
27 28 use WP_REST_Controller;
28 29 use WP_REST_Request;
29 30 use WP_REST_Response;
30 31 use WP_Error;
@@ -31,8 +32,9 @@
31 32
32 33 // Load Rate Limiter trait
33 34 require_once THINKRANK_PLUGIN_DIR . 'includes/api/traits/trait-rate-limiter.php';
34 35 require_once THINKRANK_PLUGIN_DIR . 'includes/api/traits/trait-context-authorization.php';
36 +require_once THINKRANK_PLUGIN_DIR . 'includes/api/traits/trait-csrf-protection.php';
35 37
36 38 /**
37 39 * Schema API Endpoints Class
38 40 *
@@ -45,8 +47,11 @@
45 47 class Schema_Endpoint extends WP_REST_Controller {
46 48
47 49 use Rate_Limiter;
48 50 use Context_Authorization;
51 + // Shared nonce check — this class used to carry a byte-identical private
52 + // copy of verify_request_nonce() (#457).
53 + use CSRF_Protection;
49 54
50 55 /**
51 56 * Maximum number of items a single /bulk request may process synchronously.
52 57 * Larger workloads should be paged or queued rather than run in one request.
@@ -388,11 +393,20 @@
388 393 $json = trim($script->nodeValue);
389 394 $data = json_decode($json, true);
390 395
391 396 if (json_last_error() === JSON_ERROR_NONE && !empty($data)) {
392 - // Strictly set @context to https://schema.org
393 - $data['@context'] = 'https://schema.org';
394 - $found_schemas[] = $data;
397 + // A script block may hold a single entity, a bare list
398 + // of entities, or an object wrapping @graph. Treating
399 + // every block as one flat object collapsed lists into
400 + // numeric keys and never opened @graph — the shape Yoast
401 + // and Rank Math emit — so the import produced entries
402 + // with no top-level @type that deploy silently dropped
403 + // (#467).
404 + foreach ($this->extract_schema_entities($data) as $entity) {
405 + // Strictly set @context to https://schema.org
406 + $entity['@context'] = 'https://schema.org';
407 + $found_schemas[] = $entity;
408 + }
395 409 }
396 410 }
397 411 }
398 412
@@ -420,8 +434,87 @@
420 434 }
421 435 }
422 436
423 437 /**
438 + * Sanitize schema form data of arbitrary depth.
439 + *
440 + * The metabox forms post nested structures — `faq_questions` is a list of
441 + * `{question, answer}` objects and `howto_steps` a list of `{name, text}`
442 + * objects. A flat `array_map('sanitize_text_field', $value)` handed those
443 + * inner arrays to a string sanitizer, which returns '', so every question
444 + * and step was blanked before the builder saw it and FAQPage generated with
445 + * an empty `mainEntity` (failing its own required-property validation).
446 + * Recursing keeps the shape and still sanitizes every scalar leaf.
447 + *
448 + * @since 2.0.2
449 + *
450 + * @param array $data Raw form data.
451 + * @return array Sanitized form data with structure preserved.
452 + */
453 + private function sanitize_schema_form_data(array $data): array {
454 + $sanitized = [];
455 +
456 + foreach ($data as $key => $value) {
457 + $clean_key = is_int($key) ? $key : sanitize_key($key);
458 +
459 + if (is_array($value)) {
460 + $sanitized[$clean_key] = $this->sanitize_schema_form_data($value);
461 + } elseif (is_bool($value)) {
462 + $sanitized[$clean_key] = $value;
463 + } elseif (is_string($value)) {
464 + $sanitized[$clean_key] = sanitize_text_field($value);
465 + } elseif (is_numeric($value)) {
466 + $sanitized[$clean_key] = floatval($value);
467 + }
468 + }
469 +
470 + return $sanitized;
471 + }
472 +
473 + /**
474 + * Flatten one decoded JSON-LD script block into individual entities.
475 + *
476 + * JSON-LD allows a script tag to carry a single object, an array of objects,
477 + * or an object whose `@graph` holds the entities. Mirrors the Pro file
478 + * importer's extract_schemas() so both paths agree (#467).
479 + *
480 + * @since 1.16.0
481 + *
482 + * @param array $decoded Decoded JSON-LD.
483 + * @return array<int,array> One entry per entity.
484 + */
485 + private function extract_schema_entities(array $decoded): array {
486 + // Object wrapping @graph — the shape Yoast and Rank Math emit.
487 + if (!empty($decoded['@graph']) && is_array($decoded['@graph'])) {
488 + $context = $decoded['@context'] ?? null;
489 + $entities = [];
490 +
491 + foreach ($decoded['@graph'] as $entity) {
492 + if (!is_array($entity) || empty($entity)) {
493 + continue;
494 + }
495 + // Carry the outer @context onto entities that lack their own.
496 + if (null !== $context && !isset($entity['@context'])) {
497 + $entity['@context'] = $context;
498 + }
499 + $entities[] = $entity;
500 + }
501 +
502 + return $entities;
503 + }
504 +
505 + // Bare list of entities: [{...}, {...}]
506 + if (isset($decoded[0]) && is_array($decoded[0])) {
507 + return array_values(array_filter($decoded, static function ($entity) {
508 + return is_array($entity) && !empty($entity);
509 + }));
510 + }
511 +
512 + // Single entity.
513 + return [$decoded];
514 + }
515 +
516 + /**
424 517 * Fetch a remote URL for schema import.
425 518 *
426 519 * Delegates to the shared SSRF guard, which follows redirects manually and
427 520 * re-validates the resolved host against the block list on every hop —
@@ -434,8 +527,12 @@
434 527 private function fetch_import_url(string $url) {
435 528 return \ThinkRank\Core\Url_Safety::safe_remote_get($url, [
436 529 'timeout' => 15,
437 530 'user-agent' => 'ThinkRank/1.0.0 (WordPress Schema Plugin)',
531 + // Without a cap the whole body is buffered into memory and then
532 + // handed to DOMDocument at roughly twice the size, so a hostile or
533 + // simply enormous page could exhaust the request (#473).
534 + 'limit_response_size' => 2 * MB_IN_BYTES,
438 535 ]);
439 536 }
440 537
441 538 /**
@@ -531,23 +628,10 @@
531 628
532 629 // SECURITY: Sanitize schema_form_data if provided
533 630 $schema_form_data = $request->get_param('schema_form_data');
534 631 if ($schema_form_data && is_array($schema_form_data)) {
535 - // Sanitize all form fields
536 - $sanitized_form_data = [];
537 - foreach ($schema_form_data as $key => $value) {
538 - if (is_string($value)) {
539 - $sanitized_form_data[sanitize_key($key)] = sanitize_text_field($value);
540 - } elseif (is_array($value)) {
541 - // Handle array values (like features, steps, etc.)
542 - $sanitized_form_data[sanitize_key($key)] = array_map('sanitize_text_field', $value);
543 - } elseif (is_numeric($value)) {
544 - $sanitized_form_data[sanitize_key($key)] = floatval($value);
545 - }
546 - }
547 -
548 632 // Add schema_form_data to options so schema manager can use it
549 - $options['schema_form_data'] = $sanitized_form_data;
633 + $options['schema_form_data'] = $this->sanitize_schema_form_data($schema_form_data);
550 634 }
551 635
552 636 // Generate schema markup with sanitized inputs
553 637 $generation_results = $this->schema_manager->generate_schema_markup(
@@ -709,8 +793,9 @@
709 793 }
710 794
711 795 // SECURITY: Validate each schema in the data
712 796 $sanitized_schema_data = [];
797 + $skipped_schemas = [];
713 798 foreach ($schema_data as $schema_key => $schema_content) {
714 799 $schema_key = sanitize_text_field($schema_key);
715 800
716 801 if (!is_array($schema_content)) {
@@ -721,11 +806,22 @@
721 806 );
722 807 }
723 808
724 809 // Ensure schema has required structure fields before validation
725 - // Use @type from schema content if available, otherwise fall back to key
726 - $schema_type = isset($schema_content['@type']) ? sanitize_text_field($schema_content['@type']) : $schema_key;
727 -
810 + // Use @type from schema content if available, otherwise fall back to key.
811 + // `@type` may legitimately be an array ("@type": ["Product","Offer"]);
812 + // sanitize_text_field() on an array yields '', which then failed the
813 + // whitelist lookup with "Invalid schema type:" (#468). Resolve the
814 + // primary type for lookup and leave the original value in the payload.
815 + if (isset($schema_content['@type'])) {
816 + $raw_type = $schema_content['@type'];
817 + $schema_type = is_array($raw_type)
818 + ? sanitize_text_field((string) reset($raw_type))
819 + : sanitize_text_field((string) $raw_type);
820 + } else {
821 + $schema_type = $schema_key;
822 + }
823 +
728 824 if (!isset($schema_content['@type'])) {
729 825 $schema_content['@type'] = $schema_type;
730 826 }
731 827 if (!isset($schema_content['@context'])) {
@@ -731,20 +827,21 @@
731 827 if (!isset($schema_content['@context'])) {
732 828 $schema_content['@context'] = 'https://schema.org';
733 829 }
734 830
735 - // Validate using the actual schema type, not the key
831 + // Validate using the actual schema type, not the key.
832 + // A failure skips this entry instead of aborting the batch: the
833 + // UI sends every schema in one payload, so one unsupported type
834 + // used to block the valid entries alongside it (#468).
736 835 $input_validation = $this->input_validator->validate_schema_data($schema_content, $schema_type);
836 +
737 837 if (!$input_validation['valid']) {
738 - return new WP_Error(
739 - 'schema_validation_failed',
740 - "Schema validation failed for {$schema_type}: " . implode(', ', $input_validation['errors']),
741 - [
742 - 'status' => 400,
743 - 'schema_type' => $schema_type,
744 - 'validation_errors' => $input_validation['errors']
745 - ]
746 - );
838 + $skipped_schemas[] = [
839 + 'key' => $schema_key,
840 + 'type' => $schema_type,
841 + 'errors' => $input_validation['errors'],
842 + ];
843 + continue;
747 844 }
748 845
749 846 // Store using the key (which may be unique like "Article-1")
750 847 $sanitized_schema_data[$schema_key] = $input_validation['sanitized_data'];
@@ -749,11 +846,42 @@
749 846 // Store using the key (which may be unique like "Article-1")
750 847 $sanitized_schema_data[$schema_key] = $input_validation['sanitized_data'];
751 848 }
752 849
850 + // Every entry failed — that is a request-level error worth a 400,
851 + // since there is nothing to deploy.
852 + if (empty($sanitized_schema_data) && !empty($skipped_schemas)) {
853 + return new WP_Error(
854 + 'schema_validation_failed',
855 + sprintf(
856 + /* translators: %s: comma-separated list of schema types. */
857 + __('No schema could be deployed. Failed types: %s', 'thinkrank'),
858 + implode(', ', wp_list_pluck($skipped_schemas, 'type'))
859 + ),
860 + [
861 + 'status' => 400,
862 + 'skipped' => $skipped_schemas,
863 + ]
864 + );
865 + }
866 +
753 867 // SECURITY: Sanitize options
754 868 $options = $this->input_validator->sanitize_options($request->get_param('options') ?? []);
755 869
870 + // This route is the user pressing Deploy, so the payload is the full
871 + // intended set for the context — types missing from it were removed
872 + // deliberately and must come off the page (#464).
873 + $options['authoritative'] = true;
874 +
875 + // A skipped entry was sent, so the user still wants it on the page;
876 + // it only failed validation. Retiring it as "missing from the
877 + // payload" took a live Organization down behind a success toast
878 + // (#949) — leave whatever is deployed for it in place.
879 + $options['retain_types'] = array_values(array_unique(array_merge(
880 + wp_list_pluck($skipped_schemas, 'key'),
881 + wp_list_pluck($skipped_schemas, 'type')
882 + )));
883 +
756 884 // Deploy schema markup with sanitized data
757 885 $deployment_results = $this->schema_manager->deploy_schema_markup(
758 886 $context_type,
759 887 $context_id,
@@ -760,14 +888,29 @@
760 888 $sanitized_schema_data,
761 889 $options
762 890 );
763 891
764 - return new WP_REST_Response([
892 + $response = [
765 893 'success' => true,
766 894 'data' => $deployment_results,
767 895 'message' => 'Schema markup deployed successfully'
768 - ], 200);
896 + ];
769 897
898 + // Report what was skipped so the UI can say "3 deployed, 1 skipped"
899 + // rather than silently dropping entries (#468).
900 + if (!empty($skipped_schemas)) {
901 + $response['skipped'] = $skipped_schemas;
902 + $response['partial'] = true;
903 + $response['message'] = sprintf(
904 + /* translators: 1: number deployed, 2: number skipped. */
905 + __('Deployed %1$d schema(s); skipped %2$d that failed validation.', 'thinkrank'),
906 + count($sanitized_schema_data),
907 + count($skipped_schemas)
908 + );
909 + }
910 +
911 + return new WP_REST_Response($response, 200);
912 +
770 913 } catch (\Exception $e) {
771 914 return new WP_Error(
772 915 'deployment_failed',
773 916 'Schema deployment failed: ' . $e->getMessage(),
@@ -1357,10 +1500,13 @@
1357 1500 * @param WP_REST_Request $request Request object
1358 1501 * @return bool Permission status
1359 1502 */
1360 1503 public function check_generate_permissions(WP_REST_Request $request): bool {
1361 - // Check user capability
1362 - if (!current_user_can('edit_posts')) {
1504 + // Gate on the Role Manager's schema capability, like the read and
1505 + // settings routes. Core post caps were both too loose in principle and
1506 + // too strict in practice: a role granted schema access but without
1507 + // publish_posts could not deploy (#457).
1508 + if (!\ThinkRank\Core\Capability_Manager::current_user_can('thinkrank_schema')) {
1363 1509 return false;
1364 1510 }
1365 1511
1366 1512 // SECURITY: Verify nonce for CSRF protection
@@ -1375,10 +1521,13 @@
1375 1521 * @param WP_REST_Request $request Request object
1376 1522 * @return bool Permission status
1377 1523 */
1378 1524 public function check_validate_permissions(WP_REST_Request $request): bool {
1379 - // Check user capability
1380 - if (!current_user_can('edit_posts')) {
1525 + // Gate on the Role Manager's schema capability, like the read and
1526 + // settings routes. Core post caps were both too loose in principle and
1527 + // too strict in practice: a role granted schema access but without
1528 + // publish_posts could not deploy (#457).
1529 + if (!\ThinkRank\Core\Capability_Manager::current_user_can('thinkrank_schema')) {
1381 1530 return false;
1382 1531 }
1383 1532
1384 1533 // SECURITY: Verify nonce for CSRF protection
@@ -1393,10 +1542,13 @@
1393 1542 * @param WP_REST_Request $request Request object
1394 1543 * @return bool Permission status
1395 1544 */
1396 1545 public function check_deploy_permissions(WP_REST_Request $request): bool {
1397 - // Check user capability
1398 - if (!current_user_can('publish_posts')) {
1546 + // Gate on the Role Manager's schema capability, like the read and
1547 + // settings routes. Core post caps were both too loose in principle and
1548 + // too strict in practice: a role granted schema access but without
1549 + // publish_posts could not deploy (#457).
1550 + if (!\ThinkRank\Core\Capability_Manager::current_user_can('thinkrank_schema')) {
1399 1551 return false;
1400 1552 }
1401 1553
1402 1554 // SECURITY: Verify nonce for CSRF protection
@@ -1425,10 +1577,13 @@
1425 1577 * @param WP_REST_Request $request Request object
1426 1578 * @return bool Permission status
1427 1579 */
1428 1580 public function check_optimize_permissions(WP_REST_Request $request): bool {
1429 - // Check user capability
1430 - if (!current_user_can('edit_posts')) {
1581 + // Gate on the Role Manager's schema capability, like the read and
1582 + // settings routes. Core post caps were both too loose in principle and
1583 + // too strict in practice: a role granted schema access but without
1584 + // publish_posts could not deploy (#457).
1585 + if (!\ThinkRank\Core\Capability_Manager::current_user_can('thinkrank_schema')) {
1431 1586 return false;
1432 1587 }
1433 1588
1434 1589 // SECURITY: Verify nonce for CSRF protection
@@ -1478,33 +1633,11 @@
1478 1633 /**
1479 1634 * Helper methods
1480 1635 */
1481 1636
1482 - /**
1483 - * Verify request nonce for CSRF protection
1484 - *
1485 - * @since 1.0.0
1486 - *
1487 - * @param WP_REST_Request $request Request object
1488 - * @return bool Whether nonce is valid
1489 - */
1490 - private function verify_request_nonce(WP_REST_Request $request): bool {
1491 - // Get nonce from header (preferred method for REST API)
1492 - $nonce = $request->get_header('X-WP-Nonce');
1637 + // verify_request_nonce() now comes from the shared CSRF_Protection trait
1638 + // used by the other endpoints; the local copy was identical (#457).
1493 1639
1494 - // Fallback to parameter if header not present
1495 - if (!$nonce) {
1496 - $nonce = $request->get_param('_wpnonce');
1497 - }
1498 -
1499 - // Verify nonce
1500 - if (!$nonce || !wp_verify_nonce($nonce, 'wp_rest')) {
1501 - return false;
1502 - }
1503 -
1504 - return true;
1505 - }
1506 -
1507 1640 /**
1508 1641 * Generate schema preview
1509 1642 *
1510 1643 * @since 1.0.0
@@ -1691,9 +1824,15 @@
1691 1824 'enum' => [
1692 1825 'Article', 'BlogPosting', 'TechnicalArticle', 'NewsArticle',
1693 1826 'ScholarlyArticle', 'Report', 'Product', 'Organization',
1694 1827 'LocalBusiness', 'Person', 'WebSite', 'FAQPage',
1695 - 'Event', 'HowTo', 'SoftwareApplication'
1828 + 'Event', 'HowTo', 'SoftwareApplication', 'Review', 'VideoObject',
1829 + // The WebPage family (#624). This route is the one the
1830 + // per-page selector calls, and it did not accept even
1831 + // WebPage — so every entry in that dropdown was refused
1832 + // with a 400 before any of the registries below were
1833 + // consulted. Appended so existing ordering is unchanged.
1834 + 'WebPage', 'AboutPage', 'ContactPage', 'ProfilePage'
1696 1835 ]
1697 1836 ],
1698 1837 'description' => 'Schema types to generate'
1699 1838 ],
@@ -1738,9 +1877,11 @@
1738 1877 'enum' => [
1739 1878 'Article', 'BlogPosting', 'TechnicalArticle', 'NewsArticle',
1740 1879 'ScholarlyArticle', 'Report', 'Product', 'Organization',
1741 1880 'LocalBusiness', 'Person', 'WebSite', 'WebPage', 'FAQPage',
1742 - 'SoftwareApplication', 'Event', 'Recipe', 'HowTo'
1881 + 'SoftwareApplication', 'Event', 'Recipe', 'HowTo', 'Review', 'VideoObject',
1882 + // WebPage's subtypes, which validate exactly as it does (#624).
1883 + 'AboutPage', 'ContactPage', 'ProfilePage'
1743 1884 ],
1744 1885 'description' => 'Schema type'
1745 1886 ],
1746 1887 'options' => [
@@ -1804,9 +1945,11 @@
1804 1945 'type' => 'string',
1805 1946 'enum' => [
1806 1947 'Article', 'BlogPosting', 'Product', 'Organization', 'LocalBusiness',
1807 1948 'Person', 'WebSite', 'WebPage', 'FAQPage', 'SoftwareApplication',
1808 - 'BreadcrumbList', 'Event', 'Recipe', 'HowTo'
1949 + 'BreadcrumbList', 'Event', 'Recipe', 'HowTo', 'Review', 'VideoObject',
1950 + // WebPage's subtypes, which carry the same properties (#624).
1951 + 'AboutPage', 'ContactPage', 'ProfilePage'
1809 1952 ],
1810 1953 'description' => 'Schema type'
1811 1954 ],
1812 1955 'options' => [
@@ -1836,9 +1979,11 @@
1836 1979 'type' => 'string',
1837 1980 'enum' => [
1838 1981 'Article', 'BlogPosting', 'Product', 'Organization', 'LocalBusiness',
1839 1982 'Person', 'WebSite', 'WebPage', 'FAQPage', 'SoftwareApplication',
1840 - 'BreadcrumbList', 'Event', 'Recipe', 'HowTo'
1983 + 'BreadcrumbList', 'Event', 'Recipe', 'HowTo', 'Review', 'VideoObject',
1984 + // WebPage's subtypes, which carry the same properties (#624).
1985 + 'AboutPage', 'ContactPage', 'ProfilePage'
1841 1986 ],
1842 1987 'description' => 'Schema type'
1843 1988 ]
1844 1989 ];
@@ -1961,8 +2106,15 @@
1961 2106 );
1962 2107 }
1963 2108 $context_type = $context_validation['sanitized_data']['context_type'];
1964 2109 $context_id = $context_validation['sanitized_data']['context_id'];
2110 + } else {
2111 + // Site settings are keyed on a NULL context_id. Passing the
2112 + // client's value straight through meant a stray context_id
2113 + // wrote a row at an arbitrary id, returned 200, and was never
2114 + // read back by anything (#470). validate_context_parameters()
2115 + // already normalises this internally for other contexts.
2116 + $context_id = null;
1965 2117 }
1966 2118
1967 2119 // Drop unrecognized keys so arbitrary client-supplied keys aren't
1968 2120 // persisted as settings rows (storage bloat / settings drift).
@@ -2040,14 +2192,32 @@
2040 2192 * @param string $context_type Context type (site/post/page/product).
2041 2193 * @return array Settings limited to known keys.
2042 2194 */
2043 2195 private function filter_known_setting_keys(array $settings, string $context_type): array {
2044 - $known = array_keys(\ThinkRank\Config\Schema_Settings_Config::get_default_settings($context_type));
2045 - // Keys stored/consumed by adjacent features that share the settings
2046 - // store but aren't part of the schema defaults.
2047 - $known = array_merge($known, array_keys(\ThinkRank\Config\Schema_Settings_Config::get_settings_schema($context_type)), [
2048 - 'business_name', 'site_name', 'logo_url', 'performance_tracking',
2049 - ]);
2196 + // Defer to the manager instead of maintaining a parallel list here.
2197 + // The endpoint's own list ignored additional_setting_keys() and
2198 + // dynamic_setting_key_patterns() — the mechanism #452 added so new form
2199 + // families stop getting dropped — so the two disagreed in both
2200 + // directions: the four enable_*_schema toggles and the software_/howto_/
2201 + // product_ families were dropped here but accepted by the manager, while
2202 + // deployment_method, site_name and performance_tracking survived here
2203 + // only to be dropped one layer down (#470).
2204 + $known = [];
2205 +
2206 + foreach (array_keys($settings) as $key) {
2207 + if ($this->schema_manager->accepts_setting_key((string) $key, $context_type)) {
2208 + $known[] = (string) $key;
2209 + }
2210 + }
2211 +
2212 + /**
2213 + * Filter the schema setting keys the REST endpoint will persist.
2214 + *
2215 + * @since 1.13.0
2216 + *
2217 + * @param string[] $known Keys accepted by the schema manager.
2218 + * @param string $context_type Context type.
2219 + */
2050 2220 $known = apply_filters('thinkrank_schema_known_setting_keys', $known, $context_type);
2051 2221
2052 2222 return array_intersect_key($settings, array_flip($known));
2053 2223 }