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

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