PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.5.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.5.0
2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 1.26.0 1.25.0 trunk 1.0.0 1.0.1 1.0.2 1.1.0 1.10.0 All 48 releases
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.5.0, at includes/seo/class-schema-input-validator.php

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