PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.14.1
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.14.1
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 1.28.0 1.27.0 All 56 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.1, at includes/seo/class-schema-input-validator.php

1,099 lines 41.0 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 try {
234 // 1. Validate payload size to prevent DoS attacks
235 $size_validation = $this->validate_payload_size($schema_data);
236 if (!$size_validation['valid']) {
237 $result['errors'] = array_merge($result['errors'], $size_validation['errors']);
238 return $result;
239 }
240
241 // 2. Validate schema type
242 if (!$this->is_valid_schema_type($schema_type)) {
243 $result['errors'][] = "Invalid schema type: {$schema_type}";
244 return $result;
245 }
246
247 // 3. Validate JSON structure and depth
248 $structure_validation = $this->validate_json_structure($schema_data, $schema_type);
249 if (!$structure_validation['valid']) {
250 $result['errors'] = array_merge($result['errors'], $structure_validation['errors']);
251 return $result;
252 }
253
254 // 3.1. Validate JSON depth to prevent JSON bomb attacks
255 if (!$this->validate_json_depth($schema_data)) {
256 $result['errors'][] = 'Schema data exceeds maximum allowed depth (' . self::MAX_JSON_DEPTH . ' levels)';
257 return $result;
258 }
259
260 // 4. Sanitize all input data
261 $sanitized_data = $this->sanitize_schema_data($schema_data);
262
263 // 5. Validate required fields
264 $field_validation = $this->validate_required_fields($sanitized_data, $schema_type);
265 if (!$field_validation['valid']) {
266 $result['errors'] = array_merge($result['errors'], $field_validation['errors']);
267 }
268
269 // 6. Validate data types and formats
270 $format_validation = $this->validate_data_formats($sanitized_data, $schema_type, $schema_data);
271 if (!$format_validation['valid']) {
272 $result['errors'] = array_merge($result['errors'], $format_validation['errors']);
273 }
274 $result['warnings'] = array_merge($result['warnings'], $format_validation['warnings']);
275
276 // 7. Validate content length limits
277 $length_validation = $this->validate_content_lengths($sanitized_data, $schema_type);
278 if (!$length_validation['valid']) {
279 $result['warnings'] = array_merge($result['warnings'], $length_validation['warnings']);
280 }
281
282 $result['valid'] = empty($result['errors']);
283 $result['sanitized_data'] = $sanitized_data;
284
285 } catch (\Exception $e) {
286 $result['errors'][] = 'Schema validation failed: ' . $e->getMessage();
287 }
288
289 return $result;
290 }
291
292 /**
293 * Validate payload size to prevent DoS attacks
294 *
295 * @since 1.0.0
296 *
297 * @param array $schema_data Schema data to validate
298 * @return array Validation result
299 */
300 private function validate_payload_size(array $schema_data): array {
301 $result = ['valid' => true, 'errors' => []];
302
303 // Calculate approximate payload size
304 $payload_size = strlen(wp_json_encode($schema_data));
305
306 if ($payload_size > self::MAX_PAYLOAD_SIZE) {
307 $result['errors'][] = sprintf(
308 'Payload size (%s) exceeds maximum allowed size (%s)',
309 size_format($payload_size),
310 size_format(self::MAX_PAYLOAD_SIZE)
311 );
312 $result['valid'] = false;
313 }
314
315 // Validate array sizes and string lengths recursively
316 $structure_validation = $this->validate_data_structure($schema_data);
317 if (!$structure_validation['valid']) {
318 $result['errors'] = array_merge($result['errors'], $structure_validation['errors']);
319 $result['valid'] = false;
320 }
321
322 return $result;
323 }
324
325 /**
326 * Validate data structure (arrays and strings)
327 *
328 * @since 1.0.0
329 *
330 * @param mixed $data Data to validate
331 * @param string $path Current path for error reporting
332 * @return array Validation result
333 */
334 private function validate_data_structure($data, string $path = ''): array {
335 $result = ['valid' => true, 'errors' => []];
336
337 if (is_array($data)) {
338 // Check array size
339 if (count($data) > self::MAX_ARRAY_SIZE) {
340 $result['errors'][] = sprintf(
341 'Array at path "%s" contains %d elements, maximum allowed is %d',
342 $path ?: 'root',
343 count($data),
344 self::MAX_ARRAY_SIZE
345 );
346 $result['valid'] = false;
347 }
348
349 // Recursively validate nested data
350 foreach ($data as $key => $value) {
351 $current_path = $path ? "{$path}.{$key}" : $key;
352 $nested_validation = $this->validate_data_structure($value, $current_path);
353 if (!$nested_validation['valid']) {
354 $result['errors'] = array_merge($result['errors'], $nested_validation['errors']);
355 $result['valid'] = false;
356 }
357 }
358 } elseif (is_string($data)) {
359 // Check string length
360 if (strlen($data) > self::MAX_STRING_LENGTH) {
361 $result['errors'][] = sprintf(
362 'String at path "%s" is %d characters, maximum allowed is %d',
363 $path ?: 'value',
364 strlen($data),
365 self::MAX_STRING_LENGTH
366 );
367 $result['valid'] = false;
368 }
369 }
370
371 return $result;
372 }
373
374 /**
375 * Validate schema type
376 *
377 * @since 1.0.0
378 *
379 * @param string $schema_type Schema type to validate
380 * @return bool Validation result
381 */
382 private function is_valid_schema_type(string $schema_type): bool {
383 return isset($this->allowed_schema_types[$schema_type]);
384 }
385
386 /**
387 * Validate JSON structure
388 *
389 * @since 1.0.0
390 *
391 * @param array $schema_data Schema data
392 * @param string $schema_type Schema type
393 * @return array Validation result
394 */
395 private function validate_json_structure(array $schema_data, string $schema_type): array {
396 $result = ['valid' => true, 'errors' => []];
397
398 // Check for required @context
399 if (!isset($schema_data['@context'])) {
400 $result['errors'][] = 'Missing required @context field';
401 $result['valid'] = false;
402 } elseif ($schema_data['@context'] !== 'https://schema.org') {
403 $result['errors'][] = 'Invalid @context value. Must be "https://schema.org"';
404 $result['valid'] = false;
405 }
406
407 // Check for required @type.
408 // `@type` may be an array — "@type": ["Product","Offer"] is valid
409 // JSON-LD. Comparing an array against a string emitted an "Array to
410 // string conversion" warning and always failed (#468), so match if the
411 // expected type appears anywhere in the list.
412 if (!isset($schema_data['@type'])) {
413 $result['errors'][] = 'Missing required @type field';
414 $result['valid'] = false;
415 } else {
416 $declared_types = is_array($schema_data['@type'])
417 ? array_map('strval', $schema_data['@type'])
418 : [(string) $schema_data['@type']];
419
420 if (!in_array($schema_type, $declared_types, true)) {
421 $result['errors'][] = sprintf(
422 "Schema @type '%s' does not match expected type '%s'",
423 implode(', ', $declared_types),
424 $schema_type
425 );
426 $result['valid'] = false;
427 }
428 }
429
430 return $result;
431 }
432
433 /**
434 * Sanitize schema data recursively
435 *
436 * @since 1.0.0
437 *
438 * @param mixed $data Data to sanitize
439 * @param string $field_key Current field key for context-aware sanitization
440 * @return mixed Sanitized data
441 */
442 private function sanitize_schema_data($data, string $field_key = '') {
443 if (is_array($data)) {
444 $sanitized = [];
445 foreach ($data as $key => $value) {
446 $sanitized_key = $this->sanitize_key($key);
447 $sanitized[$sanitized_key] = $this->sanitize_schema_data($value, $sanitized_key);
448 }
449 return $sanitized;
450 }
451
452 if (is_string($data)) {
453 return $this->sanitize_string_value($data, $field_key);
454 }
455
456 if (is_numeric($data)) {
457 return $this->sanitize_numeric_value($data);
458 }
459
460 if (is_bool($data)) {
461 return $data;
462 }
463
464 // For other types, convert to string and sanitize
465 return $this->sanitize_string_value((string) $data, $field_key);
466 }
467
468 /**
469 * Sanitize array key
470 *
471 * @since 1.0.0
472 *
473 * @param string $key Array key
474 * @return string Sanitized key
475 */
476 private function sanitize_key($key): string {
477 // Ensure key is a string first
478 if (!is_string($key)) {
479 return (string) $key;
480 }
481
482 // For schema data, preserve the original key names to maintain case sensitivity
483 // Schema.org properties are case-sensitive (e.g., startDate, not startdate)
484 // Only do basic validation without changing the case
485 if (preg_match('/^[a-zA-Z@][a-zA-Z0-9@_-]*$/', $key)) {
486 return $key; // Return as-is if it's a valid schema property name
487 }
488
489 // Fallback to WordPress sanitization for invalid keys
490 return sanitize_key($key);
491 }
492
493 /**
494 * Sanitize string value with context-aware sanitization
495 *
496 * @since 1.0.0
497 *
498 * @param string $value String value
499 * @param string $field_name Field name for context-aware sanitization
500 * @return string Sanitized value
501 */
502 private function sanitize_string_value(string $value, string $field_name = ''): string {
503 // Handle URLs differently to preserve valid URL structure
504 if (in_array($field_name, ['url', 'sameAs', 'logo', 'image', 'mainEntityOfPage'], true)) {
505 return esc_url_raw($value);
506 }
507
508 // Handle email fields
509 if (in_array($field_name, ['email'], true)) {
510 return sanitize_email($value);
511 }
512
513 // Handle description fields that may contain basic HTML
514 if (in_array($field_name, ['description', 'text', 'articleBody'], true)) {
515 // Allow basic HTML but strip dangerous tags
516 $allowed_html = [
517 'p' => [],
518 'br' => [],
519 'strong' => [],
520 'em' => [],
521 'b' => [],
522 'i' => []
523 ];
524 $value = wp_kses($value, $allowed_html);
525 } else {
526 // For other fields, remove all HTML tags
527 $value = wp_strip_all_tags($value);
528 }
529
530 // Sanitize for database storage. Note: escaping is intentionally NOT done
531 // here. This value is stored and later emitted as JSON-LD inside a
532 // <script type="application/ld+json"> block, where wp_json_encode() is the
533 // correct encoder. Running esc_html() on input would persist HTML entities
534 // (e.g. "Ben & Jerry's" -> "Ben &amp; Jerry&#039;s") into the structured
535 // data. Escape at the output boundary, not at storage.
536 $value = sanitize_text_field($value);
537
538 return trim($value);
539 }
540
541 /**
542 * Sanitize numeric value
543 *
544 * @since 1.0.0
545 *
546 * @param mixed $value Numeric value
547 * @return float|int Sanitized numeric value
548 */
549 private function sanitize_numeric_value($value) {
550 if (is_int($value) || ctype_digit((string) $value)) {
551 return (int) $value;
552 }
553
554 return (float) $value;
555 }
556
557 /**
558 * Validate required fields
559 *
560 * @since 1.0.0
561 *
562 * @param array $schema_data Schema data
563 * @param string $schema_type Schema type
564 * @return array Validation result
565 */
566 private function validate_required_fields(array $schema_data, string $schema_type): array {
567 $result = ['valid' => true, 'errors' => []];
568 $rules = $this->allowed_schema_types[$schema_type];
569
570 foreach ($rules['required_fields'] as $field) {
571 if (!isset($schema_data[$field]) || empty($schema_data[$field])) {
572 $result['errors'][] = "Missing required field: {$field}";
573 $result['valid'] = false;
574 }
575 }
576 return $result;
577 }
578
579 /**
580 * Validate data formats
581 *
582 * @since 1.0.0
583 *
584 * @param array $schema_data Schema data
585 * @param string $schema_type Schema type
586 * @param array $raw_data Schema data as submitted, before sanitization.
587 * @return array Validation result
588 */
589 private function validate_data_formats(array $schema_data, string $schema_type, array $raw_data = []): array {
590 $result = ['valid' => true, 'errors' => [], 'warnings' => []];
591
592 foreach ($schema_data as $field => $value) {
593 // sameAs is a list, so its members never reached the string branch
594 // below and free text entered in a social-profile field saved
595 // cleanly, then shipped as invalid structured data (#480).
596 if (is_array($value) && in_array($field, ['url', 'sameAs', 'logo', 'image'], true)) {
597 // Validated after sanitization, but reported as entered:
598 // esc_url_raw() turns "not a url" into "http://not%20a%20url",
599 // which the user never typed (#949 review).
600 $items = $this->url_candidates($value);
601 $raw_items = is_array($raw_data[$field] ?? null) ? $this->url_candidates($raw_data[$field]) : [];
602 if (count($raw_items) !== count($items)) {
603 // A non-string member sanitized into a string would shift
604 // the positions; fall back rather than name the wrong one.
605 $raw_items = [];
606 }
607
608 foreach ($items as $index => $item) {
609 if ('' === trim($item)) {
610 continue;
611 }
612
613 if (!$this->is_valid_url($item)) {
614 $shown = $raw_items[$index] ?? $item;
615 $result['errors'][] = "Invalid URL format for field: {$field} ({$shown})";
616 $result['valid'] = false;
617 }
618 }
619
620 continue;
621 }
622
623 if (is_string($value)) {
624 // Validate URLs
625 if (in_array($field, ['url', 'sameAs', 'logo', 'image'], true) && !empty($value)) {
626 if (!$this->is_valid_url($value)) {
627 $result['errors'][] = "Invalid URL format for field: {$field}";
628 $result['valid'] = false;
629 }
630 }
631
632 // Validate email addresses
633 if (in_array($field, ['email'], true) && !empty($value)) {
634 if (!is_email($value)) {
635 $result['errors'][] = "Invalid email format for field: {$field}";
636 $result['valid'] = false;
637 }
638 }
639
640 // Validate dates
641 if (in_array($field, ['datePublished', 'dateModified'], true) && !empty($value)) {
642 if (!$this->is_valid_date($value)) {
643 $result['warnings'][] = "Invalid date format for field: {$field}. Use ISO 8601 format.";
644 }
645 }
646 }
647 }
648
649 return $result;
650 }
651
652 /**
653 * The URL strings inside an array-valued URL field.
654 *
655 * The field is either a list (`sameAs`, several `image` URLs) or a single
656 * node such as the `ImageObject` Schema_Builder emits for a configured
657 * logo. Checking every member of a node URL-checked its `@type` and
658 * `width`, so "ImageObject" failed as an invalid URL and the deploy route
659 * skipped — then retired — the site's Organization (#949). Only a node's
660 * `url` / `contentUrl` are URLs; a list may hold strings or such nodes.
661 *
662 * @since 2.14.1
663 *
664 * @param array $value Array-valued URL field.
665 * @return string[] URL strings to validate.
666 */
667 private function url_candidates(array $value): array {
668 $node_urls = static function (array $node): array {
669 return array_values(array_filter(
670 [$node['url'] ?? null, $node['contentUrl'] ?? null],
671 'is_string'
672 ));
673 };
674
675 if (array_values($value) !== $value) {
676 return $node_urls($value);
677 }
678
679 $urls = [];
680 foreach ($value as $item) {
681 if (is_string($item)) {
682 $urls[] = $item;
683 } elseif (is_array($item)) {
684 $urls = array_merge($urls, $node_urls($item));
685 }
686 }
687
688 return $urls;
689 }
690
691 /**
692 * Validate content lengths
693 *
694 * @since 1.0.0
695 *
696 * @param array $schema_data Schema data
697 * @param string $schema_type Schema type
698 * @return array Validation result
699 */
700 private function validate_content_lengths(array $schema_data, string $schema_type): array {
701 $result = ['valid' => true, 'warnings' => []];
702 $rules = $this->allowed_schema_types[$schema_type];
703
704 if (isset($rules['max_length'])) {
705 foreach ($rules['max_length'] as $field => $max_length) {
706 if (isset($schema_data[$field]) && is_string($schema_data[$field])) {
707 $length = strlen($schema_data[$field]);
708 if ($length > $max_length) {
709 $result['warnings'][] = "Field '{$field}' exceeds recommended length of {$max_length} characters (current: {$length})";
710 }
711 }
712 }
713 }
714
715 return $result;
716 }
717
718 /**
719 * Validate URL format and protocol
720 *
721 * @since 1.0.0
722 *
723 * @param string $url URL to validate
724 * @return bool Validation result
725 */
726 private function is_valid_url(string $url): bool {
727 // Basic URL validation
728 if (!filter_var($url, FILTER_VALIDATE_URL)) {
729 return false;
730 }
731
732 // Check allowed protocols
733 $parsed = wp_parse_url($url);
734 if (!isset($parsed['scheme']) || !in_array($parsed['scheme'], $this->allowed_protocols, true)) {
735 return false;
736 }
737
738 return true;
739 }
740
741 /**
742 * Validate date format
743 *
744 * @since 1.0.0
745 *
746 * @param string $date Date to validate
747 * @return bool Validation result
748 */
749 private function is_valid_date(string $date): bool {
750 // Check ISO 8601 format
751 $formats = [
752 'Y-m-d\TH:i:s\Z',
753 'Y-m-d\TH:i:sP',
754 'Y-m-d\TH:i:s',
755 'Y-m-d'
756 ];
757
758 foreach ($formats as $format) {
759 $parsed = \DateTime::createFromFormat($format, $date);
760 if ($parsed && $parsed->format($format) === $date) {
761 return true;
762 }
763 }
764
765 return false;
766 }
767
768 /**
769 * Check rate limiting for user
770 *
771 * @since 1.0.0
772 *
773 * @param int $user_id User ID
774 * @param string $action Action type
775 * @param int $limit Rate limit (requests per hour)
776 * @return bool Whether request is allowed
777 */
778 public function check_rate_limit(int $user_id, string $action, int $limit = 100): bool {
779 // Persist the window in a transient (object cache / options) so the limit
780 // is enforced ACROSS requests. A per-request static array — as used
781 // previously — always starts empty on a fresh PHP process and therefore
782 // never throttled anything.
783 $key = 'thinkrank_schema_rl_' . $user_id . '_' . sanitize_key($action);
784
785 // Serialize the read-modify-write with a MySQL named lock so concurrent
786 // requests can't each read the same timestamp list, individually pass the
787 // limit check, and overwrite one another — which would let bursts slip
788 // past the configured limit. GET_LOCK is DB-level, so it serializes the
789 // critical section regardless of where the transient is stored.
790 global $wpdb;
791 $lock_name = substr('tr_schema_rl_' . md5($key), 0, 64);
792 $have_lock = ($wpdb instanceof \wpdb)
793 ? (int) $wpdb->get_var($wpdb->prepare('SELECT GET_LOCK(%s, %d)', $lock_name, 3)) === 1
794 : false;
795
796 try {
797 $current_time = time();
798 $window_start = $current_time - HOUR_IN_SECONDS; // 1 hour window
799
800 $timestamps = get_transient($key);
801 if (!is_array($timestamps)) {
802 $timestamps = [];
803 }
804
805 // Drop entries outside the window.
806 $timestamps = array_values(array_filter(
807 $timestamps,
808 static function ($timestamp) use ($window_start) {
809 return (int) $timestamp > $window_start;
810 }
811 ));
812
813 // Check if limit exceeded.
814 if (count($timestamps) >= $limit) {
815 set_transient($key, $timestamps, HOUR_IN_SECONDS);
816 return false;
817 }
818
819 // Record this request.
820 $timestamps[] = $current_time;
821 set_transient($key, $timestamps, HOUR_IN_SECONDS);
822
823 return true;
824 } finally {
825 if ($have_lock) {
826 $wpdb->query($wpdb->prepare('SELECT RELEASE_LOCK(%s)', $lock_name));
827 }
828 }
829 }
830
831 /**
832 * Validate user permissions for schema operations
833 *
834 * @since 1.0.0
835 *
836 * @param string $operation Operation type
837 * @param int $user_id User ID
838 * @return array Validation result
839 */
840 public function validate_user_permissions(string $operation, int $user_id): array {
841 $result = ['valid' => false, 'errors' => []];
842
843 // Check if user exists and is logged in
844 if (!$user_id || !get_userdata($user_id)) {
845 $result['errors'][] = 'Invalid user or user not logged in';
846 return $result;
847 }
848
849 // Check operation-specific permissions.
850 //
851 // #457 loosened the route permission_callbacks to the delegable
852 // `thinkrank_schema` capability, but these handler-level checks still
853 // demanded edit_posts / publish_posts / manage_options — so a role
854 // granted Schema access could generate and validate but was denied on
855 // deploy, bulk operations and everything site-context. That is exactly
856 // the symptom #457 set out to fix (#470). A holder of thinkrank_schema
857 // satisfies any schema operation; the built-in caps remain as the
858 // fallback for roles that never went through the Role Manager.
859 $has_schema_cap = user_can($user_id, 'thinkrank_schema');
860
861 switch ($operation) {
862 case 'generate':
863 case 'validate':
864 case 'optimize':
865 if (!$has_schema_cap && !user_can($user_id, 'edit_posts')) {
866 $result['errors'][] = 'Insufficient permissions for schema generation/validation';
867 return $result;
868 }
869 break;
870
871 case 'deploy':
872 if (!$has_schema_cap && !user_can($user_id, 'publish_posts')) {
873 $result['errors'][] = 'Insufficient permissions for schema deployment';
874 return $result;
875 }
876 break;
877
878 case 'manage_settings':
879 case 'bulk_operations':
880 if (!$has_schema_cap && !user_can($user_id, 'manage_options')) {
881 $result['errors'][] = 'Insufficient permissions for schema management';
882 return $result;
883 }
884 break;
885
886 default:
887 $result['errors'][] = "Unknown operation: {$operation}";
888 return $result;
889 }
890
891 // Check rate limiting
892 $rate_limits = [
893 'generate' => 50, // 50 generations per hour
894 'validate' => 100, // 100 validations per hour
895 'deploy' => 20, // 20 deployments per hour
896 'optimize' => 30, // 30 optimizations per hour
897 'bulk_operations' => 5 // 5 bulk operations per hour
898 ];
899
900 $limit = $rate_limits[$operation] ?? 100;
901 if (!$this->check_rate_limit($user_id, $operation, $limit)) {
902 $result['errors'][] = "Rate limit exceeded for {$operation}. Please try again later.";
903 return $result;
904 }
905
906 $result['valid'] = true;
907 return $result;
908 }
909
910 /**
911 * Sanitize and validate context parameters with ownership checks
912 *
913 * @since 1.0.0
914 *
915 * @param string $context_type Context type
916 * @param int|null $context_id Context ID
917 * @param int|null $user_id User ID for ownership validation
918 * @return array Validation result
919 */
920 public function validate_context_parameters(string $context_type, ?int $context_id, ?int $user_id = null): array {
921 $result = ['valid' => false, 'errors' => [], 'sanitized_data' => []];
922
923 // Sanitize context type
924 $context_type = sanitize_key($context_type);
925 $allowed_types = ['site', 'post', 'page', 'product'];
926
927 if (!in_array($context_type, $allowed_types, true)) {
928 $result['errors'][] = "Invalid context type: {$context_type}";
929 return $result;
930 }
931
932 // Validate context ID and ownership
933 if ($context_type !== 'site') {
934 if (!$context_id || $context_id <= 0) {
935 $result['errors'][] = 'Context ID is required for non-site contexts';
936 return $result;
937 }
938
939 $context_id = absint($context_id);
940 $post = get_post($context_id);
941
942 if (!$post) {
943 $result['errors'][] = "Invalid context ID: {$context_id}";
944 return $result;
945 }
946
947 // SECURITY: Check context ownership.
948 // Fails closed on a missing user — a security helper that waves the
949 // check through when it cannot identify the caller is the wrong way
950 // round. Every caller passes a real ID, so this only tightens an
951 // unreachable path.
952 if (!$user_id || !$this->validate_context_ownership($post, $user_id)) {
953 $result['errors'][] = "Access denied: You don't have permission to modify this {$context_type}";
954 return $result;
955 }
956 } else {
957 $context_id = null; // Site context doesn't use ID
958
959 // SECURITY: Check site-level permissions for site context.
960 // user_can($user_id, …) rather than current_user_can() so this
961 // agrees with the rest of the validator outside a REST request,
962 // where the current user and $user_id can differ (cron, CLI).
963 //
964 // Accepts the delegable `thinkrank_schema` capability as well as
965 // manage_options: /schema/settings already lets a delegated role
966 // edit site schema settings, so blocking site-context generate and
967 // deploy for the same role was inconsistent (#470).
968 if (!$user_id || (!user_can($user_id, 'thinkrank_schema') && !user_can($user_id, 'manage_options'))) {
969 $result['errors'][] = 'Access denied: You need administrator privileges for site-level schema operations';
970 return $result;
971 }
972 }
973
974 $result['valid'] = true;
975 $result['sanitized_data'] = [
976 'context_type' => $context_type,
977 'context_id' => $context_id
978 ];
979
980 return $result;
981 }
982
983 /**
984 * Validate context ownership
985 *
986 * @since 1.0.0
987 *
988 * @param \WP_Post $post Post object
989 * @param int $user_id User ID
990 * @return bool Whether user has permission
991 */
992 private function validate_context_ownership(\WP_Post $post, int $user_id): bool {
993 // `edit_post` is a meta capability: map_meta_cap() already resolves
994 // authorship, published state, and edit_others_posts for this specific
995 // post. It is the whole check.
996 //
997 // Two fallbacks used to sit under it and between them defeated the
998 // function. One granted access on authorship alone, which hands a
999 // Contributor back a post they lost edit rights to once it published.
1000 // The other granted access to anyone holding the post type's *general*
1001 // edit_posts capability — a cap every Author and Contributor has, that
1002 // says nothing about this post — so ownership validation returned true
1003 // for every post on the site (#326).
1004 //
1005 // user_can() rather than current_user_can() so the method honours the
1006 // $user_id it was handed, matching validate_user_permissions().
1007 return user_can($user_id, 'edit_post', $post->ID);
1008 }
1009
1010 /**
1011 * Validate JSON depth to prevent JSON bomb attacks
1012 *
1013 * @since 1.0.0
1014 *
1015 * @param mixed $data Data to validate
1016 * @param int $depth Current depth level
1017 * @return bool Whether depth is within limits
1018 */
1019 private function validate_json_depth($data, int $depth = 0): bool {
1020 if ($depth > self::MAX_JSON_DEPTH) {
1021 return false;
1022 }
1023
1024 if (is_array($data)) {
1025 foreach ($data as $value) {
1026 if (!$this->validate_json_depth($value, $depth + 1)) {
1027 return false;
1028 }
1029 }
1030 }
1031
1032 return true;
1033 }
1034
1035 /**
1036 * Validate and sanitize options array
1037 *
1038 * @since 1.0.0
1039 *
1040 * @param array $options Options array
1041 * @return array Sanitized options
1042 */
1043 public function sanitize_options(array $options): array {
1044 $sanitized = [];
1045 // Anything omitted here is dropped before the manager sees it, which is
1046 // why apply_content_schema_settings_from_options() and the per-request
1047 // schema-type opt-in were unreachable from REST (#470). The list now
1048 // covers every option the generate path actually reads.
1049 //
1050 // `validation_level` previously allowed 'basic' and rejected 'lenient',
1051 // disagreeing with Schema_Settings_Config, validate_settings() and the
1052 // update-settings ability, which all use 'lenient'.
1053 // `deployment_method` no longer advertises microdata/rdfa, which
1054 // determine_deployment_method() hardcodes away to json_ld anyway.
1055 $allowed_options = [
1056 'deployment_method' => ['json_ld'],
1057 'validation_level' => ['strict', 'moderate', 'lenient'],
1058 'include_meta' => 'boolean',
1059 'minify_output' => 'boolean',
1060 'cache_duration' => 'integer',
1061 'rich_snippets_optimization' => 'boolean',
1062 'knowledge_graph' => 'boolean',
1063 'auto_generate_schema' => 'boolean',
1064 'enable_article_schema' => 'boolean',
1065 'enable_faq_schema' => 'boolean',
1066 'enable_howto_schema' => 'boolean',
1067 'enable_product_schema' => 'boolean',
1068 'enable_local_business' => 'boolean',
1069 'enable_breadcrumbs_schema' => 'boolean',
1070 'enable_accordion_faq_schema' => 'boolean',
1071 ];
1072
1073 foreach ($options as $key => $value) {
1074 $sanitized_key = sanitize_key($key);
1075
1076 if (!isset($allowed_options[$sanitized_key])) {
1077 continue; // Skip unknown options
1078 }
1079
1080 $rule = $allowed_options[$sanitized_key];
1081
1082 if (is_array($rule)) {
1083 // Enum validation
1084 if (in_array($value, $rule, true)) {
1085 $sanitized[$sanitized_key] = $value;
1086 }
1087 } elseif ($rule === 'boolean') {
1088 $sanitized[$sanitized_key] = (bool) $value;
1089 } elseif ($rule === 'integer') {
1090 $sanitized[$sanitized_key] = absint($value);
1091 } else {
1092 $sanitized[$sanitized_key] = sanitize_text_field($value);
1093 }
1094 }
1095
1096 return $sanitized;
1097 }
1098 }
1099