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

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