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 +300 -276 1.28.0 → 2.14.1 View file →
@@ -22,8 +22,10 @@
22 22
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 +use ThinkRank\API\Traits\Context_Authorization;
27 +use ThinkRank\API\Traits\CSRF_Protection;
26 28 use WP_REST_Controller;
27 29 use WP_REST_Request;
28 30 use WP_REST_Response;
29 31 use WP_Error;
@@ -29,8 +31,10 @@
29 31 use WP_Error;
30 32
31 33 // Load Rate Limiter trait
32 34 require_once THINKRANK_PLUGIN_DIR . 'includes/api/traits/trait-rate-limiter.php';
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';
33 37
34 38 /**
35 39 * Schema API Endpoints Class
36 40 *
@@ -42,8 +46,12 @@
42 46 */
43 47 class Schema_Endpoint extends WP_REST_Controller {
44 48
45 49 use Rate_Limiter;
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;
46 54
47 55 /**
48 56 * Maximum number of items a single /bulk request may process synchronously.
49 57 * Larger workloads should be paged or queued rather than run in one request.
@@ -163,9 +171,10 @@
163 171 [
164 172 [
165 173 'methods' => 'GET',
166 174 'callback' => [$this, 'get_deployed_schemas'],
167 - 'permission_callback' => [$this, 'check_read_permissions']
175 + 'permission_callback' => [$this, 'check_read_permissions'],
176 + 'args' => $this->get_context_route_args()
168 177 ]
169 178 ]
170 179 );
171 180
@@ -268,9 +277,10 @@
268 277 [
269 278 [
270 279 'methods' => 'GET',
271 280 'callback' => [$this, 'get_settings'],
272 - 'permission_callback' => [$this, 'check_read_permissions']
281 + 'permission_callback' => [$this, 'check_read_permissions'],
282 + 'args' => $this->get_context_route_args()
273 283 ],
274 284 [
275 285 'methods' => 'POST',
276 286 'callback' => [$this, 'save_settings'],
@@ -325,9 +335,9 @@
325 335 // resolves to a private, loopback, link-local, or otherwise reserved
326 336 // IP range — including the link-local 169.254.0.0/16 (cloud metadata,
327 337 // e.g. 169.254.169.254) and 100.64.0.0/10 (CGNAT) ranges that
328 338 // wp_http_validate_url() does NOT block — re-validated on every
329 - // redirect hop. See fetch_import_url() / reject_disallowed_fetch_host().
339 + // redirect hop. See fetch_import_url() / \ThinkRank\Core\Url_Safety.
330 340 $response = $this->fetch_import_url($url);
331 341
332 342 if (is_wp_error($response)) {
333 343 // Preserve the SSRF/redirect block responses (they already carry a
@@ -383,11 +393,20 @@
383 393 $json = trim($script->nodeValue);
384 394 $data = json_decode($json, true);
385 395
386 396 if (json_last_error() === JSON_ERROR_NONE && !empty($data)) {
387 - // Strictly set @context to https://schema.org
388 - $data['@context'] = 'https://schema.org';
389 - $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 + }
390 409 }
391 410 }
392 411 }
393 412
@@ -415,180 +434,106 @@
415 434 }
416 435 }
417 436
418 437 /**
419 - * Fetch a remote URL for schema import, re-validating the host against the
420 - * SSRF block list on every redirect hop.
438 + * Sanitize schema form data of arbitrary depth.
421 439 *
422 - * wp_safe_remote_get() re-validates redirect targets with
423 - * wp_http_validate_url(), which shares the link-local/CGNAT blind spot, so
424 - * redirects are followed manually (redirection => 0) and each hop is checked
425 - * with {@see self::reject_disallowed_fetch_host()}.
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.
426 447 *
427 - * @param string $url URL to fetch.
428 - * @return array|\WP_Error Response array on success, WP_Error otherwise.
448 + * @since 2.0.2
449 + *
450 + * @param array $data Raw form data.
451 + * @return array Sanitized form data with structure preserved.
429 452 */
430 - private function fetch_import_url(string $url) {
431 - $max_redirects = 5;
453 + private function sanitize_schema_form_data(array $data): array {
454 + $sanitized = [];
432 455
433 - for ($hop = 0; $hop <= $max_redirects; $hop++) {
434 - if (!wp_http_validate_url($url)) {
435 - return new WP_Error('invalid_url', 'The URL is not allowed.', ['status' => 400]);
436 - }
456 + foreach ($data as $key => $value) {
457 + $clean_key = is_int($key) ? $key : sanitize_key($key);
437 458
438 - $host_check = $this->reject_disallowed_fetch_host($url);
439 - if (is_wp_error($host_check)) {
440 - return $host_check;
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);
441 467 }
442 -
443 - $response = wp_safe_remote_get($url, [
444 - 'timeout' => 15,
445 - 'redirection' => 0, // Follow manually so every hop is re-validated.
446 - 'user-agent' => 'ThinkRank/1.0.0 (WordPress Schema Plugin)',
447 - ]);
448 -
449 - if (is_wp_error($response)) {
450 - return $response;
451 - }
452 -
453 - $code = (int) wp_remote_retrieve_response_code($response);
454 - if ($code >= 300 && $code < 400) {
455 - $location = trim((string) wp_remote_retrieve_header($response, 'location'));
456 - if ('' === $location) {
457 - return $response; // Redirect without a target — treat as final.
458 - }
459 - // Resolve a relative Location against the current URL.
460 - $url = (string) \WP_Http::make_absolute_url($location, $url);
461 - if ('' === $url) {
462 - return new WP_Error('invalid_url', 'The URL is not allowed.', ['status' => 400]);
463 - }
464 - continue;
465 - }
466 -
467 - return $response;
468 468 }
469 469
470 - return new WP_Error('too_many_redirects', 'The URL redirected too many times.', ['status' => 400]);
470 + return $sanitized;
471 471 }
472 472
473 473 /**
474 - * Reject URLs whose host resolves to a private, loopback, link-local, or
475 - * otherwise reserved IP range.
474 + * Flatten one decoded JSON-LD script block into individual entities.
476 475 *
477 - * WordPress's wp_http_validate_url() blocks loopback and RFC1918 ranges but
478 - * NOT the link-local 169.254.0.0/16 (cloud metadata, e.g. 169.254.169.254)
479 - * or 100.64.0.0/10 (CGNAT) ranges. FILTER_FLAG_NO_RES_RANGE covers those
480 - * reserved ranges, and FILTER_FLAG_NO_PRIV_RANGE covers the private ranges.
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).
481 479 *
482 - * @param string $url URL to check.
483 - * @return true|\WP_Error True when every resolved address is public.
480 + * @since 1.16.0
481 + *
482 + * @param array $decoded Decoded JSON-LD.
483 + * @return array<int,array> One entry per entity.
484 484 */
485 - private function reject_disallowed_fetch_host(string $url) {
486 - $host = wp_parse_url($url, PHP_URL_HOST);
487 - if (empty($host)) {
488 - return new WP_Error('invalid_url', 'The URL is not allowed.', ['status' => 400]);
489 - }
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 490
491 - // Strip IPv6 literal brackets, if present.
492 - $host = trim($host, '[]');
493 -
494 - $ips = filter_var($host, FILTER_VALIDATE_IP) ? [$host] : $this->resolve_host_ips($host);
495 - if (empty($ips)) {
496 - return new WP_Error('invalid_url', 'The URL host could not be resolved.', ['status' => 400]);
497 - }
498 -
499 - foreach ($ips as $ip) {
500 - if (!$this->is_public_ip($ip)) {
501 - return new WP_Error('invalid_url', 'The URL is not allowed.', ['status' => 400]);
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;
502 500 }
503 - }
504 501
505 - return true;
506 - }
507 -
508 - /**
509 - * Whether an IP address is a routable public address.
510 - *
511 - * Combines PHP's private/reserved-range filters with explicit blocks for
512 - * reserved IPv4 ranges PHP's FILTER_FLAG_NO_RES_RANGE does not cover — most
513 - * importantly 100.64.0.0/10 (CGNAT), which some clouds use for metadata.
514 - *
515 - * @param string $ip IP address (v4 or v6).
516 - * @return bool
517 - */
518 - private function is_public_ip(string $ip): bool {
519 - if (!filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) {
520 - return false;
502 + return $entities;
521 503 }
522 504
523 - // Reserved IPv4 blocks PHP's NO_RES_RANGE misses.
524 - $extra_blocks = [
525 - '100.64.0.0/10', // Shared address space / CGNAT (RFC 6598).
526 - '192.0.0.0/24', // IETF protocol assignments (RFC 6890).
527 - '198.18.0.0/15', // Benchmarking (RFC 2544).
528 - '192.88.99.0/24', // 6to4 relay anycast (RFC 7526).
529 - ];
530 - foreach ($extra_blocks as $cidr) {
531 - if ($this->ipv4_in_cidr($ip, $cidr)) {
532 - return false;
533 - }
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 + }));
534 510 }
535 511
536 - return true;
512 + // Single entity.
513 + return [$decoded];
537 514 }
538 515
539 516 /**
540 - * Whether an IPv4 address falls within a CIDR block.
517 + * Fetch a remote URL for schema import.
541 518 *
542 - * @param string $ip IPv4 address.
543 - * @param string $cidr CIDR block (e.g. 100.64.0.0/10).
544 - * @return bool
545 - */
546 - private function ipv4_in_cidr(string $ip, string $cidr): bool {
547 - if (strpos($ip, ':') !== false) {
548 - return false; // IPv6 is not covered by these IPv4 blocks.
549 - }
550 -
551 - [$subnet, $bits] = array_pad(explode('/', $cidr, 2), 2, '32');
552 - $ip_long = ip2long($ip);
553 - $subnet_long = ip2long($subnet);
554 - if (false === $ip_long || false === $subnet_long) {
555 - return false;
556 - }
557 -
558 - $mask = -1 << (32 - (int) $bits);
559 - return ($ip_long & $mask) === ($subnet_long & $mask);
560 - }
561 -
562 - /**
563 - * Resolve a hostname to its IPv4 + IPv6 addresses.
519 + * Delegates to the shared SSRF guard, which follows redirects manually and
520 + * re-validates the resolved host against the block list on every hop —
521 + * wp_safe_remote_get()'s own redirect validation goes through
522 + * wp_http_validate_url(), which shares the link-local/CGNAT blind spot.
564 523 *
565 - * @param string $host Hostname.
566 - * @return string[] Resolved IP addresses (may be empty).
524 + * @param string $url URL to fetch.
525 + * @return array|\WP_Error Response array on success, WP_Error otherwise.
567 526 */
568 - private function resolve_host_ips(string $host): array {
569 - $ips = [];
570 -
571 - $records = @dns_get_record($host, DNS_A + DNS_AAAA);
572 - if (is_array($records)) {
573 - foreach ($records as $record) {
574 - if (!empty($record['ip'])) {
575 - $ips[] = $record['ip']; // A record.
576 - } elseif (!empty($record['ipv6'])) {
577 - $ips[] = $record['ipv6']; // AAAA record.
578 - }
579 - }
580 - }
581 -
582 - // Fallback where dns_get_record is unavailable or returns nothing.
583 - if (empty($ips)) {
584 - $resolved = gethostbyname($host);
585 - if ($resolved && $resolved !== $host) {
586 - $ips[] = $resolved;
587 - }
588 - }
589 -
590 - return $ips;
527 + private function fetch_import_url(string $url) {
528 + return \ThinkRank\Core\Url_Safety::safe_remote_get($url, [
529 + 'timeout' => 15,
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,
535 + ]);
591 536 }
592 537
593 538 /**
594 539 * Generate schema markup
@@ -683,23 +628,10 @@
683 628
684 629 // SECURITY: Sanitize schema_form_data if provided
685 630 $schema_form_data = $request->get_param('schema_form_data');
686 631 if ($schema_form_data && is_array($schema_form_data)) {
687 - // Sanitize all form fields
688 - $sanitized_form_data = [];
689 - foreach ($schema_form_data as $key => $value) {
690 - if (is_string($value)) {
691 - $sanitized_form_data[sanitize_key($key)] = sanitize_text_field($value);
692 - } elseif (is_array($value)) {
693 - // Handle array values (like features, steps, etc.)
694 - $sanitized_form_data[sanitize_key($key)] = array_map('sanitize_text_field', $value);
695 - } elseif (is_numeric($value)) {
696 - $sanitized_form_data[sanitize_key($key)] = floatval($value);
697 - }
698 - }
699 -
700 632 // Add schema_form_data to options so schema manager can use it
701 - $options['schema_form_data'] = $sanitized_form_data;
633 + $options['schema_form_data'] = $this->sanitize_schema_form_data($schema_form_data);
702 634 }
703 635
704 636 // Generate schema markup with sanitized inputs
705 637 $generation_results = $this->schema_manager->generate_schema_markup(
@@ -861,8 +793,9 @@
861 793 }
862 794
863 795 // SECURITY: Validate each schema in the data
864 796 $sanitized_schema_data = [];
797 + $skipped_schemas = [];
865 798 foreach ($schema_data as $schema_key => $schema_content) {
866 799 $schema_key = sanitize_text_field($schema_key);
867 800
868 801 if (!is_array($schema_content)) {
@@ -873,11 +806,22 @@
873 806 );
874 807 }
875 808
876 809 // Ensure schema has required structure fields before validation
877 - // Use @type from schema content if available, otherwise fall back to key
878 - $schema_type = isset($schema_content['@type']) ? sanitize_text_field($schema_content['@type']) : $schema_key;
879 -
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 +
880 824 if (!isset($schema_content['@type'])) {
881 825 $schema_content['@type'] = $schema_type;
882 826 }
883 827 if (!isset($schema_content['@context'])) {
@@ -883,20 +827,21 @@
883 827 if (!isset($schema_content['@context'])) {
884 828 $schema_content['@context'] = 'https://schema.org';
885 829 }
886 830
887 - // 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).
888 835 $input_validation = $this->input_validator->validate_schema_data($schema_content, $schema_type);
836 +
889 837 if (!$input_validation['valid']) {
890 - return new WP_Error(
891 - 'schema_validation_failed',
892 - "Schema validation failed for {$schema_type}: " . implode(', ', $input_validation['errors']),
893 - [
894 - 'status' => 400,
895 - 'schema_type' => $schema_type,
896 - 'validation_errors' => $input_validation['errors']
897 - ]
898 - );
838 + $skipped_schemas[] = [
839 + 'key' => $schema_key,
840 + 'type' => $schema_type,
841 + 'errors' => $input_validation['errors'],
842 + ];
843 + continue;
899 844 }
900 845
901 846 // Store using the key (which may be unique like "Article-1")
902 847 $sanitized_schema_data[$schema_key] = $input_validation['sanitized_data'];
@@ -901,11 +846,42 @@
901 846 // Store using the key (which may be unique like "Article-1")
902 847 $sanitized_schema_data[$schema_key] = $input_validation['sanitized_data'];
903 848 }
904 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 +
905 867 // SECURITY: Sanitize options
906 868 $options = $this->input_validator->sanitize_options($request->get_param('options') ?? []);
907 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 +
908 884 // Deploy schema markup with sanitized data
909 885 $deployment_results = $this->schema_manager->deploy_schema_markup(
910 886 $context_type,
911 887 $context_id,
@@ -912,14 +888,29 @@
912 888 $sanitized_schema_data,
913 889 $options
914 890 );
915 891
916 - return new WP_REST_Response([
892 + $response = [
917 893 'success' => true,
918 894 'data' => $deployment_results,
919 895 'message' => 'Schema markup deployed successfully'
920 - ], 200);
896 + ];
921 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 +
922 913 } catch (\Exception $e) {
923 914 return new WP_Error(
924 915 'deployment_failed',
925 916 'Schema deployment failed: ' . $e->getMessage(),
@@ -1057,17 +1048,17 @@
1057 1048 * @return WP_REST_Response|WP_Error Response object or error
1058 1049 */
1059 1050 public function get_deployed_schemas(WP_REST_Request $request) {
1060 1051 try {
1061 - $context_type = $request->get_param('context_type') ?? 'site';
1062 - $context_id = $request->get_param('context_id');
1063 -
1064 - // Convert context_id to int if it's a valid numeric string, otherwise null
1065 - if ($context_id !== null && is_numeric($context_id)) {
1066 - $context_id = (int) $context_id;
1067 - } else {
1068 - $context_id = null;
1052 + // SECURITY: this route reads the schema deployed against a specific
1053 + // object. The thinkrank_schema capability authorises the section, not
1054 + // every post on the site, so the object itself has to be authorised
1055 + // before the read (#385).
1056 + $context = $this->resolve_request_context($request);
1057 + if (is_wp_error($context)) {
1058 + return $context;
1069 1059 }
1060 + [$context_type, $context_id] = $context;
1070 1061
1071 1062 $deployed_schemas = $this->schema_manager->get_deployed_schemas($context_type, $context_id);
1072 1063
1073 1064 return new WP_REST_Response([
@@ -1097,15 +1088,13 @@
1097 1088 try {
1098 1089 $context_type = $request->get_param('context_type');
1099 1090 $context_id = (int) $request->get_param('context_id');
1100 1091
1101 - // Validate context
1102 - if (!$this->validate_context($context_type, $context_id)) {
1103 - return new WP_Error(
1104 - 'invalid_context',
1105 - 'Invalid context type or ID provided',
1106 - ['status' => 400]
1107 - );
1092 + // Validate context and the caller's access to it. Returns true or a
1093 + // WP_Error carrying the right status (400 shape, 403 authorization).
1094 + $context_validation = $this->validate_context($context_type, $context_id);
1095 + if (is_wp_error($context_validation)) {
1096 + return $context_validation;
1108 1097 }
1109 1098
1110 1099 // Get schema output data
1111 1100 $schema_data = $this->schema_manager->get_output_data($context_type, $context_id);
@@ -1159,11 +1148,38 @@
1159 1148 ['status' => 400]
1160 1149 );
1161 1150 }
1162 1151
1163 - // Optimize rich snippets
1152 + // SECURITY: Validate and sanitize schema data using input validator,
1153 + // the same way generate/validate/deploy do — this route must not be
1154 + // the one path that hands a raw client blob to the schema manager.
1155 + if (!is_array($schema_data)) {
1156 + return new WP_Error(
1157 + 'invalid_schema_data',
1158 + 'Schema data must be an array/object',
1159 + ['status' => 400]
1160 + );
1161 + }
1162 +
1163 + $input_validation = $this->input_validator->validate_schema_data($schema_data, $schema_type);
1164 + if (!$input_validation['valid']) {
1165 + return new WP_Error(
1166 + 'schema_validation_failed',
1167 + 'Schema data validation failed: ' . implode(', ', $input_validation['errors']),
1168 + [
1169 + 'status' => 400,
1170 + 'validation_errors' => $input_validation['errors'],
1171 + 'validation_warnings' => $input_validation['warnings']
1172 + ]
1173 + );
1174 + }
1175 +
1176 + // SECURITY: Sanitize options
1177 + $options = $this->input_validator->sanitize_options($options);
1178 +
1179 + // Optimize rich snippets with the sanitized data
1164 1180 $optimization_results = $this->schema_manager->optimize_rich_snippets(
1165 - $schema_data,
1181 + $input_validation['sanitized_data'],
1166 1182 $schema_type,
1167 1183 $options
1168 1184 );
1169 1185
@@ -1195,15 +1211,13 @@
1195 1211 $context_type = $request->get_param('context_type');
1196 1212 $context_id = (int) $request->get_param('context_id');
1197 1213 $options = $request->get_param('options') ?? [];
1198 1214
1199 - // Validate context
1200 - if (!$this->validate_context($context_type, $context_id)) {
1201 - return new WP_Error(
1202 - 'invalid_context',
1203 - 'Invalid context type or ID provided',
1204 - ['status' => 400]
1205 - );
1215 + // Validate context and the caller's access to it. Returns true or a
1216 + // WP_Error carrying the right status (400 shape, 403 authorization).
1217 + $context_validation = $this->validate_context($context_type, $context_id);
1218 + if (is_wp_error($context_validation)) {
1219 + return $context_validation;
1206 1220 }
1207 1221
1208 1222 // Track schema performance
1209 1223 $performance_data = $this->schema_manager->track_schema_performance(
@@ -1273,8 +1287,10 @@
1273 1287 * @since 1.0.0
1274 1288 *
1275 1289 * @param WP_REST_Request $request Request object
1276 1290 * @return WP_REST_Response|WP_Error Response object or error
1291 + *
1292 + * @throws \Exception On failure.
1277 1293 */
1278 1294 public function bulk_operations(WP_REST_Request $request) {
1279 1295 try {
1280 1296 $user_id = get_current_user_id();
@@ -1484,10 +1500,13 @@
1484 1500 * @param WP_REST_Request $request Request object
1485 1501 * @return bool Permission status
1486 1502 */
1487 1503 public function check_generate_permissions(WP_REST_Request $request): bool {
1488 - // Check user capability
1489 - 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')) {
1490 1509 return false;
1491 1510 }
1492 1511
1493 1512 // SECURITY: Verify nonce for CSRF protection
@@ -1502,10 +1521,13 @@
1502 1521 * @param WP_REST_Request $request Request object
1503 1522 * @return bool Permission status
1504 1523 */
1505 1524 public function check_validate_permissions(WP_REST_Request $request): bool {
1506 - // Check user capability
1507 - 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')) {
1508 1530 return false;
1509 1531 }
1510 1532
1511 1533 // SECURITY: Verify nonce for CSRF protection
@@ -1520,10 +1542,13 @@
1520 1542 * @param WP_REST_Request $request Request object
1521 1543 * @return bool Permission status
1522 1544 */
1523 1545 public function check_deploy_permissions(WP_REST_Request $request): bool {
1524 - // Check user capability
1525 - 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')) {
1526 1551 return false;
1527 1552 }
1528 1553
1529 1554 // SECURITY: Verify nonce for CSRF protection
@@ -1552,10 +1577,13 @@
1552 1577 * @param WP_REST_Request $request Request object
1553 1578 * @return bool Permission status
1554 1579 */
1555 1580 public function check_optimize_permissions(WP_REST_Request $request): bool {
1556 - // Check user capability
1557 - 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')) {
1558 1586 return false;
1559 1587 }
1560 1588
1561 1589 // SECURITY: Verify nonce for CSRF protection
@@ -1605,61 +1633,12 @@
1605 1633 /**
1606 1634 * Helper methods
1607 1635 */
1608 1636
1609 - /**
1610 - * Verify request nonce for CSRF protection
1611 - *
1612 - * @since 1.0.0
1613 - *
1614 - * @param WP_REST_Request $request Request object
1615 - * @return bool Whether nonce is valid
1616 - */
1617 - private function verify_request_nonce(WP_REST_Request $request): bool {
1618 - // Get nonce from header (preferred method for REST API)
1619 - $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).
1620 1639
1621 - // Fallback to parameter if header not present
1622 - if (!$nonce) {
1623 - $nonce = $request->get_param('_wpnonce');
1624 - }
1625 -
1626 - // Verify nonce
1627 - if (!$nonce || !wp_verify_nonce($nonce, 'wp_rest')) {
1628 - return false;
1629 - }
1630 -
1631 - return true;
1632 - }
1633 -
1634 1640 /**
1635 - * Validate context type and ID
1636 - *
1637 - * @since 1.0.0
1638 - *
1639 - * @param string $context_type Context type
1640 - * @param int|null $context_id Context ID
1641 - * @return bool Validation status
1642 - */
1643 - private function validate_context(string $context_type, ?int $context_id): bool {
1644 - $valid_types = ['site', 'post', 'page', 'product'];
1645 -
1646 - if (!in_array($context_type, $valid_types, true)) {
1647 - return false;
1648 - }
1649 -
1650 - if ($context_type !== 'site' && (!$context_id || $context_id <= 0)) {
1651 - return false;
1652 - }
1653 -
1654 - if ($context_id && !get_post($context_id)) {
1655 - return false;
1656 - }
1657 -
1658 - return true;
1659 - }
1660 -
1661 - /**
1662 1641 * Generate schema preview
1663 1642 *
1664 1643 * @since 1.0.0
1665 1644 *
@@ -1834,10 +1813,13 @@
1834 1813 'minimum' => 1,
1835 1814 'description' => 'Context ID (not required for site context)'
1836 1815 ],
1837 1816 'schema_types' => [
1838 - 'required' => false,
1817 + // generate_schema() rejects a missing or empty value with a 400,
1818 + // so the schema has to say so too.
1819 + 'required' => true,
1839 1820 'type' => 'array',
1821 + 'minItems' => 1,
1840 1822 'items' => [
1841 1823 'type' => 'string',
1842 1824 'enum' => [
1843 1825 'Article', 'BlogPosting', 'TechnicalArticle', 'NewsArticle',
@@ -1842,9 +1824,15 @@
1842 1824 'enum' => [
1843 1825 'Article', 'BlogPosting', 'TechnicalArticle', 'NewsArticle',
1844 1826 'ScholarlyArticle', 'Report', 'Product', 'Organization',
1845 1827 'LocalBusiness', 'Person', 'WebSite', 'FAQPage',
1846 - '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'
1847 1835 ]
1848 1836 ],
1849 1837 'description' => 'Schema types to generate'
1850 1838 ],
@@ -1889,9 +1877,11 @@
1889 1877 'enum' => [
1890 1878 'Article', 'BlogPosting', 'TechnicalArticle', 'NewsArticle',
1891 1879 'ScholarlyArticle', 'Report', 'Product', 'Organization',
1892 1880 'LocalBusiness', 'Person', 'WebSite', 'WebPage', 'FAQPage',
1893 - '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'
1894 1884 ],
1895 1885 'description' => 'Schema type'
1896 1886 ],
1897 1887 'options' => [
@@ -1955,9 +1945,11 @@
1955 1945 'type' => 'string',
1956 1946 'enum' => [
1957 1947 'Article', 'BlogPosting', 'Product', 'Organization', 'LocalBusiness',
1958 1948 'Person', 'WebSite', 'WebPage', 'FAQPage', 'SoftwareApplication',
1959 - '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'
1960 1952 ],
1961 1953 'description' => 'Schema type'
1962 1954 ],
1963 1955 'options' => [
@@ -1987,9 +1979,11 @@
1987 1979 'type' => 'string',
1988 1980 'enum' => [
1989 1981 'Article', 'BlogPosting', 'Product', 'Organization', 'LocalBusiness',
1990 1982 'Person', 'WebSite', 'WebPage', 'FAQPage', 'SoftwareApplication',
1991 - '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'
1992 1986 ],
1993 1987 'description' => 'Schema type'
1994 1988 ]
1995 1989 ];
@@ -2039,10 +2033,15 @@
2039 2033 * @return WP_REST_Response|WP_Error Response object or error
2040 2034 */
2041 2035 public function get_settings(WP_REST_Request $request) {
2042 2036 try {
2043 - $context_type = $request->get_param('context_type') ?? 'site';
2044 - $context_id = $request->get_param('context_id') ?? null;
2037 + // SECURITY: the settings this returns are per-object. save_settings()
2038 + // already authorises the object; the read has to as well (#385).
2039 + $context = $this->resolve_request_context($request);
2040 + if (is_wp_error($context)) {
2041 + return $context;
2042 + }
2043 + [$context_type, $context_id] = $context;
2045 2044
2046 2045 // Get settings from schema manager
2047 2046 $settings = $this->schema_manager->get_settings($context_type, $context_id);
2048 2047
@@ -2107,8 +2106,15 @@
2107 2106 );
2108 2107 }
2109 2108 $context_type = $context_validation['sanitized_data']['context_type'];
2110 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;
2111 2117 }
2112 2118
2113 2119 // Drop unrecognized keys so arbitrary client-supplied keys aren't
2114 2120 // persisted as settings rows (storage bloat / settings drift).
@@ -2186,14 +2192,32 @@
2186 2192 * @param string $context_type Context type (site/post/page/product).
2187 2193 * @return array Settings limited to known keys.
2188 2194 */
2189 2195 private function filter_known_setting_keys(array $settings, string $context_type): array {
2190 - $known = array_keys(\ThinkRank\Config\Schema_Settings_Config::get_default_settings($context_type));
2191 - // Keys stored/consumed by adjacent features that share the settings
2192 - // store but aren't part of the schema defaults.
2193 - $known = array_merge($known, array_keys(\ThinkRank\Config\Schema_Settings_Config::get_settings_schema($context_type)), [
2194 - 'business_name', 'site_name', 'logo_url', 'performance_tracking',
2195 - ]);
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 + */
2196 2220 $known = apply_filters('thinkrank_schema_known_setting_keys', $known, $context_type);
2197 2221
2198 2222 return array_intersect_key($settings, array_flip($known));
2199 2223 }