roles)); $is_admin = user_can($user, 'manage_options'); return $has_trainer_role || $is_admin; } /** * Check if user has HVAC master trainer role * * @param int|null $user_id User ID (null for current user) * @return bool True if user has master trainer access */ public static function is_hvac_master_trainer(?int $user_id = null): bool { $user = $user_id !== null ? get_user_by('id', $user_id) : wp_get_current_user(); if (!$user instanceof \WP_User) { return false; } $has_master_role = in_array('hvac_master_trainer', $user->roles, true); $is_admin = user_can($user, 'manage_options'); return $has_master_role || $is_admin; } /** * Sanitize and validate superglobal input * * @param string $type Superglobal type (use SuperglobalType constants) * @param string $key The key to retrieve * @param SanitizationMethod|string $sanitize Sanitization method to use * @param mixed $default Default value if not set * @return mixed Sanitized value */ public static function get_input( string $type, string $key, string $sanitize = SanitizationMethod::SANITIZE_TEXT_FIELD, $default = '' ) { $type_upper = strtoupper($type); // Validate type $valid_types = [SuperglobalType::GET, SuperglobalType::POST, SuperglobalType::REQUEST, SuperglobalType::COOKIE, SuperglobalType::SERVER]; if (!in_array($type_upper, $valid_types, true)) { return $default; } $superglobal = match ($type_upper) { SuperglobalType::GET => $_GET, SuperglobalType::POST => $_POST, SuperglobalType::REQUEST => $_REQUEST, SuperglobalType::COOKIE => $_COOKIE, SuperglobalType::SERVER => $_SERVER, }; if (!isset($superglobal[$key])) { return $default; } $value = $superglobal[$key]; // Handle arrays with proper sanitization if (is_array($value)) { return self::sanitize_array($value, $sanitize); } return self::apply_sanitization($value, $sanitize); } /** * Sanitize array values recursively * * @param array $array Array to sanitize * @param SanitizationMethod|string $sanitize Sanitization method * @return array Sanitized array */ private static function sanitize_array(array $array, string $sanitize): array { $sanitized = []; foreach ($array as $key => $value) { if (is_array($value)) { $sanitized[$key] = self::sanitize_array($value, $sanitize); } else { $sanitized[$key] = self::apply_sanitization($value, $sanitize); } } return $sanitized; } /** * Apply sanitization to a single value * * @param mixed $value Value to sanitize * @param SanitizationMethod|string $sanitize Sanitization method * @return mixed Sanitized value */ private static function apply_sanitization($value, string $sanitize) { return match ($sanitize) { SanitizationMethod::ABSINT => absint($value), SanitizationMethod::INTVAL => intval($value), SanitizationMethod::SANITIZE_EMAIL => sanitize_email($value), SanitizationMethod::SANITIZE_URL, SanitizationMethod::ESC_URL_RAW => esc_url_raw($value), SanitizationMethod::SANITIZE_TEXT_FIELD => sanitize_text_field($value), SanitizationMethod::SANITIZE_TEXTAREA_FIELD => sanitize_textarea_field($value), SanitizationMethod::WP_KSES_POST => wp_kses_post($value), SanitizationMethod::NONE => $value, // Use with extreme caution default => sanitize_text_field($value), // Safe fallback }; } /** * Validate file upload with enhanced security checks * * @param array $file $_FILES array element * @param array $allowed_types Allowed MIME types * @param int $max_size Maximum file size in bytes * @return bool|\WP_Error True if valid, WP_Error on failure */ public static function validate_file_upload( array $file, array $allowed_types = [], int $max_size = self::DEFAULT_MAX_FILE_SIZE ): bool|\WP_Error { // Validate file structure $required_keys = ['error', 'tmp_name', 'size', 'name', 'type']; foreach ($required_keys as $key) { if (!array_key_exists($key, $file)) { return new \WP_Error('invalid_file_structure', 'Invalid file upload structure'); } } // Check upload error if ($file['error'] !== UPLOAD_ERR_OK) { $error_message = match ($file['error']) { UPLOAD_ERR_INI_SIZE, UPLOAD_ERR_FORM_SIZE => 'File too large', UPLOAD_ERR_PARTIAL => 'File upload incomplete', UPLOAD_ERR_NO_FILE => 'No file uploaded', UPLOAD_ERR_NO_TMP_DIR => 'Missing temporary directory', UPLOAD_ERR_CANT_WRITE => 'Cannot write file to disk', UPLOAD_ERR_EXTENSION => 'File upload blocked by extension', default => 'File upload failed' }; return new \WP_Error('upload_error', $error_message); } // Security: Verify file was actually uploaded via HTTP POST if (!is_uploaded_file($file['tmp_name'])) { self::log_security_event(SecurityEventType::SUSPICIOUS_REQUEST, [ 'reason' => 'Non-uploaded file in upload validation', 'file_name' => $file['name'] ?? 'unknown' ]); return new \WP_Error('security_error', 'Invalid file upload'); } // Validate file size if ($file['size'] > $max_size) { return new \WP_Error( 'size_error', sprintf('File too large. Maximum size is %s', size_format($max_size)) ); } // Empty file check if ($file['size'] === 0) { return new \WP_Error('empty_file', 'Empty file not allowed'); } // File type validation if (!empty($allowed_types)) { $file_type = wp_check_filetype($file['name']); if (empty($file_type['type']) || !in_array($file_type['type'], $allowed_types, true)) { return new \WP_Error( 'type_error', sprintf( 'Invalid file type. Allowed types: %s', implode(', ', $allowed_types) ) ); } } // Additional security: Check for executable file extensions $dangerous_extensions = ['php', 'phtml', 'php3', 'php4', 'php5', 'pl', 'py', 'jsp', 'asp', 'sh', 'cgi']; $file_extension = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION)); if (in_array($file_extension, $dangerous_extensions, true)) { self::log_security_event(SecurityEventType::SUSPICIOUS_REQUEST, [ 'reason' => 'Dangerous file extension upload attempt', 'file_name' => $file['name'], 'extension' => $file_extension ]); return new \WP_Error('dangerous_file', 'Executable file types not allowed'); } self::log_security_event(SecurityEventType::FILE_UPLOAD_ATTEMPT, [ 'file_name' => $file['name'], 'file_size' => $file['size'], 'mime_type' => $file['type'] ]); return true; } /** * Generate secure nonce field * * @param string $action Nonce action * @param string $name Nonce field name * @return string HTML nonce field */ public static function nonce_field(string $action, string $name = '_wpnonce'): string { return wp_nonce_field($action, $name, true, false); } /** * Verify nonce from request with enhanced security logging * * @param string $action Nonce action * @param string $name Nonce field name * @param string $type Request type (use SuperglobalType constants) * @return bool True if nonce is valid */ public static function verify_nonce( string $action, string $name = '_wpnonce', string $type = SuperglobalType::POST ): bool { $nonce = self::get_input($type, $name, SanitizationMethod::SANITIZE_TEXT_FIELD, ''); if (empty($nonce)) { self::log_security_event(SecurityEventType::INVALID_NONCE, [ 'action' => $action, 'reason' => 'Empty nonce value' ]); return false; } $result = wp_verify_nonce($nonce, $action); if (!$result) { self::log_security_event(SecurityEventType::INVALID_NONCE, [ 'action' => $action, 'nonce' => substr($nonce, 0, 10) . '...' // Log partial nonce for debugging ]); return false; } return true; } /** * Check AJAX referer with enhanced error handling and logging * * @param string $action Nonce action * @param string $query_arg Nonce query argument * @param bool $send_error Whether to send JSON error response * @return bool True if nonce is valid */ public static function check_ajax_nonce( string $action, string $query_arg = 'nonce', bool $send_error = true ): bool { $result = check_ajax_referer($action, $query_arg, false); if (!$result) { self::log_security_event(SecurityEventType::INVALID_NONCE, [ 'action' => $action, 'context' => 'AJAX request', 'referer' => $_SERVER['HTTP_REFERER'] ?? 'unknown' ]); if ($send_error) { wp_send_json_error([ 'message' => 'Security check failed', 'code' => 'invalid_nonce' ]); } return false; } return true; } /** * Escape output based on context with type safety * * @param mixed $data Data to escape * @param EscapeContext|string $context Escape context * @return string Escaped data */ public static function escape($data, string $context = EscapeContext::HTML): string { // Convert complex data types to empty string for security if (is_array($data) || is_object($data)) { return ''; } // Convert to string if not already $data = (string) $data; return match ($context) { EscapeContext::HTML => esc_html($data), EscapeContext::ATTR => esc_attr($data), EscapeContext::URL => esc_url($data), EscapeContext::JS => esc_js($data), EscapeContext::TEXTAREA => esc_textarea($data), default => esc_html($data), // Safe fallback }; } /** * Validate and sanitize email with enhanced validation * * @param string $email Email address to validate * @return string|false Sanitized email or false if invalid */ public static function validate_email(string $email) { // Basic sanitization $email = trim($email); $email = sanitize_email($email); // WordPress validation if (!is_email($email)) { return false; } // Additional length check (RFC 5321 limit) if (strlen($email) > 254) { return false; } // Check for disposable email domains (basic list) $disposable_domains = [ '10minutemail.com', 'tempmail.org', 'guerrillamail.com', 'mailinator.com', 'trashmail.com' ]; $domain = substr(strrchr($email, '@'), 1); if (in_array(strtolower($domain), $disposable_domains, true)) { self::log_security_event(SecurityEventType::SUSPICIOUS_REQUEST, [ 'reason' => 'Disposable email domain detected', 'domain' => $domain ]); return false; } return $email; } /** * Add comprehensive security headers * * @param bool $strict Whether to use strict CSP policy * @return void */ public static function add_security_headers(bool $strict = false): void { // Prevent output if headers already sent if (headers_sent()) { return; } $csp_policy = $strict ? "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'" : "default-src 'self' https: data: 'unsafe-inline' 'unsafe-eval'"; $headers = [ 'Content-Security-Policy' => $csp_policy, 'X-Frame-Options' => 'SAMEORIGIN', 'X-Content-Type-Options' => 'nosniff', 'X-XSS-Protection' => '1; mode=block', 'Referrer-Policy' => 'strict-origin-when-cross-origin', 'X-Permitted-Cross-Domain-Policies' => 'none', 'Permissions-Policy' => 'camera=(), microphone=(), geolocation=()' ]; foreach ($headers as $name => $value) { header(sprintf('%s: %s', $name, $value)); } } /** * Enhanced rate limiting with exponential backoff * * @param string $action Action identifier * @param int $max_attempts Maximum attempts allowed * @param int $window Time window in seconds * @return bool True if allowed, false if rate limited */ public static function check_rate_limit( string $action, int $max_attempts = self::DEFAULT_MAX_ATTEMPTS, int $window = self::DEFAULT_RATE_LIMIT_WINDOW ): bool { $user_id = get_current_user_id(); $ip = self::get_client_ip(); $key = sprintf('hvac_rate_limit_%s', hash('sha256', $action . '_' . $user_id . '_' . $ip)); $attempts = get_transient($key); if ($attempts === false) { set_transient($key, 1, $window); return true; } if ($attempts >= $max_attempts) { // Exponential backoff: extend the window for repeat offenders $extended_window = $window * min(8, pow(2, $attempts - $max_attempts)); set_transient($key, $attempts + 1, $extended_window); self::log_security_event(SecurityEventType::RATE_LIMIT_EXCEEDED, [ 'action' => $action, 'attempts' => $attempts, 'window' => $window, 'extended_window' => $extended_window ]); return false; } set_transient($key, $attempts + 1, $window); return true; } /** * Get client IP address with enhanced detection and validation * * @return string Client IP address */ public static function get_client_ip(): string { foreach (self::IP_HEADER_PRIORITY as $header) { if (!array_key_exists($header, $_SERVER)) { continue; } $ip_list = $_SERVER[$header]; $ips = array_map('trim', explode(',', $ip_list)); foreach ($ips as $ip) { // Validate IP and exclude private/reserved ranges for public IPs if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) { return $ip; } // For local development, accept private ranges if (defined('WP_DEBUG') && WP_DEBUG && filter_var($ip, FILTER_VALIDATE_IP)) { return $ip; } } } return $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0'; } /** * Enhanced security event logging with structured data * * @param SecurityEventType|string $event Event type * @param array $data Additional event data * @return void */ public static function log_security_event(string $event, array $data = []): void { $event_type = $event; $log_data = [ 'event' => $event_type, 'timestamp' => current_time('mysql', true), // UTC timestamp 'user_id' => get_current_user_id(), 'ip' => self::get_client_ip(), 'user_agent' => $_SERVER['HTTP_USER_AGENT'] ?? 'unknown', 'request_uri' => $_SERVER['REQUEST_URI'] ?? '', 'request_method' => $_SERVER['REQUEST_METHOD'] ?? 'unknown', 'data' => $data ]; // Always log to error log with structured format error_log(sprintf( '[HVAC Security] %s - %s', strtoupper($event_type), wp_json_encode($log_data, JSON_UNESCAPED_SLASHES) )); // Optional database logging for persistent storage if (defined('HVAC_SECURITY_LOG_TO_DB') && HVAC_SECURITY_LOG_TO_DB) { self::log_to_database($event_type, $log_data); } // Trigger action hook for extensibility do_action('hvac_security_event_logged', $event_type, $log_data); } /** * Log security event to database with error handling * * @param string $event_type Event type * @param array $log_data Log data * @return void */ private static function log_to_database(string $event_type, array $log_data): void { global $wpdb; $table = $wpdb->prefix . 'hvac_security_log'; // Check if table exists before attempting insert if ($wpdb->get_var($wpdb->prepare("SHOW TABLES LIKE %s", $table)) !== $table) { return; } $insert_data = [ 'event_type' => $event_type, 'event_data' => wp_json_encode($log_data['data']), 'user_id' => $log_data['user_id'], 'ip_address' => $log_data['ip'], 'user_agent' => $log_data['user_agent'], 'request_uri' => $log_data['request_uri'], 'created_at' => $log_data['timestamp'] ]; $result = $wpdb->insert($table, $insert_data); if ($result === false) { error_log(sprintf( '[HVAC Security] Failed to log to database: %s', $wpdb->last_error )); } } }