PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.14.3
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.14.3
2.14.3 2.14.2 2.14.1 2.14.0 2.13.0 2.12.0 2.11.0 2.10.0 2.9.0 2.8.0 2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 All 58 releases
thinkrank / includes / seo / class-schema-input-validator.php

class-schema-input-validator.php in ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO 2.14.3, at includes/seo/class-schema-input-validator.php

1,112 lines 41.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Schema Input Validator Class
4 *
5 * Provides comprehensive input validation and sanitization for schema data
6 * including JSON schema validation, XSS protection, and data integrity checks.
7 *
8 * @package ThinkRank\SEO
9 * @since 1.0.0
10 */
11
12 declare(strict_types=1);
13
14 namespace ThinkRank\SEO;
15
16 // Prevent direct access
17 if (!defined('ABSPATH')) {
18 exit;
19 }
20
21 /**
22 * Schema Input Validator Class
23 *
24 * Handles validation and sanitization of all schema-related inputs
25 * with comprehensive security measures and data integrity checks.
26 *
27 * @since 1.0.0
28 */
29 class Schema_Input_Validator {
30
31 /**
32 * Allowed schema types with their validation rules
33 *
34 * @since 1.0.0
35 * @var array
36 */
37 private array $allowed_schema_types = [
38 'Article' => [
39 'required_fields' => ['@type', 'headline', 'author'],
40 'optional_fields' => ['description', 'datePublished', 'dateModified', 'image', 'url'],
41 'max_length' => ['headline' => 110, 'description' => 160]
42 ],
43 'BlogPosting' => [
44 'required_fields' => ['@type', 'headline', 'author'],
45 'optional_fields' => ['description', 'datePublished', 'dateModified', 'image', 'url'],
46 'max_length' => ['headline' => 110, 'description' => 160]
47 ],
48 'TechnicalArticle' => [
49 'required_fields' => ['@type', 'headline', 'author'],
50 'optional_fields' => ['description', 'datePublished', 'dateModified', 'image', 'url', 'dependencies', 'proficiencyLevel'],
51 'max_length' => ['headline' => 110, 'description' => 160]
52 ],
53 'NewsArticle' => [
54 'required_fields' => ['@type', 'headline', 'author'],
55 'optional_fields' => ['description', 'datePublished', 'dateModified', 'image', 'url', 'dateline'],
56 'max_length' => ['headline' => 110, 'description' => 160]
57 ],
58 'ScholarlyArticle' => [
59 'required_fields' => ['@type', 'headline', 'author'],
60 'optional_fields' => ['description', 'datePublished', 'dateModified', 'image', 'url', 'citation', 'abstract'],
61 'max_length' => ['headline' => 110, 'description' => 160]
62 ],
63 'Report' => [
64 'required_fields' => ['@type', 'headline', 'author'],
65 'optional_fields' => ['description', 'datePublished', 'dateModified', 'image', 'url'],
66 'max_length' => ['headline' => 110, 'description' => 160]
67 ],
68 'Organization' => [
69 'required_fields' => ['@type', 'name'],
70 'optional_fields' => ['description', 'url', 'logo', 'address', 'contactPoint'],
71 'max_length' => ['name' => 100, 'description' => 160]
72 ],
73 'LocalBusiness' => [
74 'required_fields' => ['@type', 'name', 'address'],
75 'optional_fields' => ['description', 'url', 'telephone', 'openingHours'],
76 'max_length' => ['name' => 100, 'description' => 160]
77 ],
78 'Product' => [
79 'required_fields' => ['@type', 'name'],
80 'optional_fields' => ['description', 'image', 'brand', 'offers'],
81 'max_length' => ['name' => 100, 'description' => 160]
82 ],
83 'WebSite' => [
84 'required_fields' => ['@type', 'name', 'url'],
85 'optional_fields' => ['description', 'potentialAction'],
86 'max_length' => ['name' => 100, 'description' => 160]
87 ],
88 'FAQPage' => [
89 'required_fields' => ['@type', 'mainEntity'],
90 'optional_fields' => ['name', 'description'],
91 'max_length' => ['name' => 100, 'description' => 160]
92 ],
93 'SoftwareApplication' => [
94 'required_fields' => ['@type', 'name'],
95 'optional_fields' => ['description', 'applicationCategory', 'operatingSystem'],
96 'max_length' => ['name' => 100, 'description' => 160]
97 ],
98 'Event' => [
99 'required_fields' => ['@type', 'name', 'startDate'],
100 'optional_fields' => ['description', 'location', 'organizer', 'endDate', 'eventStatus', 'eventAttendanceMode', 'url'],
101 'max_length' => ['name' => 100, 'description' => 160]
102 ],
103 'Person' => [
104 'required_fields' => ['@type', 'name'],
105 'optional_fields' => ['description', 'url', 'image', 'jobTitle'],
106 'max_length' => ['name' => 100, 'description' => 160]
107 ],
108 'HowTo' => [
109 'required_fields' => ['@type', 'name'],
110 'optional_fields' => ['description', 'totalTime', 'prepTime', 'difficulty', 'estimatedCost', 'supply', 'tool', 'step', 'yield', 'image', 'video'],
111 'max_length' => ['name' => 100, 'description' => 160]
112 ],
113 'BreadcrumbList' => [
114 'required_fields' => ['@type', 'itemListElement'],
115 'optional_fields' => ['name', 'description', 'numberOfItems'],
116 'max_length' => ['name' => 100, 'description' => 160]
117 ],
118 'VideoObject' => [
119 'required_fields' => ['@type', 'name', 'thumbnailUrl', 'uploadDate'],
120 'optional_fields' => ['description', 'contentUrl', 'embedUrl', 'duration', 'url'],
121 'max_length' => ['name' => 110, 'description' => 160]
122 ],
123 // Offered by the metabox Schema Type dropdown and registered in
124 // Schema_Factory, but missing here — so a Review could be generated and
125 // never deployed (#462). Field list mirrors Schema_Factory::Review.
126 'Review' => [
127 'required_fields' => ['@type', 'itemReviewed', 'reviewRating', 'author'],
128 'optional_fields' => ['reviewBody', 'datePublished', 'publisher', 'name', 'url'],
129 'max_length' => ['name' => 110, 'reviewBody' => 500]
130 ],
131 // The WebPage family. #624 made all four selectable in the metabox and
132 // taught the generate/validate/optimize/preview routes, Schema_Builder,
133 // Schema_Factory and Schema_Graph about them — but not this array, and
134 // deploy_schema_markup() gates every entry on it. So each one generated
135 // cleanly, validated at a score of 100, reported deployment_ready, then
136 // failed deployment with "Invalid schema type: AboutPage" and no row
137 // written. Exactly the #462 Review bug, one array and two releases
138 // later, which is why SchemaInputValidatorCoverageTest now scans the
139 // dropdown instead of trusting this list to be extended by hand.
140 //
141 // `name` and `url` are the two populate_webpage_schema() always sets;
142 // the rest are conditional, so they are optional here.
143 'WebPage' => [
144 'required_fields' => ['@type', 'name', 'url'],
145 'optional_fields' => ['description', 'datePublished', 'dateModified', 'isPartOf', 'breadcrumb', 'primaryImageOfPage', 'inLanguage', 'mainEntity'],
146 'max_length' => ['name' => 110, 'description' => 160]
147 ],
148 'AboutPage' => [
149 'required_fields' => ['@type', 'name', 'url'],
150 'optional_fields' => ['description', 'datePublished', 'dateModified', 'isPartOf', 'breadcrumb', 'primaryImageOfPage', 'inLanguage', 'mainEntity'],
151 'max_length' => ['name' => 110, 'description' => 160]
152 ],
153 'ContactPage' => [
154 'required_fields' => ['@type', 'name', 'url'],
155 'optional_fields' => ['description', 'datePublished', 'dateModified', 'isPartOf', 'breadcrumb', 'primaryImageOfPage', 'inLanguage', 'mainEntity'],
156 'max_length' => ['name' => 110, 'description' => 160]
157 ],
158 'ProfilePage' => [
159 'required_fields' => ['@type', 'name', 'url'],
160 'optional_fields' => ['description', 'datePublished', 'dateModified', 'isPartOf', 'breadcrumb', 'primaryImageOfPage', 'inLanguage', 'mainEntity'],
161 'max_length' => ['name' => 110, 'description' => 160]
162 ]
163 ];
164
165 /**
166 * Dangerous HTML tags and attributes to strip
167 *
168 * @since 1.0.0
169 * @var array
170 */
171 private array $dangerous_tags = [
172 'script', 'iframe', 'object', 'embed', 'form', 'input', 'button',
173 'link', 'meta', 'style', 'base', 'frame', 'frameset'
174 ];
175
176 /**
177 * Allowed URL protocols
178 *
179 * @since 1.0.0
180 * @var array
181 */
182 private array $allowed_protocols = ['http', 'https', 'mailto', 'tel'];
183
184 /**
185 * Maximum allowed JSON depth to prevent JSON bomb attacks
186 *
187 * @since 1.0.0
188 * @var int
189 */
190 private const MAX_JSON_DEPTH = 10;
191
192 /**
193 * Maximum payload size in bytes (500KB)
194 *
195 * @since 1.0.0
196 * @var int
197 */
198 private const MAX_PAYLOAD_SIZE = 512000;
199
200 /**
201 * Maximum array size (number of elements)
202 *
203 * @since 1.0.0
204 * @var int
205 */
206 private const MAX_ARRAY_SIZE = 100;
207
208 /**
209 * Maximum string length for any single field
210 *
211 * @since 1.0.0
212 * @var int
213 */
214 private const MAX_STRING_LENGTH = 10000;
215
216 /**
217 * Validate and sanitize schema data
218 *
219 * @since 1.0.0
220 *
221 * @param array $schema_data Raw schema data
222 * @param string $schema_type Schema type
223 * @return array Validation result with sanitized data
224 */
225 public function validate_schema_data(array $schema_data, string $schema_type): array {
226 $result = [
227 'valid' => false,
228 'sanitized_data' => [],
229 'errors' => [],
230 'warnings' => []
231 ];
232
233 // The deploy routes read the type from the node's @type, and a
234 // LocalBusiness is published under the business type chosen in Local
235 // SEO ("Restaurant", #1005). Validate it as the LocalBusiness it is;
236 // the @type match below accepts the subtype.
237 if (\ThinkRank\Config\Local_Business_Types_Config::is_local_business($schema_type)) {
238 $schema_type = 'LocalBusiness';
239 }
240
241 try {
242 // 1. Validate payload size to prevent DoS attacks
243 $size_validation = $this->validate_payload_size($schema_data);
244 if (!$size_validation['valid']) {
245 $result['errors'] = array_merge($result['errors'], $size_validation['errors']);
246 return $result;
247 }
248
249 // 2. Validate schema type
250 if (!$this->is_valid_schema_type($schema_type)) {
251 $result['errors'][] = "Invalid schema type: {$schema_type}";
252 return $result;
253 }
254
255 // 3. Validate JSON structure and depth
256 $structure_validation = $this->validate_json_structure($schema_data, $schema_type);
257 if (!$structure_validation['valid']) {
258 $result['errors'] = array_merge($result['errors'], $structure_validation['errors']);
259 return $result;
260 }
261
262 // 3.1. Validate JSON depth to prevent JSON bomb attacks
263 if (!$this->validate_json_depth($schema_data)) {
264 $result['errors'][] = 'Schema data exceeds maximum allowed depth (' . self::MAX_JSON_DEPTH . ' levels)';
265 return $result;
266 }
267
268 // 4. Sanitize all input data
269 $sanitized_data = $this->sanitize_schema_data($schema_data);
270
271 // 5. Validate required fields
272 $field_validation = $this->validate_required_fields($sanitized_data, $schema_type);
273 if (!$field_validation['valid']) {
274 $result['errors'] = array_merge($result['errors'], $field_validation['errors']);
275 }
276
277 // 6. Validate data types and formats
278 $format_validation = $this->validate_data_formats($sanitized_data, $schema_type, $schema_data);
279 if (!$format_validation['valid']) {
280 $result['errors'] = array_merge($result['errors'], $format_validation['errors']);
281 }
282 $result['warnings'] = array_merge($result['warnings'], $format_validation['warnings']);
283
284 // 7. Validate content length limits
285 $length_validation = $this->validate_content_lengths($sanitized_data, $schema_type);
286 if (!$length_validation['valid']) {
287 $result['warnings'] = array_merge($result['warnings'], $length_validation['warnings']);
288 }
289
290 $result['valid'] = empty($result['errors']);
291 $result['sanitized_data'] = $sanitized_data;
292
293 } catch (\Exception $e) {
294 $result['errors'][] = 'Schema validation failed: ' . $e->getMessage();
295 }
296
297 return $result;
298 }
299
300 /**
301 * Validate payload size to prevent DoS attacks
302 *
303 * @since 1.0.0
304 *
305 * @param array $schema_data Schema data to validate
306 * @return array Validation result
307 */
308 private function validate_payload_size(array $schema_data): array {
309 $result = ['valid' => true, 'errors' => []];
310
311 // Calculate approximate payload size
312 $payload_size = strlen(wp_json_encode($schema_data));
313
314 if ($payload_size > self::MAX_PAYLOAD_SIZE) {
315 $result['errors'][] = sprintf(
316 'Payload size (%s) exceeds maximum allowed size (%s)',
317 size_format($payload_size),
318 size_format(self::MAX_PAYLOAD_SIZE)
319 );
320 $result['valid'] = false;
321 }
322
323 // Validate array sizes and string lengths recursively
324 $structure_validation = $this->validate_data_structure($schema_data);
325 if (!$structure_validation['valid']) {
326 $result['errors'] = array_merge($result['errors'], $structure_validation['errors']);
327 $result['valid'] = false;
328 }
329
330 return $result;
331 }
332
333 /**
334 * Validate data structure (arrays and strings)
335 *
336 * @since 1.0.0
337 *
338 * @param mixed $data Data to validate
339 * @param string $path Current path for error reporting
340 * @return array Validation result
341 */
342 private function validate_data_structure($data, string $path = ''): array {
343 $result = ['valid' => true, 'errors' => []];
344
345 if (is_array($data)) {
346 // Check array size
347 if (count($data) > self::MAX_ARRAY_SIZE) {
348 $result['errors'][] = sprintf(
349 'Array at path "%s" contains %d elements, maximum allowed is %d',
350 $path ?: 'root',
351 count($data),
352 self::MAX_ARRAY_SIZE
353 );
354 $result['valid'] = false;
355 }
356
357 // Recursively validate nested data
358 foreach ($data as $key => $value) {
359 $current_path = $path ? "{$path}.{$key}" : $key;
360 $nested_validation = $this->validate_data_structure($value, $current_path);
361 if (!$nested_validation['valid']) {
362 $result['errors'] = array_merge($result['errors'], $nested_validation['errors']);
363 $result['valid'] = false;
364 }
365 }
366 } elseif (is_string($data)) {
367 // Check string length
368 if (strlen($data) > self::MAX_STRING_LENGTH) {
369 $result['errors'][] = sprintf(
370 'String at path "%s" is %d characters, maximum allowed is %d',
371 $path ?: 'value',
372 strlen($data),
373 self::MAX_STRING_LENGTH
374 );
375 $result['valid'] = false;
376 }
377 }
378
379 return $result;
380 }
381
382 /**
383 * Validate schema type
384 *
385 * @since 1.0.0
386 *
387 * @param string $schema_type Schema type to validate
388 * @return bool Validation result
389 */
390 private function is_valid_schema_type(string $schema_type): bool {
391 return isset($this->allowed_schema_types[$schema_type]);
392 }
393
394 /**
395 * Validate JSON structure
396 *
397 * @since 1.0.0
398 *
399 * @param array $schema_data Schema data
400 * @param string $schema_type Schema type
401 * @return array Validation result
402 */
403 private function validate_json_structure(array $schema_data, string $schema_type): array {
404 $result = ['valid' => true, 'errors' => []];
405
406 // Check for required @context
407 if (!isset($schema_data['@context'])) {
408 $result['errors'][] = 'Missing required @context field';
409 $result['valid'] = false;
410 } elseif ($schema_data['@context'] !== 'https://schema.org') {
411 $result['errors'][] = 'Invalid @context value. Must be "https://schema.org"';
412 $result['valid'] = false;
413 }
414
415 // Check for required @type.
416 // `@type` may be an array — "@type": ["Product","Offer"] is valid
417 // JSON-LD. Comparing an array against a string emitted an "Array to
418 // string conversion" warning and always failed (#468), so match if the
419 // expected type appears anywhere in the list.
420 if (!isset($schema_data['@type'])) {
421 $result['errors'][] = 'Missing required @type field';
422 $result['valid'] = false;
423 } else {
424 $declared_types = is_array($schema_data['@type'])
425 ? array_map('strval', $schema_data['@type'])
426 : [(string) $schema_data['@type']];
427
428 // A LocalBusiness is deployed under the business subtype the site
429 // chose (#1005).
430 $is_local_business_subtype = 'LocalBusiness' === $schema_type
431 && [] !== array_filter($declared_types, [\ThinkRank\Config\Local_Business_Types_Config::class, 'is_local_business']);
432
433 if (!$is_local_business_subtype && !in_array($schema_type, $declared_types, true)) {
434 $result['errors'][] = sprintf(
435 "Schema @type '%s' does not match expected type '%s'",
436 implode(', ', $declared_types),
437 $schema_type
438 );
439 $result['valid'] = false;
440 }
441 }
442
443 return $result;
444 }
445
446 /**
447 * Sanitize schema data recursively
448 *
449 * @since 1.0.0
450 *
451 * @param mixed $data Data to sanitize
452 * @param string $field_key Current field key for context-aware sanitization
453 * @return mixed Sanitized data
454 */
455 private function sanitize_schema_data($data, string $field_key = '') {
456 if (is_array($data)) {
457 $sanitized = [];
458 foreach ($data as $key => $value) {
459 $sanitized_key = $this->sanitize_key($key);
460 $sanitized[$sanitized_key] = $this->sanitize_schema_data($value, $sanitized_key);
461 }
462 return $sanitized;
463 }
464
465 if (is_string($data)) {
466 return $this->sanitize_string_value($data, $field_key);
467 }
468
469 if (is_numeric($data)) {
470 return $this->sanitize_numeric_value($data);
471 }
472
473 if (is_bool($data)) {
474 return $data;
475 }
476
477 // For other types, convert to string and sanitize
478 return $this->sanitize_string_value((string) $data, $field_key);
479 }
480
481 /**
482 * Sanitize array key
483 *
484 * @since 1.0.0
485 *
486 * @param string $key Array key
487 * @return string Sanitized key
488 */
489 private function sanitize_key($key): string {
490 // Ensure key is a string first
491 if (!is_string($key)) {
492 return (string) $key;
493 }
494
495 // For schema data, preserve the original key names to maintain case sensitivity
496 // Schema.org properties are case-sensitive (e.g., startDate, not startdate)
497 // Only do basic validation without changing the case
498 if (preg_match('/^[a-zA-Z@][a-zA-Z0-9@_-]*$/', $key)) {
499 return $key; // Return as-is if it's a valid schema property name
500 }
501
502 // Fallback to WordPress sanitization for invalid keys
503 return sanitize_key($key);
504 }
505
506 /**
507 * Sanitize string value with context-aware sanitization
508 *
509 * @since 1.0.0
510 *
511 * @param string $value String value
512 * @param string $field_name Field name for context-aware sanitization
513 * @return string Sanitized value
514 */
515 private function sanitize_string_value(string $value, string $field_name = ''): string {
516 // Handle URLs differently to preserve valid URL structure
517 if (in_array($field_name, ['url', 'sameAs', 'logo', 'image', 'mainEntityOfPage'], true)) {
518 return esc_url_raw($value);
519 }
520
521 // Handle email fields
522 if (in_array($field_name, ['email'], true)) {
523 return sanitize_email($value);
524 }
525
526 // Handle description fields that may contain basic HTML
527 if (in_array($field_name, ['description', 'text', 'articleBody'], true)) {
528 // Allow basic HTML but strip dangerous tags
529 $allowed_html = [
530 'p' => [],
531 'br' => [],
532 'strong' => [],
533 'em' => [],
534 'b' => [],
535 'i' => []
536 ];
537 $value = wp_kses($value, $allowed_html);
538 } else {
539 // For other fields, remove all HTML tags
540 $value = wp_strip_all_tags($value);
541 }
542
543 // Sanitize for database storage. Note: escaping is intentionally NOT done
544 // here. This value is stored and later emitted as JSON-LD inside a
545 // <script type="application/ld+json"> block, where wp_json_encode() is the
546 // correct encoder. Running esc_html() on input would persist HTML entities
547 // (e.g. "Ben & Jerry's" -> "Ben &amp; Jerry&#039;s") into the structured
548 // data. Escape at the output boundary, not at storage.
549 $value = sanitize_text_field($value);
550
551 return trim($value);
552 }
553
554 /**
555 * Sanitize numeric value
556 *
557 * @since 1.0.0
558 *
559 * @param mixed $value Numeric value
560 * @return float|int Sanitized numeric value
561 */
562 private function sanitize_numeric_value($value) {
563 if (is_int($value) || ctype_digit((string) $value)) {
564 return (int) $value;
565 }
566
567 return (float) $value;
568 }
569
570 /**
571 * Validate required fields
572 *
573 * @since 1.0.0
574 *
575 * @param array $schema_data Schema data
576 * @param string $schema_type Schema type
577 * @return array Validation result
578 */
579 private function validate_required_fields(array $schema_data, string $schema_type): array {
580 $result = ['valid' => true, 'errors' => []];
581 $rules = $this->allowed_schema_types[$schema_type];
582
583 foreach ($rules['required_fields'] as $field) {
584 if (!isset($schema_data[$field]) || empty($schema_data[$field])) {
585 $result['errors'][] = "Missing required field: {$field}";
586 $result['valid'] = false;
587 }
588 }
589 return $result;
590 }
591
592 /**
593 * Validate data formats
594 *
595 * @since 1.0.0
596 *
597 * @param array $schema_data Schema data
598 * @param string $schema_type Schema type
599 * @param array $raw_data Schema data as submitted, before sanitization.
600 * @return array Validation result
601 */
602 private function validate_data_formats(array $schema_data, string $schema_type, array $raw_data = []): array {
603 $result = ['valid' => true, 'errors' => [], 'warnings' => []];
604
605 foreach ($schema_data as $field => $value) {
606 // sameAs is a list, so its members never reached the string branch
607 // below and free text entered in a social-profile field saved
608 // cleanly, then shipped as invalid structured data (#480).
609 if (is_array($value) && in_array($field, ['url', 'sameAs', 'logo', 'image'], true)) {
610 // Validated after sanitization, but reported as entered:
611 // esc_url_raw() turns "not a url" into "http://not%20a%20url",
612 // which the user never typed (#949 review).
613 $items = $this->url_candidates($value);
614 $raw_items = is_array($raw_data[$field] ?? null) ? $this->url_candidates($raw_data[$field]) : [];
615 if (count($raw_items) !== count($items)) {
616 // A non-string member sanitized into a string would shift
617 // the positions; fall back rather than name the wrong one.
618 $raw_items = [];
619 }
620
621 foreach ($items as $index => $item) {
622 if ('' === trim($item)) {
623 continue;
624 }
625
626 if (!$this->is_valid_url($item)) {
627 $shown = $raw_items[$index] ?? $item;
628 $result['errors'][] = "Invalid URL format for field: {$field} ({$shown})";
629 $result['valid'] = false;
630 }
631 }
632
633 continue;
634 }
635
636 if (is_string($value)) {
637 // Validate URLs
638 if (in_array($field, ['url', 'sameAs', 'logo', 'image'], true) && !empty($value)) {
639 if (!$this->is_valid_url($value)) {
640 $result['errors'][] = "Invalid URL format for field: {$field}";
641 $result['valid'] = false;
642 }
643 }
644
645 // Validate email addresses
646 if (in_array($field, ['email'], true) && !empty($value)) {
647 if (!is_email($value)) {
648 $result['errors'][] = "Invalid email format for field: {$field}";
649 $result['valid'] = false;
650 }
651 }
652
653 // Validate dates
654 if (in_array($field, ['datePublished', 'dateModified'], true) && !empty($value)) {
655 if (!$this->is_valid_date($value)) {
656 $result['warnings'][] = "Invalid date format for field: {$field}. Use ISO 8601 format.";
657 }
658 }
659 }
660 }
661
662 return $result;
663 }
664
665 /**
666 * The URL strings inside an array-valued URL field.
667 *
668 * The field is either a list (`sameAs`, several `image` URLs) or a single
669 * node such as the `ImageObject` Schema_Builder emits for a configured
670 * logo. Checking every member of a node URL-checked its `@type` and
671 * `width`, so "ImageObject" failed as an invalid URL and the deploy route
672 * skipped — then retired — the site's Organization (#949). Only a node's
673 * `url` / `contentUrl` are URLs; a list may hold strings or such nodes.
674 *
675 * @since 2.14.1
676 *
677 * @param array $value Array-valued URL field.
678 * @return string[] URL strings to validate.
679 */
680 private function url_candidates(array $value): array {
681 $node_urls = static function (array $node): array {
682 return array_values(array_filter(
683 [$node['url'] ?? null, $node['contentUrl'] ?? null],
684 'is_string'
685 ));
686 };
687
688 if (array_values($value) !== $value) {
689 return $node_urls($value);
690 }
691
692 $urls = [];
693 foreach ($value as $item) {
694 if (is_string($item)) {
695 $urls[] = $item;
696 } elseif (is_array($item)) {
697 $urls = array_merge($urls, $node_urls($item));
698 }
699 }
700
701 return $urls;
702 }
703
704 /**
705 * Validate content lengths
706 *
707 * @since 1.0.0
708 *
709 * @param array $schema_data Schema data
710 * @param string $schema_type Schema type
711 * @return array Validation result
712 */
713 private function validate_content_lengths(array $schema_data, string $schema_type): array {
714 $result = ['valid' => true, 'warnings' => []];
715 $rules = $this->allowed_schema_types[$schema_type];
716
717 if (isset($rules['max_length'])) {
718 foreach ($rules['max_length'] as $field => $max_length) {
719 if (isset($schema_data[$field]) && is_string($schema_data[$field])) {
720 $length = strlen($schema_data[$field]);
721 if ($length > $max_length) {
722 $result['warnings'][] = "Field '{$field}' exceeds recommended length of {$max_length} characters (current: {$length})";
723 }
724 }
725 }
726 }
727
728 return $result;
729 }
730
731 /**
732 * Validate URL format and protocol
733 *
734 * @since 1.0.0
735 *
736 * @param string $url URL to validate
737 * @return bool Validation result
738 */
739 private function is_valid_url(string $url): bool {
740 // Basic URL validation
741 if (!\ThinkRank\Core\Url_Validator::is_valid($url)) {
742 return false;
743 }
744
745 // Check allowed protocols
746 $parsed = wp_parse_url($url);
747 if (!isset($parsed['scheme']) || !in_array($parsed['scheme'], $this->allowed_protocols, true)) {
748 return false;
749 }
750
751 return true;
752 }
753
754 /**
755 * Validate date format
756 *
757 * @since 1.0.0
758 *
759 * @param string $date Date to validate
760 * @return bool Validation result
761 */
762 private function is_valid_date(string $date): bool {
763 // Check ISO 8601 format
764 $formats = [
765 'Y-m-d\TH:i:s\Z',
766 'Y-m-d\TH:i:sP',
767 'Y-m-d\TH:i:s',
768 'Y-m-d'
769 ];
770
771 foreach ($formats as $format) {
772 $parsed = \DateTime::createFromFormat($format, $date);
773 if ($parsed && $parsed->format($format) === $date) {
774 return true;
775 }
776 }
777
778 return false;
779 }
780
781 /**
782 * Check rate limiting for user
783 *
784 * @since 1.0.0
785 *
786 * @param int $user_id User ID
787 * @param string $action Action type
788 * @param int $limit Rate limit (requests per hour)
789 * @return bool Whether request is allowed
790 */
791 public function check_rate_limit(int $user_id, string $action, int $limit = 100): bool {
792 // Persist the window in a transient (object cache / options) so the limit
793 // is enforced ACROSS requests. A per-request static array — as used
794 // previously — always starts empty on a fresh PHP process and therefore
795 // never throttled anything.
796 $key = 'thinkrank_schema_rl_' . $user_id . '_' . sanitize_key($action);
797
798 // Serialize the read-modify-write with a MySQL named lock so concurrent
799 // requests can't each read the same timestamp list, individually pass the
800 // limit check, and overwrite one another — which would let bursts slip
801 // past the configured limit. GET_LOCK is DB-level, so it serializes the
802 // critical section regardless of where the transient is stored.
803 global $wpdb;
804 $lock_name = substr('tr_schema_rl_' . md5($key), 0, 64);
805 $have_lock = ($wpdb instanceof \wpdb)
806 ? (int) $wpdb->get_var($wpdb->prepare('SELECT GET_LOCK(%s, %d)', $lock_name, 3)) === 1
807 : false;
808
809 try {
810 $current_time = time();
811 $window_start = $current_time - HOUR_IN_SECONDS; // 1 hour window
812
813 $timestamps = get_transient($key);
814 if (!is_array($timestamps)) {
815 $timestamps = [];
816 }
817
818 // Drop entries outside the window.
819 $timestamps = array_values(array_filter(
820 $timestamps,
821 static function ($timestamp) use ($window_start) {
822 return (int) $timestamp > $window_start;
823 }
824 ));
825
826 // Check if limit exceeded.
827 if (count($timestamps) >= $limit) {
828 set_transient($key, $timestamps, HOUR_IN_SECONDS);
829 return false;
830 }
831
832 // Record this request.
833 $timestamps[] = $current_time;
834 set_transient($key, $timestamps, HOUR_IN_SECONDS);
835
836 return true;
837 } finally {
838 if ($have_lock) {
839 $wpdb->query($wpdb->prepare('SELECT RELEASE_LOCK(%s)', $lock_name));
840 }
841 }
842 }
843
844 /**
845 * Validate user permissions for schema operations
846 *
847 * @since 1.0.0
848 *
849 * @param string $operation Operation type
850 * @param int $user_id User ID
851 * @return array Validation result
852 */
853 public function validate_user_permissions(string $operation, int $user_id): array {
854 $result = ['valid' => false, 'errors' => []];
855
856 // Check if user exists and is logged in
857 if (!$user_id || !get_userdata($user_id)) {
858 $result['errors'][] = 'Invalid user or user not logged in';
859 return $result;
860 }
861
862 // Check operation-specific permissions.
863 //
864 // #457 loosened the route permission_callbacks to the delegable
865 // `thinkrank_schema` capability, but these handler-level checks still
866 // demanded edit_posts / publish_posts / manage_options — so a role
867 // granted Schema access could generate and validate but was denied on
868 // deploy, bulk operations and everything site-context. That is exactly
869 // the symptom #457 set out to fix (#470). A holder of thinkrank_schema
870 // satisfies any schema operation; the built-in caps remain as the
871 // fallback for roles that never went through the Role Manager.
872 $has_schema_cap = user_can($user_id, 'thinkrank_schema');
873
874 switch ($operation) {
875 case 'generate':
876 case 'validate':
877 case 'optimize':
878 if (!$has_schema_cap && !user_can($user_id, 'edit_posts')) {
879 $result['errors'][] = 'Insufficient permissions for schema generation/validation';
880 return $result;
881 }
882 break;
883
884 case 'deploy':
885 if (!$has_schema_cap && !user_can($user_id, 'publish_posts')) {
886 $result['errors'][] = 'Insufficient permissions for schema deployment';
887 return $result;
888 }
889 break;
890
891 case 'manage_settings':
892 case 'bulk_operations':
893 if (!$has_schema_cap && !user_can($user_id, 'manage_options')) {
894 $result['errors'][] = 'Insufficient permissions for schema management';
895 return $result;
896 }
897 break;
898
899 default:
900 $result['errors'][] = "Unknown operation: {$operation}";
901 return $result;
902 }
903
904 // Check rate limiting
905 $rate_limits = [
906 'generate' => 50, // 50 generations per hour
907 'validate' => 100, // 100 validations per hour
908 'deploy' => 20, // 20 deployments per hour
909 'optimize' => 30, // 30 optimizations per hour
910 'bulk_operations' => 5 // 5 bulk operations per hour
911 ];
912
913 $limit = $rate_limits[$operation] ?? 100;
914 if (!$this->check_rate_limit($user_id, $operation, $limit)) {
915 $result['errors'][] = "Rate limit exceeded for {$operation}. Please try again later.";
916 return $result;
917 }
918
919 $result['valid'] = true;
920 return $result;
921 }
922
923 /**
924 * Sanitize and validate context parameters with ownership checks
925 *
926 * @since 1.0.0
927 *
928 * @param string $context_type Context type
929 * @param int|null $context_id Context ID
930 * @param int|null $user_id User ID for ownership validation
931 * @return array Validation result
932 */
933 public function validate_context_parameters(string $context_type, ?int $context_id, ?int $user_id = null): array {
934 $result = ['valid' => false, 'errors' => [], 'sanitized_data' => []];
935
936 // Sanitize context type
937 $context_type = sanitize_key($context_type);
938 $allowed_types = ['site', 'post', 'page', 'product'];
939
940 if (!in_array($context_type, $allowed_types, true)) {
941 $result['errors'][] = "Invalid context type: {$context_type}";
942 return $result;
943 }
944
945 // Validate context ID and ownership
946 if ($context_type !== 'site') {
947 if (!$context_id || $context_id <= 0) {
948 $result['errors'][] = 'Context ID is required for non-site contexts';
949 return $result;
950 }
951
952 $context_id = absint($context_id);
953 $post = get_post($context_id);
954
955 if (!$post) {
956 $result['errors'][] = "Invalid context ID: {$context_id}";
957 return $result;
958 }
959
960 // SECURITY: Check context ownership.
961 // Fails closed on a missing user — a security helper that waves the
962 // check through when it cannot identify the caller is the wrong way
963 // round. Every caller passes a real ID, so this only tightens an
964 // unreachable path.
965 if (!$user_id || !$this->validate_context_ownership($post, $user_id)) {
966 $result['errors'][] = "Access denied: You don't have permission to modify this {$context_type}";
967 return $result;
968 }
969 } else {
970 $context_id = null; // Site context doesn't use ID
971
972 // SECURITY: Check site-level permissions for site context.
973 // user_can($user_id, …) rather than current_user_can() so this
974 // agrees with the rest of the validator outside a REST request,
975 // where the current user and $user_id can differ (cron, CLI).
976 //
977 // Accepts the delegable `thinkrank_schema` capability as well as
978 // manage_options: /schema/settings already lets a delegated role
979 // edit site schema settings, so blocking site-context generate and
980 // deploy for the same role was inconsistent (#470).
981 if (!$user_id || (!user_can($user_id, 'thinkrank_schema') && !user_can($user_id, 'manage_options'))) {
982 $result['errors'][] = 'Access denied: You need administrator privileges for site-level schema operations';
983 return $result;
984 }
985 }
986
987 $result['valid'] = true;
988 $result['sanitized_data'] = [
989 'context_type' => $context_type,
990 'context_id' => $context_id
991 ];
992
993 return $result;
994 }
995
996 /**
997 * Validate context ownership
998 *
999 * @since 1.0.0
1000 *
1001 * @param \WP_Post $post Post object
1002 * @param int $user_id User ID
1003 * @return bool Whether user has permission
1004 */
1005 private function validate_context_ownership(\WP_Post $post, int $user_id): bool {
1006 // `edit_post` is a meta capability: map_meta_cap() already resolves
1007 // authorship, published state, and edit_others_posts for this specific
1008 // post. It is the whole check.
1009 //
1010 // Two fallbacks used to sit under it and between them defeated the
1011 // function. One granted access on authorship alone, which hands a
1012 // Contributor back a post they lost edit rights to once it published.
1013 // The other granted access to anyone holding the post type's *general*
1014 // edit_posts capability — a cap every Author and Contributor has, that
1015 // says nothing about this post — so ownership validation returned true
1016 // for every post on the site (#326).
1017 //
1018 // user_can() rather than current_user_can() so the method honours the
1019 // $user_id it was handed, matching validate_user_permissions().
1020 return user_can($user_id, 'edit_post', $post->ID);
1021 }
1022
1023 /**
1024 * Validate JSON depth to prevent JSON bomb attacks
1025 *
1026 * @since 1.0.0
1027 *
1028 * @param mixed $data Data to validate
1029 * @param int $depth Current depth level
1030 * @return bool Whether depth is within limits
1031 */
1032 private function validate_json_depth($data, int $depth = 0): bool {
1033 if ($depth > self::MAX_JSON_DEPTH) {
1034 return false;
1035 }
1036
1037 if (is_array($data)) {
1038 foreach ($data as $value) {
1039 if (!$this->validate_json_depth($value, $depth + 1)) {
1040 return false;
1041 }
1042 }
1043 }
1044
1045 return true;
1046 }
1047
1048 /**
1049 * Validate and sanitize options array
1050 *
1051 * @since 1.0.0
1052 *
1053 * @param array $options Options array
1054 * @return array Sanitized options
1055 */
1056 public function sanitize_options(array $options): array {
1057 $sanitized = [];
1058 // Anything omitted here is dropped before the manager sees it, which is
1059 // why apply_content_schema_settings_from_options() and the per-request
1060 // schema-type opt-in were unreachable from REST (#470). The list now
1061 // covers every option the generate path actually reads.
1062 //
1063 // `validation_level` previously allowed 'basic' and rejected 'lenient',
1064 // disagreeing with Schema_Settings_Config, validate_settings() and the
1065 // update-settings ability, which all use 'lenient'.
1066 // `deployment_method` no longer advertises microdata/rdfa, which
1067 // determine_deployment_method() hardcodes away to json_ld anyway.
1068 $allowed_options = [
1069 'deployment_method' => ['json_ld'],
1070 'validation_level' => ['strict', 'moderate', 'lenient'],
1071 'include_meta' => 'boolean',
1072 'minify_output' => 'boolean',
1073 'cache_duration' => 'integer',
1074 'rich_snippets_optimization' => 'boolean',
1075 'knowledge_graph' => 'boolean',
1076 'auto_generate_schema' => 'boolean',
1077 'enable_article_schema' => 'boolean',
1078 'enable_faq_schema' => 'boolean',
1079 'enable_howto_schema' => 'boolean',
1080 'enable_product_schema' => 'boolean',
1081 'enable_local_business' => 'boolean',
1082 'enable_breadcrumbs_schema' => 'boolean',
1083 'enable_accordion_faq_schema' => 'boolean',
1084 ];
1085
1086 foreach ($options as $key => $value) {
1087 $sanitized_key = sanitize_key($key);
1088
1089 if (!isset($allowed_options[$sanitized_key])) {
1090 continue; // Skip unknown options
1091 }
1092
1093 $rule = $allowed_options[$sanitized_key];
1094
1095 if (is_array($rule)) {
1096 // Enum validation
1097 if (in_array($value, $rule, true)) {
1098 $sanitized[$sanitized_key] = $value;
1099 }
1100 } elseif ($rule === 'boolean') {
1101 $sanitized[$sanitized_key] = (bool) $value;
1102 } elseif ($rule === 'integer') {
1103 $sanitized[$sanitized_key] = absint($value);
1104 } else {
1105 $sanitized[$sanitized_key] = sanitize_text_field($value);
1106 }
1107 }
1108
1109 return $sanitized;
1110 }
1111 }
1112