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 +626 -195 1.0.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'],
@@ -269,11 +288,255 @@
269 288 'args' => $this->get_settings_args()
270 289 ]
271 290 ]
272 291 );
292 +
293 + // Import schema from URL
294 + register_rest_route(
295 + $this->namespace,
296 + '/' . $this->rest_base . '/import',
297 + [
298 + [
299 + 'methods' => 'POST',
300 + 'callback' => [$this, 'import_schema_from_url'],
301 + 'permission_callback' => [$this, 'check_manage_permissions'],
302 + 'args' => [
303 + 'url' => [
304 + 'required' => true,
305 + 'type' => 'string',
306 + 'format' => 'uri'
307 + ]
308 + ]
309 + ]
310 + ]
311 + );
273 312 }
274 313
275 314 /**
315 + * Import schema from URL
316 + *
317 + * @since 1.0.0
318 + *
319 + * @param WP_REST_Request $request Request object
320 + * @return WP_REST_Response|WP_Error Response object or error
321 + */
322 + public function import_schema_from_url(WP_REST_Request $request) {
323 + try {
324 + $url = esc_url_raw($request->get_param('url'));
325 +
326 + if (empty($url)) {
327 + return new WP_Error(
328 + 'invalid_url',
329 + 'A valid URL is required',
330 + ['status' => 400]
331 + );
332 + }
333 +
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);
341 +
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 + }
349 + return new WP_Error(
350 + 'fetch_failed',
351 + 'Failed to fetch data from URL: ' . $response->get_error_message(),
352 + ['status' => 500]
353 + );
354 + }
355 +
356 + $response_code = wp_remote_retrieve_response_code($response);
357 + if ($response_code !== 200) {
358 + return new WP_Error(
359 + 'fetch_error',
360 + 'Failed to fetch data from URL (HTTP ' . $response_code . ')',
361 + ['status' => 400]
362 + );
363 + }
364 +
365 + $body = wp_remote_retrieve_body($response);
366 +
367 + if (empty($body)) {
368 + return new WP_Error(
369 + 'empty_response',
370 + 'Returned content is empty',
371 + ['status' => 400]
372 + );
373 + }
374 +
375 + // Suppress DOM errors for malformed HTML
376 + libxml_use_internal_errors(true);
377 +
378 + $dom = new \DOMDocument();
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);
383 +
384 + libxml_clear_errors();
385 +
386 + $xpath = new \DOMXPath($dom);
387 + $scripts = $xpath->query('//script[@type="application/ld+json"]');
388 +
389 + $found_schemas = [];
390 +
391 + if ($scripts->length > 0) {
392 + foreach ($scripts as $script) {
393 + $json = trim($script->nodeValue);
394 + $data = json_decode($json, true);
395 +
396 + if (json_last_error() === JSON_ERROR_NONE && !empty($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 + }
409 + }
410 + }
411 + }
412 +
413 + if (empty($found_schemas)) {
414 + return new WP_Error(
415 + 'no_schema_found',
416 + 'No valid JSON-LD schema markup found on this page',
417 + ['status' => 404]
418 + );
419 + }
420 +
421 + return new WP_REST_Response([
422 + 'success' => true,
423 + 'data' => $found_schemas[0],
424 + 'all_found' => $found_schemas,
425 + 'message' => 'Schema imported successfully'
426 + ], 200);
427 +
428 + } catch (\Exception $e) {
429 + return new WP_Error(
430 + 'import_failed',
431 + 'Schema import failed: ' . $e->getMessage(),
432 + ['status' => 500]
433 + );
434 + }
435 + }
436 +
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 + /**
276 539 * Generate schema markup
277 540 *
278 541 * @since 1.0.0
279 542 *
@@ -365,23 +628,10 @@
365 628
366 629 // SECURITY: Sanitize schema_form_data if provided
367 630 $schema_form_data = $request->get_param('schema_form_data');
368 631 if ($schema_form_data && is_array($schema_form_data)) {
369 - // Sanitize all form fields
370 - $sanitized_form_data = [];
371 - foreach ($schema_form_data as $key => $value) {
372 - if (is_string($value)) {
373 - $sanitized_form_data[sanitize_key($key)] = sanitize_text_field($value);
374 - } elseif (is_array($value)) {
375 - // Handle array values (like features, steps, etc.)
376 - $sanitized_form_data[sanitize_key($key)] = array_map('sanitize_text_field', $value);
377 - } elseif (is_numeric($value)) {
378 - $sanitized_form_data[sanitize_key($key)] = floatval($value);
379 - }
380 - }
381 -
382 632 // Add schema_form_data to options so schema manager can use it
383 - $options['schema_form_data'] = $sanitized_form_data;
633 + $options['schema_form_data'] = $this->sanitize_schema_form_data($schema_form_data);
384 634 }
385 635
386 636 // Generate schema markup with sanitized inputs
387 637 $generation_results = $this->schema_manager->generate_schema_markup(
@@ -543,20 +793,35 @@
543 793 }
544 794
545 795 // SECURITY: Validate each schema in the data
546 796 $sanitized_schema_data = [];
547 - foreach ($schema_data as $schema_type => $schema_content) {
548 - $schema_type = sanitize_text_field($schema_type);
797 + $skipped_schemas = [];
798 + foreach ($schema_data as $schema_key => $schema_content) {
799 + $schema_key = sanitize_text_field($schema_key);
549 800
550 801 if (!is_array($schema_content)) {
551 802 return new WP_Error(
552 803 'invalid_schema_content',
553 - "Schema content for {$schema_type} must be an array",
804 + "Schema content for {$schema_key} must be an array",
554 805 ['status' => 400]
555 806 );
556 807 }
557 808
558 809 // Ensure schema has required structure fields before validation
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 +
559 824 if (!isset($schema_content['@type'])) {
560 825 $schema_content['@type'] = $schema_type;
561 826 }
562 827 if (!isset($schema_content['@context'])) {
@@ -562,27 +827,52 @@
562 827 if (!isset($schema_content['@context'])) {
563 828 $schema_content['@context'] = 'https://schema.org';
564 829 }
565 830
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).
566 835 $input_validation = $this->input_validator->validate_schema_data($schema_content, $schema_type);
836 +
567 837 if (!$input_validation['valid']) {
568 - return new WP_Error(
569 - 'schema_validation_failed',
570 - "Schema validation failed for {$schema_type}: " . implode(', ', $input_validation['errors']),
571 - [
572 - 'status' => 400,
573 - 'schema_type' => $schema_type,
574 - 'validation_errors' => $input_validation['errors']
575 - ]
576 - );
838 + $skipped_schemas[] = [
839 + 'key' => $schema_key,
840 + 'type' => $schema_type,
841 + 'errors' => $input_validation['errors'],
842 + ];
843 + continue;
577 844 }
578 845
579 - $sanitized_schema_data[$schema_type] = $input_validation['sanitized_data'];
846 + // Store using the key (which may be unique like "Article-1")
847 + $sanitized_schema_data[$schema_key] = $input_validation['sanitized_data'];
580 848 }
581 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 +
582 867 // SECURITY: Sanitize options
583 868 $options = $this->input_validator->sanitize_options($request->get_param('options') ?? []);
584 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 +
585 875 // Deploy schema markup with sanitized data
586 876 $deployment_results = $this->schema_manager->deploy_schema_markup(
587 877 $context_type,
588 878 $context_id,
@@ -589,14 +879,28 @@
589 879 $sanitized_schema_data,
590 880 $options
591 881 );
592 882
593 - return new WP_REST_Response([
883 + $response = [
594 884 'success' => true,
595 885 'data' => $deployment_results,
596 886 'message' => 'Schema markup deployed successfully'
597 - ], 200);
887 + ];
598 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 +
599 903 } catch (\Exception $e) {
600 904 return new WP_Error(
601 905 'deployment_failed',
602 906 'Schema deployment failed: ' . $e->getMessage(),
@@ -734,17 +1038,17 @@
734 1038 * @return WP_REST_Response|WP_Error Response object or error
735 1039 */
736 1040 public function get_deployed_schemas(WP_REST_Request $request) {
737 1041 try {
738 - $context_type = $request->get_param('context_type') ?? 'site';
739 - $context_id = $request->get_param('context_id');
740 -
741 - // Convert context_id to int if it's a valid numeric string, otherwise null
742 - if ($context_id !== null && is_numeric($context_id)) {
743 - $context_id = (int) $context_id;
744 - } else {
745 - $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;
746 1049 }
1050 + [$context_type, $context_id] = $context;
747 1051
748 1052 $deployed_schemas = $this->schema_manager->get_deployed_schemas($context_type, $context_id);
749 1053
750 1054 return new WP_REST_Response([
@@ -774,15 +1078,13 @@
774 1078 try {
775 1079 $context_type = $request->get_param('context_type');
776 1080 $context_id = (int) $request->get_param('context_id');
777 1081
778 - // Validate context
779 - if (!$this->validate_context($context_type, $context_id)) {
780 - return new WP_Error(
781 - 'invalid_context',
782 - 'Invalid context type or ID provided',
783 - ['status' => 400]
784 - );
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;
785 1087 }
786 1088
787 1089 // Get schema output data
788 1090 $schema_data = $this->schema_manager->get_output_data($context_type, $context_id);
@@ -836,11 +1138,38 @@
836 1138 ['status' => 400]
837 1139 );
838 1140 }
839 1141
840 - // 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
841 1170 $optimization_results = $this->schema_manager->optimize_rich_snippets(
842 - $schema_data,
1171 + $input_validation['sanitized_data'],
843 1172 $schema_type,
844 1173 $options
845 1174 );
846 1175
@@ -872,15 +1201,13 @@
872 1201 $context_type = $request->get_param('context_type');
873 1202 $context_id = (int) $request->get_param('context_id');
874 1203 $options = $request->get_param('options') ?? [];
875 1204
876 - // Validate context
877 - if (!$this->validate_context($context_type, $context_id)) {
878 - return new WP_Error(
879 - 'invalid_context',
880 - 'Invalid context type or ID provided',
881 - ['status' => 400]
882 - );
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;
883 1210 }
884 1211
885 1212 // Track schema performance
886 1213 $performance_data = $this->schema_manager->track_schema_performance(
@@ -950,8 +1277,10 @@
950 1277 * @since 1.0.0
951 1278 *
952 1279 * @param WP_REST_Request $request Request object
953 1280 * @return WP_REST_Response|WP_Error Response object or error
1281 + *
1282 + * @throws \Exception On failure.
954 1283 */
955 1284 public function bulk_operations(WP_REST_Request $request) {
956 1285 try {
957 1286 $user_id = get_current_user_id();
@@ -978,35 +1307,137 @@
978 1307 ['status' => 400]
979 1308 );
980 1309 }
981 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 +
982 1322 $results = [];
983 1323 $errors = [];
984 1324
985 1325 foreach ($items as $item) {
986 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 +
987 1362 switch ($operation) {
988 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 + }
989 1378 $result = $this->schema_manager->generate_schema_markup(
990 - $item['context_type'],
991 - $item['context_id'],
992 - $item['schema_types'] ?? [],
993 - $options
1379 + $context_type,
1380 + $context_id,
1381 + $schema_types,
1382 + $item_options
994 1383 );
995 1384 break;
996 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.
997 1400 $result = $this->schema_manager->validate_schema_markup(
998 - $item['schema_data'],
999 - $item['schema_type'],
1000 - $options
1401 + $data_check['sanitized_data'],
1402 + $schema_type,
1403 + $item_options
1001 1404 );
1002 1405 break;
1003 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 + }
1004 1435 $result = $this->schema_manager->deploy_schema_markup(
1005 - $item['context_type'],
1006 - $item['context_id'],
1007 - $item['schema_data'],
1008 - $options
1436 + $context_type,
1437 + $context_id,
1438 + $sanitized_schema_data,
1439 + $item_options
1009 1440 );
1010 1441 break;
1011 1442 default:
1012 1443 throw new \Exception("Unsupported operation: {$operation}");
@@ -1059,10 +1490,13 @@
1059 1490 * @param WP_REST_Request $request Request object
1060 1491 * @return bool Permission status
1061 1492 */
1062 1493 public function check_generate_permissions(WP_REST_Request $request): bool {
1063 - // Check user capability
1064 - 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')) {
1065 1499 return false;
1066 1500 }
1067 1501
1068 1502 // SECURITY: Verify nonce for CSRF protection
@@ -1077,10 +1511,13 @@
1077 1511 * @param WP_REST_Request $request Request object
1078 1512 * @return bool Permission status
1079 1513 */
1080 1514 public function check_validate_permissions(WP_REST_Request $request): bool {
1081 - // Check user capability
1082 - 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')) {
1083 1520 return false;
1084 1521 }
1085 1522
1086 1523 // SECURITY: Verify nonce for CSRF protection
@@ -1095,10 +1532,13 @@
1095 1532 * @param WP_REST_Request $request Request object
1096 1533 * @return bool Permission status
1097 1534 */
1098 1535 public function check_deploy_permissions(WP_REST_Request $request): bool {
1099 - // Check user capability
1100 - 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')) {
1101 1541 return false;
1102 1542 }
1103 1543
1104 1544 // SECURITY: Verify nonce for CSRF protection
@@ -1113,10 +1553,11 @@
1113 1553 * @param WP_REST_Request $request Request object
1114 1554 * @return bool Permission status
1115 1555 */
1116 1556 public function check_read_permissions(WP_REST_Request $request): bool {
1117 - // Read operations only require basic capability
1118 - 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');
1119 1560 }
1120 1561
1121 1562 /**
1122 1563 * Check permissions for schema optimization with CSRF protection
@@ -1126,10 +1567,13 @@
1126 1567 * @param WP_REST_Request $request Request object
1127 1568 * @return bool Permission status
1128 1569 */
1129 1570 public function check_optimize_permissions(WP_REST_Request $request): bool {
1130 - // Check user capability
1131 - 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')) {
1132 1576 return false;
1133 1577 }
1134 1578
1135 1579 // SECURITY: Verify nonce for CSRF protection
@@ -1145,9 +1589,9 @@
1145 1589 * @return bool Permission status
1146 1590 */
1147 1591 public function check_bulk_permissions(WP_REST_Request $request): bool {
1148 1592 // Check user capability
1149 - if (!current_user_can('manage_options')) {
1593 + if (!\ThinkRank\Core\Capability_Manager::current_user_can('thinkrank_schema')) {
1150 1594 return false;
1151 1595 }
1152 1596
1153 1597 // SECURITY: Verify nonce for CSRF protection
@@ -1163,9 +1607,9 @@
1163 1607 * @return bool Permission status
1164 1608 */
1165 1609 public function check_manage_permissions(WP_REST_Request $request): bool {
1166 1610 // Check user capability
1167 - if (!current_user_can('manage_options')) {
1611 + if (!\ThinkRank\Core\Capability_Manager::current_user_can('thinkrank_schema')) {
1168 1612 return false;
1169 1613 }
1170 1614
1171 1615 // SECURITY: Verify nonce for CSRF protection (only for POST requests)
@@ -1179,61 +1623,12 @@
1179 1623 /**
1180 1624 * Helper methods
1181 1625 */
1182 1626
1183 - /**
1184 - * Verify request nonce for CSRF protection
1185 - *
1186 - * @since 1.0.0
1187 - *
1188 - * @param WP_REST_Request $request Request object
1189 - * @return bool Whether nonce is valid
1190 - */
1191 - private function verify_request_nonce(WP_REST_Request $request): bool {
1192 - // Get nonce from header (preferred method for REST API)
1193 - $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).
1194 1629
1195 - // Fallback to parameter if header not present
1196 - if (!$nonce) {
1197 - $nonce = $request->get_param('_wpnonce');
1198 - }
1199 -
1200 - // Verify nonce
1201 - if (!$nonce || !wp_verify_nonce($nonce, 'wp_rest')) {
1202 - return false;
1203 - }
1204 -
1205 - return true;
1206 - }
1207 -
1208 1630 /**
1209 - * Validate context type and ID
1210 - *
1211 - * @since 1.0.0
1212 - *
1213 - * @param string $context_type Context type
1214 - * @param int|null $context_id Context ID
1215 - * @return bool Validation status
1216 - */
1217 - private function validate_context(string $context_type, ?int $context_id): bool {
1218 - $valid_types = ['site', 'post', 'page', 'product'];
1219 -
1220 - if (!in_array($context_type, $valid_types, true)) {
1221 - return false;
1222 - }
1223 -
1224 - if ($context_type !== 'site' && (!$context_id || $context_id <= 0)) {
1225 - return false;
1226 - }
1227 -
1228 - if ($context_id && !get_post($context_id)) {
1229 - return false;
1230 - }
1231 -
1232 - return true;
1233 - }
1234 -
1235 - /**
1236 1631 * Generate schema preview
1237 1632 *
1238 1633 * @since 1.0.0
1239 1634 *
@@ -1294,66 +1689,8 @@
1294 1689 'additional_info' => ''
1295 1690 ];
1296 1691 }
1297 1692 }
1298 -
1299 - /**
1300 - * Extract additional preview information
1301 - *
1302 - * @since 1.0.0
1303 - *
1304 - * @param array $schema_data Schema data
1305 - * @param string $schema_type Schema type
1306 - * @return array Additional information
1307 - */
1308 - private function extract_preview_info(array $schema_data, string $schema_type): array {
1309 - $info = [];
1310 -
1311 - switch ($schema_type) {
1312 - case 'Article':
1313 - if (isset($schema_data['author']['name'])) {
1314 - $info['author'] = $schema_data['author']['name'];
1315 - }
1316 - if (isset($schema_data['datePublished'])) {
1317 - $info['date'] = gmdate('M j, Y', strtotime($schema_data['datePublished']));
1318 - }
1319 - if (isset($schema_data['wordCount'])) {
1320 - $info['word_count'] = $schema_data['wordCount'];
1321 - }
1322 - break;
1323 - case 'Product':
1324 - if (isset($schema_data['offers']['price'])) {
1325 - $currency = $schema_data['offers']['priceCurrency'] ?? '';
1326 - $info['price'] = $currency . $schema_data['offers']['price'];
1327 - }
1328 - if (isset($schema_data['brand']['name'])) {
1329 - $info['brand'] = $schema_data['brand']['name'];
1330 - }
1331 - if (isset($schema_data['offers']['availability'])) {
1332 - $info['availability'] = str_replace('https://schema.org/', '', $schema_data['offers']['availability']);
1333 - }
1334 - break;
1335 - case 'LocalBusiness':
1336 - if (isset($schema_data['address']['addressLocality'])) {
1337 - $info['location'] = $schema_data['address']['addressLocality'];
1338 - }
1339 - if (isset($schema_data['telephone'])) {
1340 - $info['phone'] = $schema_data['telephone'];
1341 - }
1342 - break;
1343 - }
1344 -
1345 - return $info;
1346 - }
1347 -
1348 - /**
1349 - * Format organization additional info
1350 - *
1351 - * @since 1.0.0
1352 - *
1353 - * @param array $schema_data Schema data
1354 - * @return string Formatted info
1355 - */
1356 1693 private function format_organization_info(array $schema_data): string {
1357 1694 $info = [];
1358 1695
1359 1696 if (!empty($schema_data['contactPoint']['telephone'])) {
@@ -1466,10 +1803,13 @@
1466 1803 'minimum' => 1,
1467 1804 'description' => 'Context ID (not required for site context)'
1468 1805 ],
1469 1806 'schema_types' => [
1470 - '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,
1471 1810 'type' => 'array',
1811 + 'minItems' => 1,
1472 1812 'items' => [
1473 1813 'type' => 'string',
1474 1814 'enum' => [
1475 1815 'Article', 'BlogPosting', 'TechnicalArticle', 'NewsArticle',
@@ -1474,9 +1814,9 @@
1474 1814 'enum' => [
1475 1815 'Article', 'BlogPosting', 'TechnicalArticle', 'NewsArticle',
1476 1816 'ScholarlyArticle', 'Report', 'Product', 'Organization',
1477 1817 'LocalBusiness', 'Person', 'WebSite', 'FAQPage',
1478 - 'Event', 'HowTo', 'SoftwareApplication'
1818 + 'Event', 'HowTo', 'SoftwareApplication', 'Review', 'VideoObject'
1479 1819 ]
1480 1820 ],
1481 1821 'description' => 'Schema types to generate'
1482 1822 ],
@@ -1521,9 +1861,9 @@
1521 1861 'enum' => [
1522 1862 'Article', 'BlogPosting', 'TechnicalArticle', 'NewsArticle',
1523 1863 'ScholarlyArticle', 'Report', 'Product', 'Organization',
1524 1864 'LocalBusiness', 'Person', 'WebSite', 'WebPage', 'FAQPage',
1525 - 'SoftwareApplication', 'Event', 'Recipe', 'HowTo'
1865 + 'SoftwareApplication', 'Event', 'Recipe', 'HowTo', 'Review', 'VideoObject'
1526 1866 ],
1527 1867 'description' => 'Schema type'
1528 1868 ],
1529 1869 'options' => [
@@ -1587,9 +1927,9 @@
1587 1927 'type' => 'string',
1588 1928 'enum' => [
1589 1929 'Article', 'BlogPosting', 'Product', 'Organization', 'LocalBusiness',
1590 1930 'Person', 'WebSite', 'WebPage', 'FAQPage', 'SoftwareApplication',
1591 - 'BreadcrumbList', 'Event', 'Recipe', 'HowTo'
1931 + 'BreadcrumbList', 'Event', 'Recipe', 'HowTo', 'Review', 'VideoObject'
1592 1932 ],
1593 1933 'description' => 'Schema type'
1594 1934 ],
1595 1935 'options' => [
@@ -1619,9 +1959,9 @@
1619 1959 'type' => 'string',
1620 1960 'enum' => [
1621 1961 'Article', 'BlogPosting', 'Product', 'Organization', 'LocalBusiness',
1622 1962 'Person', 'WebSite', 'WebPage', 'FAQPage', 'SoftwareApplication',
1623 - 'BreadcrumbList', 'Event', 'Recipe', 'HowTo'
1963 + 'BreadcrumbList', 'Event', 'Recipe', 'HowTo', 'Review', 'VideoObject'
1624 1964 ],
1625 1965 'description' => 'Schema type'
1626 1966 ]
1627 1967 ];
@@ -1647,9 +1987,13 @@
1647 1987 'type' => 'array',
1648 1988 'items' => [
1649 1989 'type' => 'object'
1650 1990 ],
1651 - '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 . ')'
1652 1996 ],
1653 1997 'options' => [
1654 1998 'required' => false,
1655 1999 'type' => 'object',
@@ -1667,10 +2011,15 @@
1667 2011 * @return WP_REST_Response|WP_Error Response object or error
1668 2012 */
1669 2013 public function get_settings(WP_REST_Request $request) {
1670 2014 try {
1671 - $context_type = $request->get_param('context_type') ?? 'site';
1672 - $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;
1673 2022
1674 2023 // Get settings from schema manager
1675 2024 $settings = $this->schema_manager->get_settings($context_type, $context_id);
1676 2025
@@ -1715,8 +2064,48 @@
1715 2064 ['status' => 400]
1716 2065 );
1717 2066 }
1718 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 +
1719 2108 // Get validation results for detailed error reporting
1720 2109 $validation = $this->schema_manager->validate_settings($settings);
1721 2110
1722 2111 if (!$validation['valid']) {
@@ -1766,8 +2155,50 @@
1766 2155 'Failed to update schema settings: ' . $e->getMessage(),
1767 2156 ['status' => 500]
1768 2157 );
1769 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));
1770 2201 }
1771 2202
1772 2203 /**
1773 2204 * Get arguments for settings endpoints