upskill-event-manager/includes/class-hvac-security-helpers.php
ben 1032fbfe85
Some checks failed
Security Monitoring & Compliance / Static Code Security Analysis (push) Has been cancelled
Security Monitoring & Compliance / Security Compliance Validation (push) Has been cancelled
HVAC Plugin CI/CD Pipeline / Security Analysis (push) Has been cancelled
HVAC Plugin CI/CD Pipeline / Code Quality & Standards (push) Has been cancelled
HVAC Plugin CI/CD Pipeline / Unit Tests (push) Has been cancelled
HVAC Plugin CI/CD Pipeline / Integration Tests (push) Has been cancelled
Security Monitoring & Compliance / Dependency Vulnerability Scan (push) Has been cancelled
Security Monitoring & Compliance / Secrets & Credential Scan (push) Has been cancelled
Security Monitoring & Compliance / WordPress Security Analysis (push) Has been cancelled
Security Monitoring & Compliance / Security Summary Report (push) Has been cancelled
HVAC Plugin CI/CD Pipeline / Deploy to Staging (push) Has been cancelled
HVAC Plugin CI/CD Pipeline / Deploy to Production (push) Has been cancelled
HVAC Plugin CI/CD Pipeline / Notification (push) Has been cancelled
Security Monitoring & Compliance / Security Team Notification (push) Has been cancelled
feat: complete PHP 8+ modernization with backward compatibility
Major modernization of HVAC plugin for PHP 8+ with full backward compatibility:

CORE MODERNIZATION:
- Implement strict type declarations throughout codebase
- Modernize main plugin class with PHP 8+ features
- Convert array syntax to modern PHP format
- Add constructor property promotion where applicable
- Enhance security helpers with modern PHP patterns

COMPATIBILITY FIXES:
- Fix PHP 8.1+ enum compatibility (convert to class constants)
- Fix union type compatibility (true|WP_Error → bool|WP_Error)
- Remove mixed type declarations for PHP 8.0 compatibility
- Add default arms to match expressions preventing UnhandledMatchError
- Fix method naming inconsistency (ensureRegistrationAccess callback)
- Add null coalescing in TEC integration for strict type compliance

DEPLOYMENT STATUS:
 Successfully deployed and tested on staging
 Site functional at https://upskill-staging.measurequick.com
 Expert code review completed with GPT-5 validation
 MCP Playwright testing confirms functionality

Ready for production deployment when requested.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-31 17:44:39 -03:00

644 lines
No EOL
21 KiB
PHP

<?php
declare(strict_types=1);
/**
* HVAC Security Helpers
*
* Centralized security functions for the HVAC Community Events plugin
*
* @package HVAC_Community_Events
* @since 2.1.0
*/
if (!defined('ABSPATH')) {
exit;
}
/**
* Superglobal types constants for enhanced security
*/
final class SuperglobalType
{
public const GET = 'GET';
public const POST = 'POST';
public const REQUEST = 'REQUEST';
public const COOKIE = 'COOKIE';
public const SERVER = 'SERVER';
}
/**
* Sanitization methods constants
*/
final class SanitizationMethod
{
public const ABSINT = 'absint';
public const INTVAL = 'intval';
public const SANITIZE_EMAIL = 'sanitize_email';
public const SANITIZE_URL = 'sanitize_url';
public const ESC_URL_RAW = 'esc_url_raw';
public const SANITIZE_TEXT_FIELD = 'sanitize_text_field';
public const SANITIZE_TEXTAREA_FIELD = 'sanitize_textarea_field';
public const WP_KSES_POST = 'wp_kses_post';
public const NONE = 'none';
}
/**
* Escape contexts constants
*/
final class EscapeContext
{
public const HTML = 'html';
public const ATTR = 'attr';
public const URL = 'url';
public const JS = 'js';
public const TEXTAREA = 'textarea';
}
/**
* Security event types constants
*/
final class SecurityEventType
{
public const AUTHENTICATION_FAILURE = 'auth_failure';
public const RATE_LIMIT_EXCEEDED = 'rate_limit_exceeded';
public const INVALID_NONCE = 'invalid_nonce';
public const UNAUTHORIZED_ACCESS = 'unauthorized_access';
public const FILE_UPLOAD_ATTEMPT = 'file_upload_attempt';
public const SUSPICIOUS_REQUEST = 'suspicious_request';
}
/**
* HVAC_Security_Helpers class
*
* Modernized security helpers with PHP 8+ features for enhanced type safety and security
*/
final class HVAC_Security_Helpers
{
private const DEFAULT_MAX_FILE_SIZE = 5242880; // 5MB
private const DEFAULT_RATE_LIMIT_WINDOW = 60; // seconds
private const DEFAULT_MAX_ATTEMPTS = 5;
private const IP_HEADER_PRIORITY = [
'HTTP_CF_CONNECTING_IP',
'HTTP_CLIENT_IP',
'HTTP_X_FORWARDED_FOR',
'REMOTE_ADDR'
];
/**
* Check if user has HVAC trainer role
*
* @param int|null $user_id User ID (null for current user)
* @return bool True if user has trainer access
*/
public static function is_hvac_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;
}
$trainer_roles = ['hvac_trainer', 'hvac_master_trainer'];
$has_trainer_role = !empty(array_intersect($trainer_roles, $user->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<string> $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<string, mixed> $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<string, mixed> $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
));
}
}
}