| @@ -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 |