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