tigerstyle-scent/includes/class-input-validator.php
Ryan Malloy 120f0b616d Add release tooling and update for v1.0.0 release
- Add .distignore (operator-private files excluded)
- Add build.sh for WordPress-installable release ZIPs
- Update CLAUDE.md references (now operator-private only)
2026-05-27 14:32:07 -06:00

379 lines
14 KiB
PHP

<?php
/**
* TigerStyle Scent Input Validation Framework
* Comprehensive validation system that sets the gold standard for WordPress security
*
* @package TigerStyle Scent
*/
defined('ABSPATH') or die('Direct access forbidden.');
class TigerStyleScent_InputValidator {
/**
* 🔐 SECURITY: Comprehensive OAuth2 parameter validation
*
* @param array $data Input data to validate
* @param array $rules Validation rules
* @return array Validation results with sanitized data
*/
public static function validate_oauth2_request(array $data, array $rules): array {
$result = [
'valid' => true,
'errors' => [],
'sanitized' => [],
'warnings' => []
];
foreach ($rules as $field => $rule_set) {
$value = $data[$field] ?? null;
$field_result = self::validate_field($field, $value, $rule_set);
if (!$field_result['valid']) {
$result['valid'] = false;
$result['errors'][$field] = $field_result['errors'];
}
if (!empty($field_result['warnings'])) {
$result['warnings'][$field] = $field_result['warnings'];
}
$result['sanitized'][$field] = $field_result['sanitized'];
}
return $result;
}
/**
* 🔐 SECURITY: Validate individual field with comprehensive rules
*/
private static function validate_field(string $field, $value, array $rules): array {
$result = [
'valid' => true,
'errors' => [],
'warnings' => [],
'sanitized' => $value
];
// Required field check
if (isset($rules['required']) && $rules['required'] && empty($value)) {
$result['valid'] = false;
$result['errors'][] = "Field '{$field}' is required";
return $result;
}
// Skip further validation if field is empty and not required
if (empty($value) && !($rules['required'] ?? false)) {
return $result;
}
// Apply validation rules
foreach ($rules as $rule => $rule_value) {
switch ($rule) {
case 'type':
$type_result = self::validate_type($value, $rule_value);
if (!$type_result['valid']) {
$result['valid'] = false;
$result['errors'] = array_merge($result['errors'], $type_result['errors']);
}
$result['sanitized'] = $type_result['sanitized'];
break;
case 'length':
$length_result = self::validate_length($value, $rule_value);
if (!$length_result['valid']) {
$result['valid'] = false;
$result['errors'] = array_merge($result['errors'], $length_result['errors']);
}
break;
case 'pattern':
$pattern_result = self::validate_pattern($value, $rule_value);
if (!$pattern_result['valid']) {
$result['valid'] = false;
$result['errors'] = array_merge($result['errors'], $pattern_result['errors']);
}
break;
case 'enum':
$enum_result = self::validate_enum($value, $rule_value);
if (!$enum_result['valid']) {
$result['valid'] = false;
$result['errors'] = array_merge($result['errors'], $enum_result['errors']);
}
break;
case 'security':
$security_result = self::validate_security($value, $rule_value);
if (!$security_result['valid']) {
$result['valid'] = false;
$result['errors'] = array_merge($result['errors'], $security_result['errors']);
}
if (!empty($security_result['warnings'])) {
$result['warnings'] = array_merge($result['warnings'], $security_result['warnings']);
}
break;
}
}
return $result;
}
/**
* 🔐 SECURITY: Type validation with WordPress sanitization
*/
private static function validate_type($value, string $type): array {
$result = ['valid' => true, 'errors' => [], 'sanitized' => $value];
switch ($type) {
case 'oauth2_client_id':
// OAuth2 client IDs should be alphanumeric with limited special chars
$sanitized = preg_replace('/[^a-zA-Z0-9._-]/', '', $value);
if ($sanitized !== $value) {
$result['valid'] = false;
$result['errors'][] = 'Client ID contains invalid characters';
}
$result['sanitized'] = $sanitized;
break;
case 'oauth2_scope':
// OAuth2 scopes: space-separated tokens with limited chars
$sanitized = preg_replace('/[^a-zA-Z0-9:._\s-]/', '', $value);
$result['sanitized'] = trim($sanitized);
if ($sanitized !== $value) {
$result['valid'] = false;
$result['errors'][] = 'Scope contains invalid characters';
}
break;
case 'oauth2_response_type':
$result['sanitized'] = sanitize_text_field($value);
break;
case 'oauth2_grant_type':
$result['sanitized'] = sanitize_text_field($value);
break;
case 'url':
$sanitized = esc_url_raw($value);
if (empty($sanitized) || $sanitized !== $value) {
$result['valid'] = false;
$result['errors'][] = 'Invalid URL format';
}
$result['sanitized'] = $sanitized;
break;
case 'oauth2_code':
// Authorization codes should be base64url-like
$sanitized = preg_replace('/[^a-zA-Z0-9._-]/', '', $value);
if ($sanitized !== $value) {
$result['valid'] = false;
$result['errors'][] = 'Authorization code contains invalid characters';
}
$result['sanitized'] = $sanitized;
break;
case 'oauth2_token':
// Access tokens should be base64url-like
$sanitized = preg_replace('/[^a-zA-Z0-9._-]/', '', $value);
if ($sanitized !== $value) {
$result['valid'] = false;
$result['errors'][] = 'Token contains invalid characters';
}
$result['sanitized'] = $sanitized;
break;
case 'text':
$result['sanitized'] = sanitize_text_field($value);
break;
default:
$result['sanitized'] = sanitize_text_field($value);
}
return $result;
}
/**
* 🔐 SECURITY: Length validation
*/
private static function validate_length($value, array $constraints): array {
$result = ['valid' => true, 'errors' => []];
$length = strlen($value);
if (isset($constraints['min']) && $length < $constraints['min']) {
$result['valid'] = false;
$result['errors'][] = "Value too short (minimum {$constraints['min']} characters)";
}
if (isset($constraints['max']) && $length > $constraints['max']) {
$result['valid'] = false;
$result['errors'][] = "Value too long (maximum {$constraints['max']} characters)";
}
return $result;
}
/**
* 🔐 SECURITY: Pattern validation with security considerations
*/
private static function validate_pattern($value, string $pattern): array {
$result = ['valid' => true, 'errors' => []];
if (!preg_match($pattern, $value)) {
$result['valid'] = false;
$result['errors'][] = 'Value does not match required pattern';
}
return $result;
}
/**
* 🔐 SECURITY: Enumeration validation
*/
private static function validate_enum($value, array $allowed_values): array {
$result = ['valid' => true, 'errors' => []];
if (!in_array($value, $allowed_values, true)) {
$result['valid'] = false;
$result['errors'][] = 'Value not in allowed list: ' . implode(', ', $allowed_values);
}
return $result;
}
/**
* 🔐 SECURITY: Advanced security validation
*/
private static function validate_security($value, array $security_rules): array {
$result = ['valid' => true, 'errors' => [], 'warnings' => []];
// Check for SQL injection patterns
if (isset($security_rules['sql_injection']) && $security_rules['sql_injection']) {
$sql_patterns = [
'/(\b(select|insert|update|delete|drop|create|alter|exec|execute)\b)/i',
'/(\b(union|or|and)\s+[\w\s]*=)/i',
'/(\'|\"|;|--|\*|\/\*|\*\/)/i'
];
foreach ($sql_patterns as $pattern) {
if (preg_match($pattern, $value)) {
$result['valid'] = false;
$result['errors'][] = 'Potential SQL injection attempt detected';
break;
}
}
}
// Check for XSS patterns
if (isset($security_rules['xss']) && $security_rules['xss']) {
$xss_patterns = [
'/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/i',
'/javascript:/i',
'/on\w+\s*=/i',
'/<iframe\b/i',
'/<object\b/i',
'/<embed\b/i'
];
foreach ($xss_patterns as $pattern) {
if (preg_match($pattern, $value)) {
$result['valid'] = false;
$result['errors'][] = 'Potential XSS attempt detected';
break;
}
}
}
// Check for suspicious characters
if (isset($security_rules['suspicious_chars']) && $security_rules['suspicious_chars']) {
if (preg_match('/[<>"\'\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/', $value)) {
$result['warnings'][] = 'Contains potentially suspicious characters';
}
}
// Check for common attack patterns
if (isset($security_rules['attack_patterns']) && $security_rules['attack_patterns']) {
$attack_patterns = [
'/\.\.[\/\\\\]/', // Directory traversal
'/\x00/', // Null bytes
'/eval\s*\(/i', // Code injection
'/system\s*\(/i', // Command injection
];
foreach ($attack_patterns as $pattern) {
if (preg_match($pattern, $value)) {
$result['valid'] = false;
$result['errors'][] = 'Security threat pattern detected';
break;
}
}
}
return $result;
}
/**
* 🔐 SECURITY: Get OAuth2 validation rules
*/
public static function get_oauth2_validation_rules(): array {
return [
'client_id' => [
'required' => true,
'type' => 'oauth2_client_id',
'length' => ['min' => 1, 'max' => 255],
'pattern' => '/^[a-zA-Z0-9._-]+$/',
'security' => ['sql_injection' => true, 'xss' => true, 'attack_patterns' => true]
],
'client_secret' => [
'required' => false, // May not be required for public clients
'type' => 'text',
'length' => ['min' => 1, 'max' => 1000],
'security' => ['sql_injection' => true, 'xss' => true, 'attack_patterns' => true]
],
'response_type' => [
'required' => true,
'type' => 'oauth2_response_type',
'enum' => ['code', 'token', 'id_token']
],
'grant_type' => [
'required' => true,
'type' => 'oauth2_grant_type',
'enum' => ['authorization_code', 'refresh_token', 'client_credentials', 'password']
],
'redirect_uri' => [
'required' => true,
'type' => 'url',
'length' => ['max' => 2048],
'security' => ['xss' => true, 'attack_patterns' => true]
],
'scope' => [
'required' => false,
'type' => 'oauth2_scope',
'length' => ['max' => 1000],
'pattern' => '/^[a-zA-Z0-9:._\s-]*$/',
'security' => ['sql_injection' => true, 'xss' => true]
],
'state' => [
'required' => false,
'type' => 'text',
'length' => ['max' => 1000],
'security' => ['sql_injection' => true, 'xss' => true, 'suspicious_chars' => true]
],
'code' => [
'required' => false,
'type' => 'oauth2_code',
'length' => ['min' => 10, 'max' => 512],
'pattern' => '/^[a-zA-Z0-9._-]+$/',
'security' => ['sql_injection' => true, 'attack_patterns' => true]
],
'token' => [
'required' => false,
'type' => 'oauth2_token',
'length' => ['min' => 10, 'max' => 1024],
'pattern' => '/^[a-zA-Z0-9._-]+$/',
'security' => ['sql_injection' => true, 'attack_patterns' => true]
]
];
}
}