PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 1.0.2
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v1.0.2
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 1.0.2, at includes/seo/class-schema-input-validator.php

906 lines 29.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 ];
114
115 /**
116 * Dangerous HTML tags and attributes to strip
117 *
118 * @since 1.0.0
119 * @var array
120 */
121 private array $dangerous_tags = [
122 'script', 'iframe', 'object', 'embed', 'form', 'input', 'button',
123 'link', 'meta', 'style', 'base', 'frame', 'frameset'
124 ];
125
126 /**
127 * Allowed URL protocols
128 *
129 * @since 1.0.0
130 * @var array
131 */
132 private array $allowed_protocols = ['http', 'https', 'mailto', 'tel'];
133
134 /**
135 * Rate limiting storage
136 *
137 * @since 1.0.0
138 * @var array
139 */
140 private static array $rate_limits = [];
141
142 /**
143 * Maximum allowed JSON depth to prevent JSON bomb attacks
144 *
145 * @since 1.0.0
146 * @var int
147 */
148 private const MAX_JSON_DEPTH = 10;
149
150 /**
151 * Maximum payload size in bytes (500KB)
152 *
153 * @since 1.0.0
154 * @var int
155 */
156 private const MAX_PAYLOAD_SIZE = 512000;
157
158 /**
159 * Maximum array size (number of elements)
160 *
161 * @since 1.0.0
162 * @var int
163 */
164 private const MAX_ARRAY_SIZE = 100;
165
166 /**
167 * Maximum string length for any single field
168 *
169 * @since 1.0.0
170 * @var int
171 */
172 private const MAX_STRING_LENGTH = 10000;
173
174 /**
175 * Validate and sanitize schema data
176 *
177 * @since 1.0.0
178 *
179 * @param array $schema_data Raw schema data
180 * @param string $schema_type Schema type
181 * @return array Validation result with sanitized data
182 */
183 public function validate_schema_data(array $schema_data, string $schema_type): array {
184 $result = [
185 'valid' => false,
186 'sanitized_data' => [],
187 'errors' => [],
188 'warnings' => []
189 ];
190
191 try {
192 // 1. Validate payload size to prevent DoS attacks
193 $size_validation = $this->validate_payload_size($schema_data);
194 if (!$size_validation['valid']) {
195 $result['errors'] = array_merge($result['errors'], $size_validation['errors']);
196 return $result;
197 }
198
199 // 2. Validate schema type
200 if (!$this->is_valid_schema_type($schema_type)) {
201 $result['errors'][] = "Invalid schema type: {$schema_type}";
202 return $result;
203 }
204
205 // 3. Validate JSON structure and depth
206 $structure_validation = $this->validate_json_structure($schema_data, $schema_type);
207 if (!$structure_validation['valid']) {
208 $result['errors'] = array_merge($result['errors'], $structure_validation['errors']);
209 return $result;
210 }
211
212 // 3.1. Validate JSON depth to prevent JSON bomb attacks
213 if (!$this->validate_json_depth($schema_data)) {
214 $result['errors'][] = 'Schema data exceeds maximum allowed depth (' . self::MAX_JSON_DEPTH . ' levels)';
215 return $result;
216 }
217
218 // 4. Sanitize all input data
219 $sanitized_data = $this->sanitize_schema_data($schema_data);
220
221 // 5. Validate required fields
222 $field_validation = $this->validate_required_fields($sanitized_data, $schema_type);
223 if (!$field_validation['valid']) {
224 $result['errors'] = array_merge($result['errors'], $field_validation['errors']);
225 }
226
227 // 6. Validate data types and formats
228 $format_validation = $this->validate_data_formats($sanitized_data, $schema_type);
229 if (!$format_validation['valid']) {
230 $result['errors'] = array_merge($result['errors'], $format_validation['errors']);
231 }
232 $result['warnings'] = array_merge($result['warnings'], $format_validation['warnings']);
233
234 // 7. Validate content length limits
235 $length_validation = $this->validate_content_lengths($sanitized_data, $schema_type);
236 if (!$length_validation['valid']) {
237 $result['warnings'] = array_merge($result['warnings'], $length_validation['warnings']);
238 }
239
240 $result['valid'] = empty($result['errors']);
241 $result['sanitized_data'] = $sanitized_data;
242
243 } catch (\Exception $e) {
244 $result['errors'][] = 'Schema validation failed: ' . $e->getMessage();
245 }
246
247 return $result;
248 }
249
250 /**
251 * Validate payload size to prevent DoS attacks
252 *
253 * @since 1.0.0
254 *
255 * @param array $schema_data Schema data to validate
256 * @return array Validation result
257 */
258 private function validate_payload_size(array $schema_data): array {
259 $result = ['valid' => true, 'errors' => []];
260
261 // Calculate approximate payload size
262 $payload_size = strlen(wp_json_encode($schema_data));
263
264 if ($payload_size > self::MAX_PAYLOAD_SIZE) {
265 $result['errors'][] = sprintf(
266 'Payload size (%s) exceeds maximum allowed size (%s)',
267 size_format($payload_size),
268 size_format(self::MAX_PAYLOAD_SIZE)
269 );
270 $result['valid'] = false;
271 }
272
273 // Validate array sizes and string lengths recursively
274 $structure_validation = $this->validate_data_structure($schema_data);
275 if (!$structure_validation['valid']) {
276 $result['errors'] = array_merge($result['errors'], $structure_validation['errors']);
277 $result['valid'] = false;
278 }
279
280 return $result;
281 }
282
283 /**
284 * Validate data structure (arrays and strings)
285 *
286 * @since 1.0.0
287 *
288 * @param mixed $data Data to validate
289 * @param string $path Current path for error reporting
290 * @return array Validation result
291 */
292 private function validate_data_structure($data, string $path = ''): array {
293 $result = ['valid' => true, 'errors' => []];
294
295 if (is_array($data)) {
296 // Check array size
297 if (count($data) > self::MAX_ARRAY_SIZE) {
298 $result['errors'][] = sprintf(
299 'Array at path "%s" contains %d elements, maximum allowed is %d',
300 $path ?: 'root',
301 count($data),
302 self::MAX_ARRAY_SIZE
303 );
304 $result['valid'] = false;
305 }
306
307 // Recursively validate nested data
308 foreach ($data as $key => $value) {
309 $current_path = $path ? "{$path}.{$key}" : $key;
310 $nested_validation = $this->validate_data_structure($value, $current_path);
311 if (!$nested_validation['valid']) {
312 $result['errors'] = array_merge($result['errors'], $nested_validation['errors']);
313 $result['valid'] = false;
314 }
315 }
316 } elseif (is_string($data)) {
317 // Check string length
318 if (strlen($data) > self::MAX_STRING_LENGTH) {
319 $result['errors'][] = sprintf(
320 'String at path "%s" is %d characters, maximum allowed is %d',
321 $path ?: 'value',
322 strlen($data),
323 self::MAX_STRING_LENGTH
324 );
325 $result['valid'] = false;
326 }
327 }
328
329 return $result;
330 }
331
332 /**
333 * Validate schema type
334 *
335 * @since 1.0.0
336 *
337 * @param string $schema_type Schema type to validate
338 * @return bool Validation result
339 */
340 private function is_valid_schema_type(string $schema_type): bool {
341 return isset($this->allowed_schema_types[$schema_type]);
342 }
343
344 /**
345 * Validate JSON structure
346 *
347 * @since 1.0.0
348 *
349 * @param array $schema_data Schema data
350 * @param string $schema_type Schema type
351 * @return array Validation result
352 */
353 private function validate_json_structure(array $schema_data, string $schema_type): array {
354 $result = ['valid' => true, 'errors' => []];
355
356 // Check for required @context
357 if (!isset($schema_data['@context'])) {
358 $result['errors'][] = 'Missing required @context field';
359 $result['valid'] = false;
360 } elseif ($schema_data['@context'] !== 'https://schema.org') {
361 $result['errors'][] = 'Invalid @context value. Must be "https://schema.org"';
362 $result['valid'] = false;
363 }
364
365 // Check for required @type
366 if (!isset($schema_data['@type'])) {
367 $result['errors'][] = 'Missing required @type field';
368 $result['valid'] = false;
369 } elseif ($schema_data['@type'] !== $schema_type) {
370 $result['errors'][] = "Schema @type '{$schema_data['@type']}' does not match expected type '{$schema_type}'";
371 $result['valid'] = false;
372 }
373
374 return $result;
375 }
376
377 /**
378 * Sanitize schema data recursively
379 *
380 * @since 1.0.0
381 *
382 * @param mixed $data Data to sanitize
383 * @param string $field_key Current field key for context-aware sanitization
384 * @return mixed Sanitized data
385 */
386 private function sanitize_schema_data($data, string $field_key = '') {
387 if (is_array($data)) {
388 $sanitized = [];
389 foreach ($data as $key => $value) {
390 $sanitized_key = $this->sanitize_key($key);
391 $sanitized[$sanitized_key] = $this->sanitize_schema_data($value, $sanitized_key);
392 }
393 return $sanitized;
394 }
395
396 if (is_string($data)) {
397 return $this->sanitize_string_value($data, $field_key);
398 }
399
400 if (is_numeric($data)) {
401 return $this->sanitize_numeric_value($data);
402 }
403
404 if (is_bool($data)) {
405 return $data;
406 }
407
408 // For other types, convert to string and sanitize
409 return $this->sanitize_string_value((string) $data, $field_key);
410 }
411
412 /**
413 * Sanitize array key
414 *
415 * @since 1.0.0
416 *
417 * @param string $key Array key
418 * @return string Sanitized key
419 */
420 private function sanitize_key($key): string {
421 // Ensure key is a string first
422 if (!is_string($key)) {
423 return (string) $key;
424 }
425
426 // For schema data, preserve the original key names to maintain case sensitivity
427 // Schema.org properties are case-sensitive (e.g., startDate, not startdate)
428 // Only do basic validation without changing the case
429 if (preg_match('/^[a-zA-Z@][a-zA-Z0-9@_-]*$/', $key)) {
430 return $key; // Return as-is if it's a valid schema property name
431 }
432
433 // Fallback to WordPress sanitization for invalid keys
434 return sanitize_key($key);
435 }
436
437 /**
438 * Sanitize string value with context-aware sanitization
439 *
440 * @since 1.0.0
441 *
442 * @param string $value String value
443 * @param string $field_name Field name for context-aware sanitization
444 * @return string Sanitized value
445 */
446 private function sanitize_string_value(string $value, string $field_name = ''): string {
447 // Handle URLs differently to preserve valid URL structure
448 if (in_array($field_name, ['url', 'sameAs', 'logo', 'image', 'mainEntityOfPage'])) {
449 return esc_url_raw($value);
450 }
451
452 // Handle email fields
453 if (in_array($field_name, ['email'])) {
454 return sanitize_email($value);
455 }
456
457 // Handle description fields that may contain basic HTML
458 if (in_array($field_name, ['description', 'text', 'articleBody'])) {
459 // Allow basic HTML but strip dangerous tags
460 $allowed_html = [
461 'p' => [],
462 'br' => [],
463 'strong' => [],
464 'em' => [],
465 'b' => [],
466 'i' => []
467 ];
468 $value = wp_kses($value, $allowed_html);
469 } else {
470 // For other fields, remove all HTML tags
471 $value = wp_strip_all_tags($value);
472 }
473
474 // Sanitize for database storage
475 $value = sanitize_text_field($value);
476
477 // Additional XSS protection for output
478 $value = esc_html($value);
479
480 return trim($value);
481 }
482
483 /**
484 * Sanitize numeric value
485 *
486 * @since 1.0.0
487 *
488 * @param mixed $value Numeric value
489 * @return float|int Sanitized numeric value
490 */
491 private function sanitize_numeric_value($value) {
492 if (is_int($value) || ctype_digit((string) $value)) {
493 return (int) $value;
494 }
495
496 return (float) $value;
497 }
498
499 /**
500 * Validate required fields
501 *
502 * @since 1.0.0
503 *
504 * @param array $schema_data Schema data
505 * @param string $schema_type Schema type
506 * @return array Validation result
507 */
508 private function validate_required_fields(array $schema_data, string $schema_type): array {
509 $result = ['valid' => true, 'errors' => []];
510 $rules = $this->allowed_schema_types[$schema_type];
511
512 foreach ($rules['required_fields'] as $field) {
513 if (!isset($schema_data[$field]) || empty($schema_data[$field])) {
514 $result['errors'][] = "Missing required field: {$field}";
515 $result['valid'] = false;
516 }
517 }
518 return $result;
519 }
520
521 /**
522 * Validate data formats
523 *
524 * @since 1.0.0
525 *
526 * @param array $schema_data Schema data
527 * @param string $schema_type Schema type
528 * @return array Validation result
529 */
530 private function validate_data_formats(array $schema_data, string $schema_type): array {
531 $result = ['valid' => true, 'errors' => [], 'warnings' => []];
532
533 foreach ($schema_data as $field => $value) {
534 if (is_string($value)) {
535 // Validate URLs
536 if (in_array($field, ['url', 'sameAs', 'logo', 'image']) && !empty($value)) {
537 if (!$this->is_valid_url($value)) {
538 $result['errors'][] = "Invalid URL format for field: {$field}";
539 $result['valid'] = false;
540 }
541 }
542
543 // Validate email addresses
544 if (in_array($field, ['email']) && !empty($value)) {
545 if (!is_email($value)) {
546 $result['errors'][] = "Invalid email format for field: {$field}";
547 $result['valid'] = false;
548 }
549 }
550
551 // Validate dates
552 if (in_array($field, ['datePublished', 'dateModified']) && !empty($value)) {
553 if (!$this->is_valid_date($value)) {
554 $result['warnings'][] = "Invalid date format for field: {$field}. Use ISO 8601 format.";
555 }
556 }
557 }
558 }
559
560 return $result;
561 }
562
563 /**
564 * Validate content lengths
565 *
566 * @since 1.0.0
567 *
568 * @param array $schema_data Schema data
569 * @param string $schema_type Schema type
570 * @return array Validation result
571 */
572 private function validate_content_lengths(array $schema_data, string $schema_type): array {
573 $result = ['valid' => true, 'warnings' => []];
574 $rules = $this->allowed_schema_types[$schema_type];
575
576 if (isset($rules['max_length'])) {
577 foreach ($rules['max_length'] as $field => $max_length) {
578 if (isset($schema_data[$field]) && is_string($schema_data[$field])) {
579 $length = strlen($schema_data[$field]);
580 if ($length > $max_length) {
581 $result['warnings'][] = "Field '{$field}' exceeds recommended length of {$max_length} characters (current: {$length})";
582 }
583 }
584 }
585 }
586
587 return $result;
588 }
589
590 /**
591 * Validate URL format and protocol
592 *
593 * @since 1.0.0
594 *
595 * @param string $url URL to validate
596 * @return bool Validation result
597 */
598 private function is_valid_url(string $url): bool {
599 // Basic URL validation
600 if (!filter_var($url, FILTER_VALIDATE_URL)) {
601 return false;
602 }
603
604 // Check allowed protocols
605 $parsed = wp_parse_url($url);
606 if (!isset($parsed['scheme']) || !in_array($parsed['scheme'], $this->allowed_protocols)) {
607 return false;
608 }
609
610 return true;
611 }
612
613 /**
614 * Validate date format
615 *
616 * @since 1.0.0
617 *
618 * @param string $date Date to validate
619 * @return bool Validation result
620 */
621 private function is_valid_date(string $date): bool {
622 // Check ISO 8601 format
623 $formats = [
624 'Y-m-d\TH:i:s\Z',
625 'Y-m-d\TH:i:sP',
626 'Y-m-d\TH:i:s',
627 'Y-m-d'
628 ];
629
630 foreach ($formats as $format) {
631 $parsed = \DateTime::createFromFormat($format, $date);
632 if ($parsed && $parsed->format($format) === $date) {
633 return true;
634 }
635 }
636
637 return false;
638 }
639
640 /**
641 * Check rate limiting for user
642 *
643 * @since 1.0.0
644 *
645 * @param int $user_id User ID
646 * @param string $action Action type
647 * @param int $limit Rate limit (requests per hour)
648 * @return bool Whether request is allowed
649 */
650 public function check_rate_limit(int $user_id, string $action, int $limit = 100): bool {
651 $key = "rate_limit_{$user_id}_{$action}";
652 $current_time = time();
653 $window_start = $current_time - 3600; // 1 hour window
654
655 // Initialize if not exists
656 if (!isset(self::$rate_limits[$key])) {
657 self::$rate_limits[$key] = [];
658 }
659
660 // Clean old entries
661 self::$rate_limits[$key] = array_filter(
662 self::$rate_limits[$key],
663 function($timestamp) use ($window_start) {
664 return $timestamp > $window_start;
665 }
666 );
667
668 // Check if limit exceeded
669 if (count(self::$rate_limits[$key]) >= $limit) {
670 return false;
671 }
672
673 // Add current request
674 self::$rate_limits[$key][] = $current_time;
675
676 return true;
677 }
678
679 /**
680 * Validate user permissions for schema operations
681 *
682 * @since 1.0.0
683 *
684 * @param string $operation Operation type
685 * @param int $user_id User ID
686 * @return array Validation result
687 */
688 public function validate_user_permissions(string $operation, int $user_id): array {
689 $result = ['valid' => false, 'errors' => []];
690
691 // Check if user exists and is logged in
692 if (!$user_id || !get_userdata($user_id)) {
693 $result['errors'][] = 'Invalid user or user not logged in';
694 return $result;
695 }
696
697 // Check operation-specific permissions
698 switch ($operation) {
699 case 'generate':
700 case 'validate':
701 case 'optimize':
702 if (!user_can($user_id, 'edit_posts')) {
703 $result['errors'][] = 'Insufficient permissions for schema generation/validation';
704 return $result;
705 }
706 break;
707
708 case 'deploy':
709 if (!user_can($user_id, 'publish_posts')) {
710 $result['errors'][] = 'Insufficient permissions for schema deployment';
711 return $result;
712 }
713 break;
714
715 case 'manage_settings':
716 case 'bulk_operations':
717 if (!user_can($user_id, 'manage_options')) {
718 $result['errors'][] = 'Insufficient permissions for schema management';
719 return $result;
720 }
721 break;
722
723 default:
724 $result['errors'][] = "Unknown operation: {$operation}";
725 return $result;
726 }
727
728 // Check rate limiting
729 $rate_limits = [
730 'generate' => 50, // 50 generations per hour
731 'validate' => 100, // 100 validations per hour
732 'deploy' => 20, // 20 deployments per hour
733 'optimize' => 30, // 30 optimizations per hour
734 'bulk_operations' => 5 // 5 bulk operations per hour
735 ];
736
737 $limit = $rate_limits[$operation] ?? 100;
738 if (!$this->check_rate_limit($user_id, $operation, $limit)) {
739 $result['errors'][] = "Rate limit exceeded for {$operation}. Please try again later.";
740 return $result;
741 }
742
743 $result['valid'] = true;
744 return $result;
745 }
746
747 /**
748 * Sanitize and validate context parameters with ownership checks
749 *
750 * @since 1.0.0
751 *
752 * @param string $context_type Context type
753 * @param int|null $context_id Context ID
754 * @param int|null $user_id User ID for ownership validation
755 * @return array Validation result
756 */
757 public function validate_context_parameters(string $context_type, ?int $context_id, ?int $user_id = null): array {
758 $result = ['valid' => false, 'errors' => [], 'sanitized_data' => []];
759
760 // Sanitize context type
761 $context_type = sanitize_key($context_type);
762 $allowed_types = ['site', 'post', 'page', 'product'];
763
764 if (!in_array($context_type, $allowed_types, true)) {
765 $result['errors'][] = "Invalid context type: {$context_type}";
766 return $result;
767 }
768
769 // Validate context ID and ownership
770 if ($context_type !== 'site') {
771 if (!$context_id || $context_id <= 0) {
772 $result['errors'][] = 'Context ID is required for non-site contexts';
773 return $result;
774 }
775
776 $context_id = absint($context_id);
777 $post = get_post($context_id);
778
779 if (!$post) {
780 $result['errors'][] = "Invalid context ID: {$context_id}";
781 return $result;
782 }
783
784 // SECURITY: Check context ownership
785 if ($user_id && !$this->validate_context_ownership($post, $user_id)) {
786 $result['errors'][] = "Access denied: You don't have permission to modify this {$context_type}";
787 return $result;
788 }
789 } else {
790 $context_id = null; // Site context doesn't use ID
791
792 // SECURITY: Check site-level permissions for site context
793 if ($user_id && !current_user_can('manage_options')) {
794 $result['errors'][] = 'Access denied: You need administrator privileges for site-level schema operations';
795 return $result;
796 }
797 }
798
799 $result['valid'] = true;
800 $result['sanitized_data'] = [
801 'context_type' => $context_type,
802 'context_id' => $context_id
803 ];
804
805 return $result;
806 }
807
808 /**
809 * Validate context ownership
810 *
811 * @since 1.0.0
812 *
813 * @param \WP_Post $post Post object
814 * @param int $user_id User ID
815 * @return bool Whether user has permission
816 */
817 private function validate_context_ownership(\WP_Post $post, int $user_id): bool {
818 // Check if user can edit this specific post
819 if (current_user_can('edit_post', $post->ID)) {
820 return true;
821 }
822
823 // Check if user is the post author
824 if ($post->post_author == $user_id) {
825 return true;
826 }
827
828 // Check if user has general edit capabilities for this post type
829 $post_type_object = get_post_type_object($post->post_type);
830 if ($post_type_object && current_user_can($post_type_object->cap->edit_posts)) {
831 return true;
832 }
833
834 return false;
835 }
836
837 /**
838 * Validate JSON depth to prevent JSON bomb attacks
839 *
840 * @since 1.0.0
841 *
842 * @param mixed $data Data to validate
843 * @param int $depth Current depth level
844 * @return bool Whether depth is within limits
845 */
846 private function validate_json_depth($data, int $depth = 0): bool {
847 if ($depth > self::MAX_JSON_DEPTH) {
848 return false;
849 }
850
851 if (is_array($data)) {
852 foreach ($data as $value) {
853 if (!$this->validate_json_depth($value, $depth + 1)) {
854 return false;
855 }
856 }
857 }
858
859 return true;
860 }
861
862 /**
863 * Validate and sanitize options array
864 *
865 * @since 1.0.0
866 *
867 * @param array $options Options array
868 * @return array Sanitized options
869 */
870 public function sanitize_options(array $options): array {
871 $sanitized = [];
872 $allowed_options = [
873 'deployment_method' => ['json_ld', 'microdata', 'rdfa'],
874 'validation_level' => ['strict', 'moderate', 'basic'],
875 'include_meta' => 'boolean',
876 'minify_output' => 'boolean',
877 'cache_duration' => 'integer'
878 ];
879
880 foreach ($options as $key => $value) {
881 $sanitized_key = sanitize_key($key);
882
883 if (!isset($allowed_options[$sanitized_key])) {
884 continue; // Skip unknown options
885 }
886
887 $rule = $allowed_options[$sanitized_key];
888
889 if (is_array($rule)) {
890 // Enum validation
891 if (in_array($value, $rule, true)) {
892 $sanitized[$sanitized_key] = $value;
893 }
894 } elseif ($rule === 'boolean') {
895 $sanitized[$sanitized_key] = (bool) $value;
896 } elseif ($rule === 'integer') {
897 $sanitized[$sanitized_key] = absint($value);
898 } else {
899 $sanitized[$sanitized_key] = sanitize_text_field($value);
900 }
901 }
902
903 return $sanitized;
904 }
905 }
906