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

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