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/api/class-schema-endpoint.php +507 -206 1.10.02.7.0 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,10 +46,23 @@
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 /**
56 + * Maximum number of items a single /bulk request may process synchronously.
57 + * Larger workloads should be paged or queued rather than run in one request.
58 + *
59 + * @since 1.20.1
60 + * @var int
61 + */
62 + private const MAX_BULK_ITEMS = 50;
63 +
64 + /**
48 65 * Schema Management System instance
49 66 *
50 67 * @since 1.0.0
51 68 * @var Schema_Management_System
@@ -154,9 +171,10 @@
154 171 [
155 172 [
156 173 'methods' => 'GET',
157 174 'callback' => [$this, 'get_deployed_schemas'],
158 - 'permission_callback' => [$this, 'check_read_permissions']
175 + 'permission_callback' => [$this, 'check_read_permissions'],
176 + 'args' => $this->get_context_route_args()
159 177 ]
160 178 ]
161 179 );
162 180
@@ -259,9 +277,10 @@
259 277 [
260 278 [
261 279 'methods' => 'GET',
262 280 'callback' => [$this, 'get_settings'],
263 - 'permission_callback' => [$this, 'check_read_permissions']
281 + 'permission_callback' => [$this, 'check_read_permissions'],
282 + 'args' => $this->get_context_route_args()
264 283 ],
265 284 [
266 285 'methods' => 'POST',
267 286 'callback' => [$this, 'save_settings'],
@@ -311,16 +330,23 @@
311 330 ['status' => 400]
312 331 );
313 332 }
314 333
315 - // Fetch the URL
316 - $response = wp_remote_get($url, [
317 - 'timeout' => 15,
318 - 'user-agent' => 'ThinkRank/1.0.0 (WordPress Schema Plugin)',
319 - 'sslverify' => false // Relaxed SSL verification for broader compatibility
320 - ]);
334 + // Block SSRF: reject non-http(s)/malformed URLs and any host that
335 + // resolves to a private, loopback, link-local, or otherwise reserved
336 + // IP range — including the link-local 169.254.0.0/16 (cloud metadata,
337 + // e.g. 169.254.169.254) and 100.64.0.0/10 (CGNAT) ranges that
338 + // wp_http_validate_url() does NOT block — re-validated on every
339 + // redirect hop. See fetch_import_url() / \ThinkRank\Core\Url_Safety.
340 + $response = $this->fetch_import_url($url);
321 341
322 342 if (is_wp_error($response)) {
343 + // Preserve the SSRF/redirect block responses (they already carry a
344 + // 4xx status); wrap transport-level failures as a 500.
345 + $error_data = $response->get_error_data();
346 + if (is_array($error_data) && isset($error_data['status'])) {
347 + return $response;
348 + }
323 349 return new WP_Error(
324 350 'fetch_failed',
325 351 'Failed to fetch data from URL: ' . $response->get_error_message(),
326 352 ['status' => 500]
@@ -349,10 +375,12 @@
349 375 // Suppress DOM errors for malformed HTML
350 376 libxml_use_internal_errors(true);
351 377
352 378 $dom = new \DOMDocument();
353 - // Using loadHTML with minimal options to handle various encodings/formats better
354 - $dom->loadHTML(mb_convert_encoding($body, 'HTML-ENTITIES', 'UTF-8'), LIBXML_NOERROR | LIBXML_NOWARNING);
379 + // Prepend an XML encoding hint so DOMDocument parses UTF-8 correctly.
380 + // Avoids the deprecated mb_convert_encoding($body, 'HTML-ENTITIES') call,
381 + // which emits deprecation notices on PHP 8.2+.
382 + $dom->loadHTML('<?xml encoding="UTF-8">' . $body, LIBXML_NOERROR | LIBXML_NOWARNING);
355 383
356 384 libxml_clear_errors();
357 385
358 386 $xpath = new \DOMXPath($dom);
@@ -365,11 +393,20 @@
365 393 $json = trim($script->nodeValue);
366 394 $data = json_decode($json, true);
367 395
368 396 if (json_last_error() === JSON_ERROR_NONE && !empty($data)) {
369 - // Strictly set @context to https://schema.org
370 - $data['@context'] = 'https://schema.org';
371 - $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 + }
372 409 }
373 410 }
374 411 }
375 412
@@ -397,8 +434,109 @@
397 434 }
398 435 }
399 436
400 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 + /**
517 + * Fetch a remote URL for schema import.
518 + *
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.
523 + *
524 + * @param string $url URL to fetch.
525 + * @return array|\WP_Error Response array on success, WP_Error otherwise.
526 + */
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 + ]);
536 + }
537 +
538 + /**
401 539 * Generate schema markup
402 540 *
403 541 * @since 1.0.0
404 542 *
@@ -490,23 +628,10 @@
490 628
491 629 // SECURITY: Sanitize schema_form_data if provided
492 630 $schema_form_data = $request->get_param('schema_form_data');
493 631 if ($schema_form_data && is_array($schema_form_data)) {
494 - // Sanitize all form fields
495 - $sanitized_form_data = [];
496 - foreach ($schema_form_data as $key => $value) {
497 - if (is_string($value)) {
498 - $sanitized_form_data[sanitize_key($key)] = sanitize_text_field($value);
499 - } elseif (is_array($value)) {
500 - // Handle array values (like features, steps, etc.)
501 - $sanitized_form_data[sanitize_key($key)] = array_map('sanitize_text_field', $value);
502 - } elseif (is_numeric($value)) {
503 - $sanitized_form_data[sanitize_key($key)] = floatval($value);
504 - }
505 - }
506 -
507 632 // Add schema_form_data to options so schema manager can use it
508 - $options['schema_form_data'] = $sanitized_form_data;
633 + $options['schema_form_data'] = $this->sanitize_schema_form_data($schema_form_data);
509 634 }
510 635
511 636 // Generate schema markup with sanitized inputs
512 637 $generation_results = $this->schema_manager->generate_schema_markup(
@@ -668,8 +793,9 @@
668 793 }
669 794
670 795 // SECURITY: Validate each schema in the data
671 796 $sanitized_schema_data = [];
797 + $skipped_schemas = [];
672 798 foreach ($schema_data as $schema_key => $schema_content) {
673 799 $schema_key = sanitize_text_field($schema_key);
674 800
675 801 if (!is_array($schema_content)) {
@@ -680,11 +806,22 @@
680 806 );
681 807 }
682 808
683 809 // Ensure schema has required structure fields before validation
684 - // Use @type from schema content if available, otherwise fall back to key
685 - $schema_type = isset($schema_content['@type']) ? sanitize_text_field($schema_content['@type']) : $schema_key;
686 -
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 +
687 824 if (!isset($schema_content['@type'])) {
688 825 $schema_content['@type'] = $schema_type;
689 826 }
690 827 if (!isset($schema_content['@context'])) {
@@ -690,20 +827,21 @@
690 827 if (!isset($schema_content['@context'])) {
691 828 $schema_content['@context'] = 'https://schema.org';
692 829 }
693 830
694 - // 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).
695 835 $input_validation = $this->input_validator->validate_schema_data($schema_content, $schema_type);
836 +
696 837 if (!$input_validation['valid']) {
697 - return new WP_Error(
698 - 'schema_validation_failed',
699 - "Schema validation failed for {$schema_type}: " . implode(', ', $input_validation['errors']),
700 - [
701 - 'status' => 400,
702 - 'schema_type' => $schema_type,
703 - 'validation_errors' => $input_validation['errors']
704 - ]
705 - );
838 + $skipped_schemas[] = [
839 + 'key' => $schema_key,
840 + 'type' => $schema_type,
841 + 'errors' => $input_validation['errors'],
842 + ];
843 + continue;
706 844 }
707 845
708 846 // Store using the key (which may be unique like "Article-1")
709 847 $sanitized_schema_data[$schema_key] = $input_validation['sanitized_data'];
@@ -708,11 +846,33 @@
708 846 // Store using the key (which may be unique like "Article-1")
709 847 $sanitized_schema_data[$schema_key] = $input_validation['sanitized_data'];
710 848 }
711 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 +
712 867 // SECURITY: Sanitize options
713 868 $options = $this->input_validator->sanitize_options($request->get_param('options') ?? []);
714 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 +
715 875 // Deploy schema markup with sanitized data
716 876 $deployment_results = $this->schema_manager->deploy_schema_markup(
717 877 $context_type,
718 878 $context_id,
@@ -719,14 +879,28 @@
719 879 $sanitized_schema_data,
720 880 $options
721 881 );
722 882
723 - return new WP_REST_Response([
883 + $response = [
724 884 'success' => true,
725 885 'data' => $deployment_results,
726 886 'message' => 'Schema markup deployed successfully'
727 - ], 200);
887 + ];
728 888
889 + // Report what was skipped so the UI can say "3 deployed, 1 skipped"
890 + // rather than silently dropping entries (#468).
891 + if (!empty($skipped_schemas)) {
892 + $response['skipped'] = $skipped_schemas;
893 + $response['message'] = sprintf(
894 + /* translators: 1: number deployed, 2: number skipped. */
895 + __('Deployed %1$d schema(s); skipped %2$d that failed validation.', 'thinkrank'),
896 + count($sanitized_schema_data),
897 + count($skipped_schemas)
898 + );
899 + }
900 +
901 + return new WP_REST_Response($response, 200);
902 +
729 903 } catch (\Exception $e) {
730 904 return new WP_Error(
731 905 'deployment_failed',
732 906 'Schema deployment failed: ' . $e->getMessage(),
@@ -864,17 +1038,17 @@
864 1038 * @return WP_REST_Response|WP_Error Response object or error
865 1039 */
866 1040 public function get_deployed_schemas(WP_REST_Request $request) {
867 1041 try {
868 - $context_type = $request->get_param('context_type') ?? 'site';
869 - $context_id = $request->get_param('context_id');
870 -
871 - // Convert context_id to int if it's a valid numeric string, otherwise null
872 - if ($context_id !== null && is_numeric($context_id)) {
873 - $context_id = (int) $context_id;
874 - } else {
875 - $context_id = null;
1042 + // SECURITY: this route reads the schema deployed against a specific
1043 + // object. The thinkrank_schema capability authorises the section, not
1044 + // every post on the site, so the object itself has to be authorised
1045 + // before the read (#385).
1046 + $context = $this->resolve_request_context($request);
1047 + if (is_wp_error($context)) {
1048 + return $context;
876 1049 }
1050 + [$context_type, $context_id] = $context;
877 1051
878 1052 $deployed_schemas = $this->schema_manager->get_deployed_schemas($context_type, $context_id);
879 1053
880 1054 return new WP_REST_Response([
@@ -904,15 +1078,13 @@
904 1078 try {
905 1079 $context_type = $request->get_param('context_type');
906 1080 $context_id = (int) $request->get_param('context_id');
907 1081
908 - // Validate context
909 - if (!$this->validate_context($context_type, $context_id)) {
910 - return new WP_Error(
911 - 'invalid_context',
912 - 'Invalid context type or ID provided',
913 - ['status' => 400]
914 - );
1082 + // Validate context and the caller's access to it. Returns true or a
1083 + // WP_Error carrying the right status (400 shape, 403 authorization).
1084 + $context_validation = $this->validate_context($context_type, $context_id);
1085 + if (is_wp_error($context_validation)) {
1086 + return $context_validation;
915 1087 }
916 1088
917 1089 // Get schema output data
918 1090 $schema_data = $this->schema_manager->get_output_data($context_type, $context_id);
@@ -966,11 +1138,38 @@
966 1138 ['status' => 400]
967 1139 );
968 1140 }
969 1141
970 - // Optimize rich snippets
1142 + // SECURITY: Validate and sanitize schema data using input validator,
1143 + // the same way generate/validate/deploy do — this route must not be
1144 + // the one path that hands a raw client blob to the schema manager.
1145 + if (!is_array($schema_data)) {
1146 + return new WP_Error(
1147 + 'invalid_schema_data',
1148 + 'Schema data must be an array/object',
1149 + ['status' => 400]
1150 + );
1151 + }
1152 +
1153 + $input_validation = $this->input_validator->validate_schema_data($schema_data, $schema_type);
1154 + if (!$input_validation['valid']) {
1155 + return new WP_Error(
1156 + 'schema_validation_failed',
1157 + 'Schema data validation failed: ' . implode(', ', $input_validation['errors']),
1158 + [
1159 + 'status' => 400,
1160 + 'validation_errors' => $input_validation['errors'],
1161 + 'validation_warnings' => $input_validation['warnings']
1162 + ]
1163 + );
1164 + }
1165 +
1166 + // SECURITY: Sanitize options
1167 + $options = $this->input_validator->sanitize_options($options);
1168 +
1169 + // Optimize rich snippets with the sanitized data
971 1170 $optimization_results = $this->schema_manager->optimize_rich_snippets(
972 - $schema_data,
1171 + $input_validation['sanitized_data'],
973 1172 $schema_type,
974 1173 $options
975 1174 );
976 1175
@@ -1002,15 +1201,13 @@
1002 1201 $context_type = $request->get_param('context_type');
1003 1202 $context_id = (int) $request->get_param('context_id');
1004 1203 $options = $request->get_param('options') ?? [];
1005 1204
1006 - // Validate context
1007 - if (!$this->validate_context($context_type, $context_id)) {
1008 - return new WP_Error(
1009 - 'invalid_context',
1010 - 'Invalid context type or ID provided',
1011 - ['status' => 400]
1012 - );
1205 + // Validate context and the caller's access to it. Returns true or a
1206 + // WP_Error carrying the right status (400 shape, 403 authorization).
1207 + $context_validation = $this->validate_context($context_type, $context_id);
1208 + if (is_wp_error($context_validation)) {
1209 + return $context_validation;
1013 1210 }
1014 1211
1015 1212 // Track schema performance
1016 1213 $performance_data = $this->schema_manager->track_schema_performance(
@@ -1080,8 +1277,10 @@
1080 1277 * @since 1.0.0
1081 1278 *
1082 1279 * @param WP_REST_Request $request Request object
1083 1280 * @return WP_REST_Response|WP_Error Response object or error
1281 + *
1282 + * @throws \Exception On failure.
1084 1283 */
1085 1284 public function bulk_operations(WP_REST_Request $request) {
1086 1285 try {
1087 1286 $user_id = get_current_user_id();
@@ -1108,35 +1307,137 @@
1108 1307 ['status' => 400]
1109 1308 );
1110 1309 }
1111 1310
1311 + // Defensive recheck of the item cap (the REST arg maxItems already
1312 + // enforces it, but never process an unbounded batch even if that
1313 + // schema is bypassed).
1314 + if (count($items) > self::MAX_BULK_ITEMS) {
1315 + return new WP_Error(
1316 + 'too_many_items',
1317 + sprintf('Bulk operations are limited to %d items per request.', self::MAX_BULK_ITEMS),
1318 + ['status' => 400]
1319 + );
1320 + }
1321 +
1112 1322 $results = [];
1113 1323 $errors = [];
1114 1324
1115 1325 foreach ($items as $item) {
1116 1326 try {
1327 + if (!is_array($item)) {
1328 + throw new \Exception('Invalid bulk item');
1329 + }
1330 +
1331 + // SECURITY: apply the same per-item context-ownership and
1332 + // schema validation the single-item routes enforce, and carry
1333 + // the validators' NORMALIZED output forward to dispatch. The
1334 + // bulk path previously dispatched raw context_id / schema_data
1335 + // with no ownership (IDOR) or size/depth/type checks, and even
1336 + // after validating still passed the raw item fields on.
1337 + $item_context_type = isset($item['context_type']) ? (string) $item['context_type'] : '';
1338 + $item_context_id = isset($item['context_id']) ? (int) $item['context_id'] : null;
1339 +
1340 + // Sanitized values actually dispatched (default to the raw
1341 + // context for the validate operation, which has no context).
1342 + $context_type = $item_context_type;
1343 + $context_id = $item_context_id;
1344 +
1345 + if ($operation === 'generate' || $operation === 'deploy') {
1346 + $context_check = $this->input_validator->validate_context_parameters(
1347 + $item_context_type,
1348 + $item_context_id,
1349 + $user_id
1350 + );
1351 + if (!$context_check['valid']) {
1352 + throw new \Exception(implode(', ', $context_check['errors']));
1353 + }
1354 + // Use the sanitized context, matching the single routes.
1355 + $context_type = $context_check['sanitized_data']['context_type'];
1356 + $context_id = $context_check['sanitized_data']['context_id'];
1357 + }
1358 +
1359 + // Sanitize shared options once per item, as the single routes do.
1360 + $item_options = $this->input_validator->sanitize_options($options);
1361 +
1117 1362 switch ($operation) {
1118 1363 case 'generate':
1364 + // Sanitize schema types like the single generate route.
1365 + $raw_types = (isset($item['schema_types']) && is_array($item['schema_types']))
1366 + ? $item['schema_types']
1367 + : [];
1368 + $schema_types = [];
1369 + foreach ($raw_types as $type) {
1370 + $type = sanitize_text_field((string) $type);
1371 + if ($type !== '') {
1372 + $schema_types[] = $type;
1373 + }
1374 + }
1375 + if (empty($schema_types)) {
1376 + throw new \Exception('schema_types is required');
1377 + }
1119 1378 $result = $this->schema_manager->generate_schema_markup(
1120 - $item['context_type'],
1121 - $item['context_id'],
1122 - $item['schema_types'] ?? [],
1123 - $options
1379 + $context_type,
1380 + $context_id,
1381 + $schema_types,
1382 + $item_options
1124 1383 );
1125 1384 break;
1126 1385 case 'validate':
1386 + if (!isset($item['schema_data']) || !is_array($item['schema_data'])) {
1387 + throw new \Exception('schema_data is required');
1388 + }
1389 + $schema_type = '';
1390 + if (isset($item['schema_type']) && is_string($item['schema_type'])) {
1391 + $schema_type = sanitize_text_field($item['schema_type']);
1392 + } elseif (isset($item['schema_data']['@type']) && is_string($item['schema_data']['@type'])) {
1393 + $schema_type = sanitize_text_field($item['schema_data']['@type']);
1394 + }
1395 + $data_check = $this->input_validator->validate_schema_data($item['schema_data'], $schema_type);
1396 + if (!$data_check['valid']) {
1397 + throw new \Exception(implode(', ', $data_check['errors']));
1398 + }
1399 + // Validate the SANITIZED data, not the raw payload.
1127 1400 $result = $this->schema_manager->validate_schema_markup(
1128 - $item['schema_data'],
1129 - $item['schema_type'],
1130 - $options
1401 + $data_check['sanitized_data'],
1402 + $schema_type,
1403 + $item_options
1131 1404 );
1132 1405 break;
1133 1406 case 'deploy':
1407 + if (!isset($item['schema_data']) || !is_array($item['schema_data'])) {
1408 + throw new \Exception('schema_data is required');
1409 + }
1410 + // Mirror the single deploy route: validate EACH schema
1411 + // entry in the collection (type resolution + default
1412 + // @type/@context) and build a sanitized collection,
1413 + // rather than validating the whole map as one schema.
1414 + $sanitized_schema_data = [];
1415 + foreach ($item['schema_data'] as $schema_key => $schema_content) {
1416 + $schema_key = sanitize_text_field((string) $schema_key);
1417 + if (!is_array($schema_content)) {
1418 + throw new \Exception("Schema content for {$schema_key} must be an array");
1419 + }
1420 + $schema_type = isset($schema_content['@type'])
1421 + ? sanitize_text_field($schema_content['@type'])
1422 + : $schema_key;
1423 + if (!isset($schema_content['@type'])) {
1424 + $schema_content['@type'] = $schema_type;
1425 + }
1426 + if (!isset($schema_content['@context'])) {
1427 + $schema_content['@context'] = 'https://schema.org';
1428 + }
1429 + $data_check = $this->input_validator->validate_schema_data($schema_content, $schema_type);
1430 + if (!$data_check['valid']) {
1431 + throw new \Exception("Schema validation failed for {$schema_type}: " . implode(', ', $data_check['errors']));
1432 + }
1433 + $sanitized_schema_data[$schema_key] = $data_check['sanitized_data'];
1434 + }
1134 1435 $result = $this->schema_manager->deploy_schema_markup(
1135 - $item['context_type'],
1136 - $item['context_id'],
1137 - $item['schema_data'],
1138 - $options
1436 + $context_type,
1437 + $context_id,
1438 + $sanitized_schema_data,
1439 + $item_options
1139 1440 );
1140 1441 break;
1141 1442 default:
1142 1443 throw new \Exception("Unsupported operation: {$operation}");
@@ -1189,10 +1490,13 @@
1189 1490 * @param WP_REST_Request $request Request object
1190 1491 * @return bool Permission status
1191 1492 */
1192 1493 public function check_generate_permissions(WP_REST_Request $request): bool {
1193 - // Check user capability
1194 - if (!current_user_can('edit_posts')) {
1494 + // Gate on the Role Manager's schema capability, like the read and
1495 + // settings routes. Core post caps were both too loose in principle and
1496 + // too strict in practice: a role granted schema access but without
1497 + // publish_posts could not deploy (#457).
1498 + if (!\ThinkRank\Core\Capability_Manager::current_user_can('thinkrank_schema')) {
1195 1499 return false;
1196 1500 }
1197 1501
1198 1502 // SECURITY: Verify nonce for CSRF protection
@@ -1207,10 +1511,13 @@
1207 1511 * @param WP_REST_Request $request Request object
1208 1512 * @return bool Permission status
1209 1513 */
1210 1514 public function check_validate_permissions(WP_REST_Request $request): bool {
1211 - // Check user capability
1212 - if (!current_user_can('edit_posts')) {
1515 + // Gate on the Role Manager's schema capability, like the read and
1516 + // settings routes. Core post caps were both too loose in principle and
1517 + // too strict in practice: a role granted schema access but without
1518 + // publish_posts could not deploy (#457).
1519 + if (!\ThinkRank\Core\Capability_Manager::current_user_can('thinkrank_schema')) {
1213 1520 return false;
1214 1521 }
1215 1522
1216 1523 // SECURITY: Verify nonce for CSRF protection
@@ -1225,10 +1532,13 @@
1225 1532 * @param WP_REST_Request $request Request object
1226 1533 * @return bool Permission status
1227 1534 */
1228 1535 public function check_deploy_permissions(WP_REST_Request $request): bool {
1229 - // Check user capability
1230 - if (!current_user_can('publish_posts')) {
1536 + // Gate on the Role Manager's schema capability, like the read and
1537 + // settings routes. Core post caps were both too loose in principle and
1538 + // too strict in practice: a role granted schema access but without
1539 + // publish_posts could not deploy (#457).
1540 + if (!\ThinkRank\Core\Capability_Manager::current_user_can('thinkrank_schema')) {
1231 1541 return false;
1232 1542 }
1233 1543
1234 1544 // SECURITY: Verify nonce for CSRF protection
@@ -1243,10 +1553,11 @@
1243 1553 * @param WP_REST_Request $request Request object
1244 1554 * @return bool Permission status
1245 1555 */
1246 1556 public function check_read_permissions(WP_REST_Request $request): bool {
1247 - // Read operations only require basic capability
1248 - return current_user_can('read');
1557 + // Schema config + deployed JSON-LD are not subscriber-visible — require
1558 + // the same Schema management capability as the write routes.
1559 + return \ThinkRank\Core\Capability_Manager::current_user_can('thinkrank_schema');
1249 1560 }
1250 1561
1251 1562 /**
1252 1563 * Check permissions for schema optimization with CSRF protection
@@ -1256,10 +1567,13 @@
1256 1567 * @param WP_REST_Request $request Request object
1257 1568 * @return bool Permission status
1258 1569 */
1259 1570 public function check_optimize_permissions(WP_REST_Request $request): bool {
1260 - // Check user capability
1261 - if (!current_user_can('edit_posts')) {
1571 + // Gate on the Role Manager's schema capability, like the read and
1572 + // settings routes. Core post caps were both too loose in principle and
1573 + // too strict in practice: a role granted schema access but without
1574 + // publish_posts could not deploy (#457).
1575 + if (!\ThinkRank\Core\Capability_Manager::current_user_can('thinkrank_schema')) {
1262 1576 return false;
1263 1577 }
1264 1578
1265 1579 // SECURITY: Verify nonce for CSRF protection
@@ -1275,9 +1589,9 @@
1275 1589 * @return bool Permission status
1276 1590 */
1277 1591 public function check_bulk_permissions(WP_REST_Request $request): bool {
1278 1592 // Check user capability
1279 - if (!current_user_can('manage_options')) {
1593 + if (!\ThinkRank\Core\Capability_Manager::current_user_can('thinkrank_schema')) {
1280 1594 return false;
1281 1595 }
1282 1596
1283 1597 // SECURITY: Verify nonce for CSRF protection
@@ -1293,9 +1607,9 @@
1293 1607 * @return bool Permission status
1294 1608 */
1295 1609 public function check_manage_permissions(WP_REST_Request $request): bool {
1296 1610 // Check user capability
1297 - if (!current_user_can('manage_options')) {
1611 + if (!\ThinkRank\Core\Capability_Manager::current_user_can('thinkrank_schema')) {
1298 1612 return false;
1299 1613 }
1300 1614
1301 1615 // SECURITY: Verify nonce for CSRF protection (only for POST requests)
@@ -1309,61 +1623,12 @@
1309 1623 /**
1310 1624 * Helper methods
1311 1625 */
1312 1626
1313 - /**
1314 - * Verify request nonce for CSRF protection
1315 - *
1316 - * @since 1.0.0
1317 - *
1318 - * @param WP_REST_Request $request Request object
1319 - * @return bool Whether nonce is valid
1320 - */
1321 - private function verify_request_nonce(WP_REST_Request $request): bool {
1322 - // Get nonce from header (preferred method for REST API)
1323 - $nonce = $request->get_header('X-WP-Nonce');
1627 + // verify_request_nonce() now comes from the shared CSRF_Protection trait
1628 + // used by the other endpoints; the local copy was identical (#457).
1324 1629
1325 - // Fallback to parameter if header not present
1326 - if (!$nonce) {
1327 - $nonce = $request->get_param('_wpnonce');
1328 - }
1329 -
1330 - // Verify nonce
1331 - if (!$nonce || !wp_verify_nonce($nonce, 'wp_rest')) {
1332 - return false;
1333 - }
1334 -
1335 - return true;
1336 - }
1337 -
1338 1630 /**
1339 - * Validate context type and ID
1340 - *
1341 - * @since 1.0.0
1342 - *
1343 - * @param string $context_type Context type
1344 - * @param int|null $context_id Context ID
1345 - * @return bool Validation status
1346 - */
1347 - private function validate_context(string $context_type, ?int $context_id): bool {
1348 - $valid_types = ['site', 'post', 'page', 'product'];
1349 -
1350 - if (!in_array($context_type, $valid_types, true)) {
1351 - return false;
1352 - }
1353 -
1354 - if ($context_type !== 'site' && (!$context_id || $context_id <= 0)) {
1355 - return false;
1356 - }
1357 -
1358 - if ($context_id && !get_post($context_id)) {
1359 - return false;
1360 - }
1361 -
1362 - return true;
1363 - }
1364 -
1365 - /**
1366 1631 * Generate schema preview
1367 1632 *
1368 1633 * @since 1.0.0
1369 1634 *
@@ -1424,66 +1689,8 @@
1424 1689 'additional_info' => ''
1425 1690 ];
1426 1691 }
1427 1692 }
1428 -
1429 - /**
1430 - * Extract additional preview information
1431 - *
1432 - * @since 1.0.0
1433 - *
1434 - * @param array $schema_data Schema data
1435 - * @param string $schema_type Schema type
1436 - * @return array Additional information
1437 - */
1438 - private function extract_preview_info(array $schema_data, string $schema_type): array {
1439 - $info = [];
1440 -
1441 - switch ($schema_type) {
1442 - case 'Article':
1443 - if (isset($schema_data['author']['name'])) {
1444 - $info['author'] = $schema_data['author']['name'];
1445 - }
1446 - if (isset($schema_data['datePublished'])) {
1447 - $info['date'] = gmdate('M j, Y', strtotime($schema_data['datePublished']));
1448 - }
1449 - if (isset($schema_data['wordCount'])) {
1450 - $info['word_count'] = $schema_data['wordCount'];
1451 - }
1452 - break;
1453 - case 'Product':
1454 - if (isset($schema_data['offers']['price'])) {
1455 - $currency = $schema_data['offers']['priceCurrency'] ?? '';
1456 - $info['price'] = $currency . $schema_data['offers']['price'];
1457 - }
1458 - if (isset($schema_data['brand']['name'])) {
1459 - $info['brand'] = $schema_data['brand']['name'];
1460 - }
1461 - if (isset($schema_data['offers']['availability'])) {
1462 - $info['availability'] = str_replace('https://schema.org/', '', $schema_data['offers']['availability']);
1463 - }
1464 - break;
1465 - case 'LocalBusiness':
1466 - if (isset($schema_data['address']['addressLocality'])) {
1467 - $info['location'] = $schema_data['address']['addressLocality'];
1468 - }
1469 - if (isset($schema_data['telephone'])) {
1470 - $info['phone'] = $schema_data['telephone'];
1471 - }
1472 - break;
1473 - }
1474 -
1475 - return $info;
1476 - }
1477 -
1478 - /**
1479 - * Format organization additional info
1480 - *
1481 - * @since 1.0.0
1482 - *
1483 - * @param array $schema_data Schema data
1484 - * @return string Formatted info
1485 - */
1486 1693 private function format_organization_info(array $schema_data): string {
1487 1694 $info = [];
1488 1695
1489 1696 if (!empty($schema_data['contactPoint']['telephone'])) {
@@ -1596,10 +1803,13 @@
1596 1803 'minimum' => 1,
1597 1804 'description' => 'Context ID (not required for site context)'
1598 1805 ],
1599 1806 'schema_types' => [
1600 - 'required' => false,
1807 + // generate_schema() rejects a missing or empty value with a 400,
1808 + // so the schema has to say so too.
1809 + 'required' => true,
1601 1810 'type' => 'array',
1811 + 'minItems' => 1,
1602 1812 'items' => [
1603 1813 'type' => 'string',
1604 1814 'enum' => [
1605 1815 'Article', 'BlogPosting', 'TechnicalArticle', 'NewsArticle',
@@ -1604,9 +1814,9 @@
1604 1814 'enum' => [
1605 1815 'Article', 'BlogPosting', 'TechnicalArticle', 'NewsArticle',
1606 1816 'ScholarlyArticle', 'Report', 'Product', 'Organization',
1607 1817 'LocalBusiness', 'Person', 'WebSite', 'FAQPage',
1608 - 'Event', 'HowTo', 'SoftwareApplication'
1818 + 'Event', 'HowTo', 'SoftwareApplication', 'Review', 'VideoObject'
1609 1819 ]
1610 1820 ],
1611 1821 'description' => 'Schema types to generate'
1612 1822 ],
@@ -1651,9 +1861,9 @@
1651 1861 'enum' => [
1652 1862 'Article', 'BlogPosting', 'TechnicalArticle', 'NewsArticle',
1653 1863 'ScholarlyArticle', 'Report', 'Product', 'Organization',
1654 1864 'LocalBusiness', 'Person', 'WebSite', 'WebPage', 'FAQPage',
1655 - 'SoftwareApplication', 'Event', 'Recipe', 'HowTo'
1865 + 'SoftwareApplication', 'Event', 'Recipe', 'HowTo', 'Review', 'VideoObject'
1656 1866 ],
1657 1867 'description' => 'Schema type'
1658 1868 ],
1659 1869 'options' => [
@@ -1717,9 +1927,9 @@
1717 1927 'type' => 'string',
1718 1928 'enum' => [
1719 1929 'Article', 'BlogPosting', 'Product', 'Organization', 'LocalBusiness',
1720 1930 'Person', 'WebSite', 'WebPage', 'FAQPage', 'SoftwareApplication',
1721 - 'BreadcrumbList', 'Event', 'Recipe', 'HowTo'
1931 + 'BreadcrumbList', 'Event', 'Recipe', 'HowTo', 'Review', 'VideoObject'
1722 1932 ],
1723 1933 'description' => 'Schema type'
1724 1934 ],
1725 1935 'options' => [
@@ -1749,9 +1959,9 @@
1749 1959 'type' => 'string',
1750 1960 'enum' => [
1751 1961 'Article', 'BlogPosting', 'Product', 'Organization', 'LocalBusiness',
1752 1962 'Person', 'WebSite', 'WebPage', 'FAQPage', 'SoftwareApplication',
1753 - 'BreadcrumbList', 'Event', 'Recipe', 'HowTo'
1963 + 'BreadcrumbList', 'Event', 'Recipe', 'HowTo', 'Review', 'VideoObject'
1754 1964 ],
1755 1965 'description' => 'Schema type'
1756 1966 ]
1757 1967 ];
@@ -1777,9 +1987,13 @@
1777 1987 'type' => 'array',
1778 1988 'items' => [
1779 1989 'type' => 'object'
1780 1990 ],
1781 - 'description' => 'Items to process in bulk'
1991 + // Bound aggregate request work: every item can trigger context
1992 + // lookups, recursive schema validation, generation, and
1993 + // deployment, so cap the count at the REST layer.
1994 + 'maxItems' => self::MAX_BULK_ITEMS,
1995 + 'description' => 'Items to process in bulk (max ' . self::MAX_BULK_ITEMS . ')'
1782 1996 ],
1783 1997 'options' => [
1784 1998 'required' => false,
1785 1999 'type' => 'object',
@@ -1797,10 +2011,15 @@
1797 2011 * @return WP_REST_Response|WP_Error Response object or error
1798 2012 */
1799 2013 public function get_settings(WP_REST_Request $request) {
1800 2014 try {
1801 - $context_type = $request->get_param('context_type') ?? 'site';
1802 - $context_id = $request->get_param('context_id') ?? null;
2015 + // SECURITY: the settings this returns are per-object. save_settings()
2016 + // already authorises the object; the read has to as well (#385).
2017 + $context = $this->resolve_request_context($request);
2018 + if (is_wp_error($context)) {
2019 + return $context;
2020 + }
2021 + [$context_type, $context_id] = $context;
1803 2022
1804 2023 // Get settings from schema manager
1805 2024 $settings = $this->schema_manager->get_settings($context_type, $context_id);
1806 2025
@@ -1845,8 +2064,48 @@
1845 2064 ['status' => 400]
1846 2065 );
1847 2066 }
1848 2067
2068 + // SECURITY: For non-site contexts (post/page/product), verify the
2069 + // caller can edit that specific object — same ownership gate the
2070 + // generate/deploy routes use. Site context stays governed by the
2071 + // thinkrank_schema capability via the Role Manager gate.
2072 + $context_type = sanitize_key((string) $context_type);
2073 + if ($context_type !== 'site') {
2074 + $context_validation = $this->input_validator->validate_context_parameters(
2075 + $context_type,
2076 + $context_id !== null ? absint($context_id) : null,
2077 + get_current_user_id()
2078 + );
2079 + if (!$context_validation['valid']) {
2080 + return new WP_Error(
2081 + 'invalid_context',
2082 + implode(', ', $context_validation['errors']),
2083 + ['status' => 403]
2084 + );
2085 + }
2086 + $context_type = $context_validation['sanitized_data']['context_type'];
2087 + $context_id = $context_validation['sanitized_data']['context_id'];
2088 + } else {
2089 + // Site settings are keyed on a NULL context_id. Passing the
2090 + // client's value straight through meant a stray context_id
2091 + // wrote a row at an arbitrary id, returned 200, and was never
2092 + // read back by anything (#470). validate_context_parameters()
2093 + // already normalises this internally for other contexts.
2094 + $context_id = null;
2095 + }
2096 +
2097 + // Drop unrecognized keys so arbitrary client-supplied keys aren't
2098 + // persisted as settings rows (storage bloat / settings drift).
2099 + $settings = $this->filter_known_setting_keys($settings, $context_type);
2100 + if (empty($settings)) {
2101 + return new WP_Error(
2102 + 'invalid_settings',
2103 + 'No recognized schema settings were provided',
2104 + ['status' => 400]
2105 + );
2106 + }
2107 +
1849 2108 // Get validation results for detailed error reporting
1850 2109 $validation = $this->schema_manager->validate_settings($settings);
1851 2110
1852 2111 if (!$validation['valid']) {
@@ -1896,8 +2155,50 @@
1896 2155 'Failed to update schema settings: ' . $e->getMessage(),
1897 2156 ['status' => 500]
1898 2157 );
1899 2158 }
2159 + }
2160 +
2161 + /**
2162 + * Restrict a settings payload to recognized keys.
2163 + *
2164 + * The known set is the context's default settings plus a few keys that are
2165 + * legitimately stored/consumed elsewhere (site-identity/local-SEO fields and
2166 + * the schema settings schema) but not seeded into the defaults. Filterable
2167 + * so Pro/integrations can register additional keys.
2168 + *
2169 + * @param array $settings Incoming settings.
2170 + * @param string $context_type Context type (site/post/page/product).
2171 + * @return array Settings limited to known keys.
2172 + */
2173 + private function filter_known_setting_keys(array $settings, string $context_type): array {
2174 + // Defer to the manager instead of maintaining a parallel list here.
2175 + // The endpoint's own list ignored additional_setting_keys() and
2176 + // dynamic_setting_key_patterns() — the mechanism #452 added so new form
2177 + // families stop getting dropped — so the two disagreed in both
2178 + // directions: the four enable_*_schema toggles and the software_/howto_/
2179 + // product_ families were dropped here but accepted by the manager, while
2180 + // deployment_method, site_name and performance_tracking survived here
2181 + // only to be dropped one layer down (#470).
2182 + $known = [];
2183 +
2184 + foreach (array_keys($settings) as $key) {
2185 + if ($this->schema_manager->accepts_setting_key((string) $key, $context_type)) {
2186 + $known[] = (string) $key;
2187 + }
2188 + }
2189 +
2190 + /**
2191 + * Filter the schema setting keys the REST endpoint will persist.
2192 + *
2193 + * @since 1.13.0
2194 + *
2195 + * @param string[] $known Keys accepted by the schema manager.
2196 + * @param string $context_type Context type.
2197 + */
2198 + $known = apply_filters('thinkrank_schema_known_setting_keys', $known, $context_type);
2199 +
2200 + return array_intersect_key($settings, array_flip($known));
1900 2201 }
1901 2202
1902 2203 /**
1903 2204 * Get arguments for settings endpoints