[ 'required_fields' => ['@type', 'headline', 'author'], 'optional_fields' => ['description', 'datePublished', 'dateModified', 'image', 'url'], 'max_length' => ['headline' => 110, 'description' => 160] ], 'BlogPosting' => [ 'required_fields' => ['@type', 'headline', 'author'], 'optional_fields' => ['description', 'datePublished', 'dateModified', 'image', 'url'], 'max_length' => ['headline' => 110, 'description' => 160] ], 'TechnicalArticle' => [ 'required_fields' => ['@type', 'headline', 'author'], 'optional_fields' => ['description', 'datePublished', 'dateModified', 'image', 'url', 'dependencies', 'proficiencyLevel'], 'max_length' => ['headline' => 110, 'description' => 160] ], 'NewsArticle' => [ 'required_fields' => ['@type', 'headline', 'author'], 'optional_fields' => ['description', 'datePublished', 'dateModified', 'image', 'url', 'dateline'], 'max_length' => ['headline' => 110, 'description' => 160] ], 'ScholarlyArticle' => [ 'required_fields' => ['@type', 'headline', 'author'], 'optional_fields' => ['description', 'datePublished', 'dateModified', 'image', 'url', 'citation', 'abstract'], 'max_length' => ['headline' => 110, 'description' => 160] ], 'Report' => [ 'required_fields' => ['@type', 'headline', 'author'], 'optional_fields' => ['description', 'datePublished', 'dateModified', 'image', 'url'], 'max_length' => ['headline' => 110, 'description' => 160] ], 'Organization' => [ 'required_fields' => ['@type', 'name'], 'optional_fields' => ['description', 'url', 'logo', 'address', 'contactPoint'], 'max_length' => ['name' => 100, 'description' => 160] ], 'LocalBusiness' => [ 'required_fields' => ['@type', 'name', 'address'], 'optional_fields' => ['description', 'url', 'telephone', 'openingHours'], 'max_length' => ['name' => 100, 'description' => 160] ], 'Product' => [ 'required_fields' => ['@type', 'name'], 'optional_fields' => ['description', 'image', 'brand', 'offers'], 'max_length' => ['name' => 100, 'description' => 160] ], 'WebSite' => [ 'required_fields' => ['@type', 'name', 'url'], 'optional_fields' => ['description', 'potentialAction'], 'max_length' => ['name' => 100, 'description' => 160] ], 'FAQPage' => [ 'required_fields' => ['@type', 'mainEntity'], 'optional_fields' => ['name', 'description'], 'max_length' => ['name' => 100, 'description' => 160] ], 'SoftwareApplication' => [ 'required_fields' => ['@type', 'name'], 'optional_fields' => ['description', 'applicationCategory', 'operatingSystem'], 'max_length' => ['name' => 100, 'description' => 160] ], 'Event' => [ 'required_fields' => ['@type', 'name', 'startDate'], 'optional_fields' => ['description', 'location', 'organizer', 'endDate', 'eventStatus', 'eventAttendanceMode', 'url'], 'max_length' => ['name' => 100, 'description' => 160] ], 'Person' => [ 'required_fields' => ['@type', 'name'], 'optional_fields' => ['description', 'url', 'image', 'jobTitle'], 'max_length' => ['name' => 100, 'description' => 160] ], 'HowTo' => [ 'required_fields' => ['@type', 'name'], 'optional_fields' => ['description', 'totalTime', 'prepTime', 'difficulty', 'estimatedCost', 'supply', 'tool', 'step', 'yield', 'image', 'video'], 'max_length' => ['name' => 100, 'description' => 160] ], 'BreadcrumbList' => [ 'required_fields' => ['@type', 'itemListElement'], 'optional_fields' => ['name', 'description', 'numberOfItems'], 'max_length' => ['name' => 100, 'description' => 160] ], 'VideoObject' => [ 'required_fields' => ['@type', 'name', 'thumbnailUrl', 'uploadDate'], 'optional_fields' => ['description', 'contentUrl', 'embedUrl', 'duration', 'url'], 'max_length' => ['name' => 110, 'description' => 160] ] ]; /** * Dangerous HTML tags and attributes to strip * * @since 1.0.0 * @var array */ private array $dangerous_tags = [ 'script', 'iframe', 'object', 'embed', 'form', 'input', 'button', 'link', 'meta', 'style', 'base', 'frame', 'frameset' ]; /** * Allowed URL protocols * * @since 1.0.0 * @var array */ private array $allowed_protocols = ['http', 'https', 'mailto', 'tel']; /** * Maximum allowed JSON depth to prevent JSON bomb attacks * * @since 1.0.0 * @var int */ private const MAX_JSON_DEPTH = 10; /** * Maximum payload size in bytes (500KB) * * @since 1.0.0 * @var int */ private const MAX_PAYLOAD_SIZE = 512000; /** * Maximum array size (number of elements) * * @since 1.0.0 * @var int */ private const MAX_ARRAY_SIZE = 100; /** * Maximum string length for any single field * * @since 1.0.0 * @var int */ private const MAX_STRING_LENGTH = 10000; /** * Validate and sanitize schema data * * @since 1.0.0 * * @param array $schema_data Raw schema data * @param string $schema_type Schema type * @return array Validation result with sanitized data */ public function validate_schema_data(array $schema_data, string $schema_type): array { $result = [ 'valid' => false, 'sanitized_data' => [], 'errors' => [], 'warnings' => [] ]; try { // 1. Validate payload size to prevent DoS attacks $size_validation = $this->validate_payload_size($schema_data); if (!$size_validation['valid']) { $result['errors'] = array_merge($result['errors'], $size_validation['errors']); return $result; } // 2. Validate schema type if (!$this->is_valid_schema_type($schema_type)) { $result['errors'][] = "Invalid schema type: {$schema_type}"; return $result; } // 3. Validate JSON structure and depth $structure_validation = $this->validate_json_structure($schema_data, $schema_type); if (!$structure_validation['valid']) { $result['errors'] = array_merge($result['errors'], $structure_validation['errors']); return $result; } // 3.1. Validate JSON depth to prevent JSON bomb attacks if (!$this->validate_json_depth($schema_data)) { $result['errors'][] = 'Schema data exceeds maximum allowed depth (' . self::MAX_JSON_DEPTH . ' levels)'; return $result; } // 4. Sanitize all input data $sanitized_data = $this->sanitize_schema_data($schema_data); // 5. Validate required fields $field_validation = $this->validate_required_fields($sanitized_data, $schema_type); if (!$field_validation['valid']) { $result['errors'] = array_merge($result['errors'], $field_validation['errors']); } // 6. Validate data types and formats $format_validation = $this->validate_data_formats($sanitized_data, $schema_type); if (!$format_validation['valid']) { $result['errors'] = array_merge($result['errors'], $format_validation['errors']); } $result['warnings'] = array_merge($result['warnings'], $format_validation['warnings']); // 7. Validate content length limits $length_validation = $this->validate_content_lengths($sanitized_data, $schema_type); if (!$length_validation['valid']) { $result['warnings'] = array_merge($result['warnings'], $length_validation['warnings']); } $result['valid'] = empty($result['errors']); $result['sanitized_data'] = $sanitized_data; } catch (\Exception $e) { $result['errors'][] = 'Schema validation failed: ' . $e->getMessage(); } return $result; } /** * Validate payload size to prevent DoS attacks * * @since 1.0.0 * * @param array $schema_data Schema data to validate * @return array Validation result */ private function validate_payload_size(array $schema_data): array { $result = ['valid' => true, 'errors' => []]; // Calculate approximate payload size $payload_size = strlen(wp_json_encode($schema_data)); if ($payload_size > self::MAX_PAYLOAD_SIZE) { $result['errors'][] = sprintf( 'Payload size (%s) exceeds maximum allowed size (%s)', size_format($payload_size), size_format(self::MAX_PAYLOAD_SIZE) ); $result['valid'] = false; } // Validate array sizes and string lengths recursively $structure_validation = $this->validate_data_structure($schema_data); if (!$structure_validation['valid']) { $result['errors'] = array_merge($result['errors'], $structure_validation['errors']); $result['valid'] = false; } return $result; } /** * Validate data structure (arrays and strings) * * @since 1.0.0 * * @param mixed $data Data to validate * @param string $path Current path for error reporting * @return array Validation result */ private function validate_data_structure($data, string $path = ''): array { $result = ['valid' => true, 'errors' => []]; if (is_array($data)) { // Check array size if (count($data) > self::MAX_ARRAY_SIZE) { $result['errors'][] = sprintf( 'Array at path "%s" contains %d elements, maximum allowed is %d', $path ?: 'root', count($data), self::MAX_ARRAY_SIZE ); $result['valid'] = false; } // Recursively validate nested data foreach ($data as $key => $value) { $current_path = $path ? "{$path}.{$key}" : $key; $nested_validation = $this->validate_data_structure($value, $current_path); if (!$nested_validation['valid']) { $result['errors'] = array_merge($result['errors'], $nested_validation['errors']); $result['valid'] = false; } } } elseif (is_string($data)) { // Check string length if (strlen($data) > self::MAX_STRING_LENGTH) { $result['errors'][] = sprintf( 'String at path "%s" is %d characters, maximum allowed is %d', $path ?: 'value', strlen($data), self::MAX_STRING_LENGTH ); $result['valid'] = false; } } return $result; } /** * Validate schema type * * @since 1.0.0 * * @param string $schema_type Schema type to validate * @return bool Validation result */ private function is_valid_schema_type(string $schema_type): bool { return isset($this->allowed_schema_types[$schema_type]); } /** * Validate JSON structure * * @since 1.0.0 * * @param array $schema_data Schema data * @param string $schema_type Schema type * @return array Validation result */ private function validate_json_structure(array $schema_data, string $schema_type): array { $result = ['valid' => true, 'errors' => []]; // Check for required @context if (!isset($schema_data['@context'])) { $result['errors'][] = 'Missing required @context field'; $result['valid'] = false; } elseif ($schema_data['@context'] !== 'https://schema.org') { $result['errors'][] = 'Invalid @context value. Must be "https://schema.org"'; $result['valid'] = false; } // Check for required @type if (!isset($schema_data['@type'])) { $result['errors'][] = 'Missing required @type field'; $result['valid'] = false; } elseif ($schema_data['@type'] !== $schema_type) { $result['errors'][] = "Schema @type '{$schema_data['@type']}' does not match expected type '{$schema_type}'"; $result['valid'] = false; } return $result; } /** * Sanitize schema data recursively * * @since 1.0.0 * * @param mixed $data Data to sanitize * @param string $field_key Current field key for context-aware sanitization * @return mixed Sanitized data */ private function sanitize_schema_data($data, string $field_key = '') { if (is_array($data)) { $sanitized = []; foreach ($data as $key => $value) { $sanitized_key = $this->sanitize_key($key); $sanitized[$sanitized_key] = $this->sanitize_schema_data($value, $sanitized_key); } return $sanitized; } if (is_string($data)) { return $this->sanitize_string_value($data, $field_key); } if (is_numeric($data)) { return $this->sanitize_numeric_value($data); } if (is_bool($data)) { return $data; } // For other types, convert to string and sanitize return $this->sanitize_string_value((string) $data, $field_key); } /** * Sanitize array key * * @since 1.0.0 * * @param string $key Array key * @return string Sanitized key */ private function sanitize_key($key): string { // Ensure key is a string first if (!is_string($key)) { return (string) $key; } // For schema data, preserve the original key names to maintain case sensitivity // Schema.org properties are case-sensitive (e.g., startDate, not startdate) // Only do basic validation without changing the case if (preg_match('/^[a-zA-Z@][a-zA-Z0-9@_-]*$/', $key)) { return $key; // Return as-is if it's a valid schema property name } // Fallback to WordPress sanitization for invalid keys return sanitize_key($key); } /** * Sanitize string value with context-aware sanitization * * @since 1.0.0 * * @param string $value String value * @param string $field_name Field name for context-aware sanitization * @return string Sanitized value */ private function sanitize_string_value(string $value, string $field_name = ''): string { // Handle URLs differently to preserve valid URL structure if (in_array($field_name, ['url', 'sameAs', 'logo', 'image', 'mainEntityOfPage'], true)) { return esc_url_raw($value); } // Handle email fields if (in_array($field_name, ['email'], true)) { return sanitize_email($value); } // Handle description fields that may contain basic HTML if (in_array($field_name, ['description', 'text', 'articleBody'], true)) { // Allow basic HTML but strip dangerous tags $allowed_html = [ 'p' => [], 'br' => [], 'strong' => [], 'em' => [], 'b' => [], 'i' => [] ]; $value = wp_kses($value, $allowed_html); } else { // For other fields, remove all HTML tags $value = wp_strip_all_tags($value); } // Sanitize for database storage. Note: escaping is intentionally NOT done // here. This value is stored and later emitted as JSON-LD inside a //