Comprehensive code review using GPT-5, Gemini 3, Kimi K2.5, and Zen MCP tools across 11 critical files (~9,000 lines). Identified and fixed issues by consensus prioritization. CRITICAL fixes: - Strip passwords from transients in registration error handling - Rewrite O(3600) token verification loop to O(1) with embedded timestamp HIGH fixes: - Replace remove_all_actions() with targeted hook removal (breaks WP isolation) - Prefer wp-config.php constant for encryption key storage - Add revocation check before generating certificate download URLs - Fix security headers condition to apply to AJAX requests - Add zoho-config.php to .gitignore MEDIUM fixes: - IP spoofing: only trust proxy headers when behind configured trusted proxies - Remove unsafe-eval from CSP (keep unsafe-inline for compatibility) - Remove duplicate Master Trainer component initialization - Remove file-scope side-effect initialization in profile manager - Use WordPress current_time() for consistent timezone in cert numbers Validated as non-issues: - Path traversal (token-based system prevents) - SQL injection (proper $wpdb->prepare throughout) - OAuth CSRF (correctly implemented with hash_equals) All 7 modified PHP files pass syntax validation (php -l). Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
258 lines
No EOL
6.5 KiB
PHP
258 lines
No EOL
6.5 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
/**
|
|
* HVAC Community Events Security Helper
|
|
*
|
|
* Provides security utilities and validation methods
|
|
*
|
|
* @package HVAC_Community_Events
|
|
* @subpackage Includes
|
|
* @since 1.1.0
|
|
*/
|
|
|
|
if ( ! defined( 'ABSPATH' ) ) {
|
|
exit;
|
|
}
|
|
|
|
/**
|
|
* Class HVAC_Security
|
|
*/
|
|
class HVAC_Security {
|
|
|
|
/**
|
|
* Verify a nonce with proper error handling
|
|
*
|
|
* @param string $nonce The nonce to verify
|
|
* @param string $action The nonce action
|
|
* @param bool $die_on_fail Whether to die on failure
|
|
* @return bool
|
|
*/
|
|
public static function verify_nonce( $nonce, $action, $die_on_fail = false ) {
|
|
$is_valid = wp_verify_nonce( $nonce, $action );
|
|
|
|
if ( ! $is_valid ) {
|
|
HVAC_Logger::warning( 'Nonce verification failed', 'Security', [
|
|
'action' => $action,
|
|
'user_id' => get_current_user_id(),
|
|
] );
|
|
|
|
if ( $die_on_fail ) {
|
|
wp_die( __( 'Security check failed. Please refresh the page and try again.', 'hvac-community-events' ) );
|
|
}
|
|
}
|
|
|
|
return $is_valid;
|
|
}
|
|
|
|
/**
|
|
* Check if current user has required capability
|
|
*
|
|
* @param string $capability The capability to check
|
|
* @param bool $die_on_fail Whether to die on failure
|
|
* @return bool
|
|
*/
|
|
public static function check_capability( $capability, $die_on_fail = false ) {
|
|
$has_cap = current_user_can( $capability );
|
|
|
|
if ( ! $has_cap ) {
|
|
HVAC_Logger::warning( 'Capability check failed', 'Security', [
|
|
'capability' => $capability,
|
|
'user_id' => get_current_user_id(),
|
|
] );
|
|
|
|
if ( $die_on_fail ) {
|
|
wp_die( __( 'You do not have permission to perform this action.', 'hvac-community-events' ) );
|
|
}
|
|
}
|
|
|
|
return $has_cap;
|
|
}
|
|
|
|
/**
|
|
* Check if user is logged in with optional redirect
|
|
*
|
|
* @param string $redirect_to URL to redirect if not logged in
|
|
* @return bool
|
|
*/
|
|
public static function require_login( $redirect_to = '' ) {
|
|
if ( ! is_user_logged_in() ) {
|
|
if ( ! empty( $redirect_to ) ) {
|
|
wp_safe_redirect( $redirect_to );
|
|
exit;
|
|
}
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Sanitize and validate email
|
|
*
|
|
* @param string $email Email to validate
|
|
* @return string|false Sanitized email or false if invalid
|
|
*/
|
|
public static function sanitize_email( $email ) {
|
|
$email = sanitize_email( $email );
|
|
return is_email( $email ) ? $email : false;
|
|
}
|
|
|
|
/**
|
|
* Sanitize and validate URL
|
|
*
|
|
* @param string $url URL to validate
|
|
* @return string|false Sanitized URL or false if invalid
|
|
*/
|
|
public static function sanitize_url( $url ) {
|
|
$url = esc_url_raw( $url );
|
|
return filter_var( $url, FILTER_VALIDATE_URL ) ? $url : false;
|
|
}
|
|
|
|
/**
|
|
* Sanitize array of values
|
|
*
|
|
* @param array $array Array to sanitize
|
|
* @param string $type Type of sanitization (text|email|url|int)
|
|
* @return array
|
|
*/
|
|
public static function sanitize_array( $array, $type = 'text' ) {
|
|
if ( ! is_array( $array ) ) {
|
|
return [];
|
|
}
|
|
|
|
$sanitized = [];
|
|
foreach ( $array as $key => $value ) {
|
|
switch ( $type ) {
|
|
case 'email':
|
|
$clean = self::sanitize_email( $value );
|
|
if ( $clean ) {
|
|
$sanitized[ $key ] = $clean;
|
|
}
|
|
break;
|
|
case 'url':
|
|
$clean = self::sanitize_url( $value );
|
|
if ( $clean ) {
|
|
$sanitized[ $key ] = $clean;
|
|
}
|
|
break;
|
|
case 'int':
|
|
$sanitized[ $key ] = intval( $value );
|
|
break;
|
|
default:
|
|
$sanitized[ $key ] = sanitize_text_field( $value );
|
|
}
|
|
}
|
|
|
|
return $sanitized;
|
|
}
|
|
|
|
/**
|
|
* Escape output based on context
|
|
*
|
|
* @param mixed $value Value to escape
|
|
* @param string $context Context (html|attr|url|js)
|
|
* @return string
|
|
*/
|
|
public static function escape_output( $value, $context = 'html' ) {
|
|
switch ( $context ) {
|
|
case 'attr':
|
|
return esc_attr( $value );
|
|
case 'url':
|
|
return esc_url( $value );
|
|
case 'js':
|
|
return esc_js( $value );
|
|
case 'textarea':
|
|
return esc_textarea( $value );
|
|
default:
|
|
return esc_html( $value );
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if request is AJAX
|
|
*
|
|
* @return bool
|
|
*/
|
|
public static function is_ajax_request() {
|
|
return defined( 'DOING_AJAX' ) && DOING_AJAX;
|
|
}
|
|
|
|
/**
|
|
* Get user IP address
|
|
*
|
|
* SECURITY FIX (C3): Only trust proxy headers when behind a known trusted proxy.
|
|
* Previous implementation trusted user-controllable headers unconditionally,
|
|
* allowing attackers to spoof IPs and bypass rate limiting.
|
|
*
|
|
* For most deployments, use REMOTE_ADDR directly. Only trust X-Forwarded-For
|
|
* when behind Cloudflare, AWS ALB, or other known reverse proxies.
|
|
*
|
|
* @return string
|
|
*/
|
|
public static function get_user_ip() {
|
|
// Primary: Use REMOTE_ADDR (cannot be spoofed at network level)
|
|
$ip = isset($_SERVER['REMOTE_ADDR']) ? $_SERVER['REMOTE_ADDR'] : '';
|
|
|
|
// Only trust proxy headers if HVAC_TRUSTED_PROXIES is defined
|
|
// Define as comma-separated list of trusted proxy IPs in wp-config.php
|
|
// Example: define('HVAC_TRUSTED_PROXIES', '10.0.0.1,10.0.0.2');
|
|
if (defined('HVAC_TRUSTED_PROXIES') && HVAC_TRUSTED_PROXIES) {
|
|
$trusted_proxies = array_map('trim', explode(',', HVAC_TRUSTED_PROXIES));
|
|
|
|
if (in_array($ip, $trusted_proxies, true)) {
|
|
// Behind trusted proxy - check forwarded headers
|
|
if (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
|
|
// Take first IP (original client) from comma-separated list
|
|
$forwarded = explode(',', $_SERVER['HTTP_X_FORWARDED_FOR']);
|
|
$client_ip = trim($forwarded[0]);
|
|
if (filter_var($client_ip, FILTER_VALIDATE_IP)) {
|
|
$ip = $client_ip;
|
|
}
|
|
} elseif (!empty($_SERVER['HTTP_CLIENT_IP'])) {
|
|
$client_ip = $_SERVER['HTTP_CLIENT_IP'];
|
|
if (filter_var($client_ip, FILTER_VALIDATE_IP)) {
|
|
$ip = $client_ip;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return sanitize_text_field($ip);
|
|
}
|
|
|
|
/**
|
|
* Rate limiting check
|
|
*
|
|
* @param string $action Action to limit
|
|
* @param int $limit Number of attempts allowed
|
|
* @param int $window Time window in seconds
|
|
* @param string $identifier User identifier (defaults to IP)
|
|
* @return bool True if within limits, false if exceeded
|
|
*/
|
|
public static function check_rate_limit( $action, $limit = 5, $window = 300, $identifier = null ) {
|
|
if ( null === $identifier ) {
|
|
$identifier = self::get_user_ip();
|
|
}
|
|
|
|
$key = 'hvac_rate_limit_' . md5( $action . $identifier );
|
|
$attempts = get_transient( $key );
|
|
|
|
if ( false === $attempts ) {
|
|
set_transient( $key, 1, $window );
|
|
return true;
|
|
}
|
|
|
|
if ( $attempts >= $limit ) {
|
|
HVAC_Logger::warning( 'Rate limit exceeded', 'Security', [
|
|
'action' => $action,
|
|
'identifier' => $identifier,
|
|
'attempts' => $attempts,
|
|
] );
|
|
return false;
|
|
}
|
|
|
|
set_transient( $key, $attempts + 1, $window );
|
|
return true;
|
|
}
|
|
} |