bool, 'message' => string, 'remaining' => int] */ public static function check_rate_limit($checker_id, $ip) { $checker = get_post_meta($checker_id, 'checker', true); // Check if rate limiting is enabled if (!isset($checker['security']['rate_limit']['enabled']) || $checker['security']['rate_limit']['enabled'] !== 'yes') { return ['allowed' => true, 'remaining' => 999]; } // Get settings with defaults $max_attempts = isset($checker['security']['rate_limit']['max_attempts']) ? (int)$checker['security']['rate_limit']['max_attempts'] : 5; $time_window = isset($checker['security']['rate_limit']['time_window']) ? (int)$checker['security']['rate_limit']['time_window'] : 15; $block_duration = isset($checker['security']['rate_limit']['block_duration']) ? (int)$checker['security']['rate_limit']['block_duration'] : 60; $error_message = isset($checker['security']['rate_limit']['error_message']) ? $checker['security']['rate_limit']['error_message'] : 'Too many attempts. Please try again later.'; // Create transient keys with checker-specific prefix $transient_prefix = 'checker_rate_' . $checker_id . '_' . self::get_ip_hash($ip); $transient_key = $transient_prefix . '_attempts'; $block_key = $transient_prefix . '_blocked'; // Check if IP is already blocked $blocked_until = get_transient($block_key); if ($blocked_until !== false) { // Calculate remaining time $time_remaining = $blocked_until - time(); $minutes_remaining = ceil($time_remaining / 60); // Build user-friendly message $custom_message = $error_message; if ($custom_message === "Too many attempts. Please try again later.") { $custom_message = sprintf( __('You are temporarily blocked. Please wait %d minutes before trying again.', 'sheet-data-checker-pro'), max(1, $minutes_remaining) ); } return [ 'allowed' => false, 'message' => $custom_message, 'remaining' => 0, 'blocked_until' => $blocked_until ]; } // Get current attempts $attempts = get_transient($transient_key); if ($attempts === false) { $attempts = 0; } // Increment attempts $attempts++; // Check if exceeded limit if ($attempts > $max_attempts) { // Block the IP $block_until = time() + ($block_duration * 60); set_transient($block_key, $block_until, $block_duration * 60); // Reset attempts counter delete_transient($transient_key); // Log the rate limit block if (class_exists('CHECKER_SECURITY_LOGGER')) { CHECKER_SECURITY_LOGGER::log_rate_limit_block($checker_id, $ip, [ 'max_attempts' => $max_attempts, 'time_window' => $time_window, 'block_duration' => $block_duration ]); } // Build user-friendly error message $minutes_remaining = ceil($block_duration); $custom_message = $error_message; // If using default message, enhance it with time info if ($custom_message === "Too many attempts. Please try again later.") { $custom_message = sprintf( __('Too many attempts. Please wait %d minutes before trying again.', 'sheet-data-checker-pro'), $minutes_remaining ); } return [ 'allowed' => false, 'message' => $custom_message, 'remaining' => 0, 'blocked_until' => $block_until ]; } // Update attempts counter set_transient($transient_key, $attempts, $time_window * 60); return [ 'allowed' => true, 'remaining' => $max_attempts - $attempts ]; } /** * Verify reCAPTCHA v3 token with improved implementation * * @param int $checker_id Checker post ID * @param string $token reCAPTCHA token from frontend * @param string $action Action name for reCAPTCHA (optional) * @return array ['success' => bool, 'score' => float, 'message' => string] */ public static function verify_recaptcha($checker_id, $token, $action = 'submit') { $checker = get_post_meta($checker_id, 'checker', true); // Check if reCAPTCHA is enabled if (!isset($checker['security']['recaptcha']['enabled']) || $checker['security']['recaptcha']['enabled'] !== 'yes') { return ['success' => true, 'score' => 1.0]; } // Get settings $secret_key_raw = isset($checker['security']['recaptcha']['secret_key']) ? $checker['security']['recaptcha']['secret_key'] : ''; $secret_key = trim((string) $secret_key_raw); $min_score_raw = isset($checker['security']['recaptcha']['min_score']) ? $checker['security']['recaptcha']['min_score'] : 0.5; if (is_string($min_score_raw)) { $min_score_raw = str_replace(',', '.', $min_score_raw); } $min_score = (float) $min_score_raw; if (empty($secret_key) || empty($token)) { error_log("Sheet Data Checker: reCAPTCHA verification failed - Missing credentials (Checker ID: {$checker_id})"); return [ 'success' => false, 'score' => 0, 'message' => 'reCAPTCHA verification failed: Missing credentials' ]; } error_log("Sheet Data Checker: Starting reCAPTCHA verification (Checker ID: {$checker_id}, Min Score: {$min_score})"); // Verify with Google using WordPress HTTP API $response = wp_remote_post('https://www.google.com/recaptcha/api/siteverify', [ 'timeout' => 10, 'body' => [ 'secret' => $secret_key, 'response' => $token, 'remoteip' => self::get_client_ip() ] ]); if (is_wp_error($response)) { error_log('Sheet Data Checker: reCAPTCHA verification failed - ' . $response->get_error_message()); return [ 'success' => false, 'score' => 0, 'message' => 'reCAPTCHA verification failed: ' . $response->get_error_message() ]; } $body = json_decode(wp_remote_retrieve_body($response), true); $score = isset($body['score']) ? (float)$body['score'] : 0; $response_action = isset($body['action']) ? $body['action'] : ''; if (!isset($body['success']) || !$body['success']) { $error_codes = isset($body['error-codes']) ? $body['error-codes'] : 'unknown'; error_log("Sheet Data Checker: reCAPTCHA verification failed - Error codes: {$error_codes}"); // Log the CAPTCHA failure if (class_exists('CHECKER_SECURITY_LOGGER')) { CHECKER_SECURITY_LOGGER::log_captcha_failure($checker_id, 'recaptcha', [ 'success' => false, 'score' => $score, 'error_codes' => is_array($body['error-codes']) ? $body['error-codes'] : [] ]); } return [ 'success' => false, 'score' => $score, 'message' => 'reCAPTCHA verification failed' ]; } // Verify action matches if specified (reCAPTCHA v3 feature) if ($action && $response_action !== $action) { error_log("Sheet Data Checker: reCAPTCHA action mismatch - Expected: {$action}, Got: {$response_action}"); return [ 'success' => false, 'score' => $score, 'message' => 'reCAPTCHA action verification failed' ]; } if ($score < $min_score) { error_log("Sheet Data Checker: reCAPTCHA score too low - Score: {$score}, Min: {$min_score}"); return [ 'success' => false, 'score' => $score, 'message' => 'reCAPTCHA score too low. Please try again.' ]; } error_log("Sheet Data Checker: reCAPTCHA verification SUCCESS - Score: {$score}, Action: {$response_action}"); return [ 'success' => true, 'score' => $score ]; } /** * Verify Cloudflare Turnstile token with improved implementation * * @param int $checker_id Checker post ID * @param string $token Turnstile token from frontend * @return array ['success' => bool, 'message' => string] */ public static function verify_turnstile($checker_id, $token) { $checker = get_post_meta($checker_id, 'checker', true); // Check if Turnstile is enabled if (!isset($checker['security']['turnstile']['enabled']) || $checker['security']['turnstile']['enabled'] !== 'yes') { return ['success' => true]; } // Get settings $secret_key = isset($checker['security']['turnstile']['secret_key']) ? $checker['security']['turnstile']['secret_key'] : ''; if (empty($secret_key) || empty($token)) { error_log("Sheet Data Checker: Turnstile verification failed - Missing credentials (Checker ID: {$checker_id})"); return [ 'success' => false, 'message' => 'Turnstile verification failed: Missing credentials' ]; } error_log("Sheet Data Checker: Starting Turnstile verification (Checker ID: {$checker_id})"); // Verify with Cloudflare using WordPress HTTP API $response = wp_remote_post('https://challenges.cloudflare.com/turnstile/v0/siteverify', [ 'timeout' => 10, 'body' => [ 'secret' => $secret_key, 'response' => $token, 'remoteip' => self::get_client_ip() ] ]); if (is_wp_error($response)) { error_log('Sheet Data Checker: Turnstile verification failed - ' . $response->get_error_message()); return [ 'success' => false, 'message' => 'Turnstile verification failed: ' . $response->get_error_message() ]; } $body = json_decode(wp_remote_retrieve_body($response), true); if (!isset($body['success']) || !$body['success']) { $error_codes = isset($body['error-codes']) ? implode(', ', $body['error-codes']) : 'unknown'; error_log("Sheet Data Checker: Turnstile verification failed - Error codes: {$error_codes}"); // Log the CAPTCHA failure if (class_exists('CHECKER_SECURITY_LOGGER')) { CHECKER_SECURITY_LOGGER::log_captcha_failure($checker_id, 'turnstile', [ 'success' => false, 'error_codes' => is_array($body['error-codes']) ? $body['error-codes'] : [] ]); } return [ 'success' => false, 'message' => 'Turnstile verification failed' ]; } error_log("Sheet Data Checker: Turnstile verification SUCCESS"); return ['success' => true]; } /** * Get client IP address with improved proxy detection * * @return string IP address */ public static function get_client_ip() { // Check for Cloudflare first if (!empty($_SERVER['HTTP_CF_CONNECTING_IP'])) { return sanitize_text_field($_SERVER['HTTP_CF_CONNECTING_IP']); } // Check various proxy headers $ip_headers = [ 'HTTP_X_FORWARDED_FOR', 'HTTP_X_REAL_IP', 'HTTP_X_FORWARDED', 'HTTP_FORWARDED_FOR', 'HTTP_FORWARDED', 'REMOTE_ADDR' ]; foreach ($ip_headers as $header) { if (!empty($_SERVER[$header])) { $ips = explode(',', $_SERVER[$header]); $ip = trim($ips[0]); // Validate IP if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) { return $ip; } } } // Fallback to REMOTE_ADDR return !empty($_SERVER['REMOTE_ADDR']) ? sanitize_text_field($_SERVER['REMOTE_ADDR']) : '0.0.0.0'; } /** * Create a hash of the IP for storage (more secure than storing raw IP) * * @param string $ip IP address * @return string Hashed IP */ private static function get_ip_hash($ip) { return wp_hash($ip . 'sheet_checker_rate_limit'); } /** * Verify nonce for AJAX requests * * @param string $nonce Nonce value * @param string $action Action name * @param int $checker_id Optional checker ID for logging * @return bool True if valid, false otherwise */ public static function verify_nonce($nonce, $action, $checker_id = 0) { if (!$nonce) { return false; } $is_valid = wp_verify_nonce($nonce, $action) !== false; // Log nonce failure if checker_id is provided if (!$is_valid && $checker_id && class_exists('CHECKER_SECURITY_LOGGER')) { CHECKER_SECURITY_LOGGER::log_nonce_failure($checker_id, $nonce); } return $is_valid; } /** * Check if security features are properly configured * * @param int $checker_id Checker post ID * @return array Configuration status */ public static function check_security_config($checker_id) { $checker = get_post_meta($checker_id, 'checker', true); $issues = []; // Check rate limiting if (isset($checker['security']['rate_limit']['enabled']) && $checker['security']['rate_limit']['enabled'] === 'yes') { if (!isset($checker['security']['rate_limit']['max_attempts']) || $checker['security']['rate_limit']['max_attempts'] < 1) { $issues[] = 'Rate limiting enabled but max attempts not set or invalid'; } if (!isset($checker['security']['rate_limit']['time_window']) || $checker['security']['rate_limit']['time_window'] < 1) { $issues[] = 'Rate limiting enabled but time window not set or invalid'; } } // Check reCAPTCHA if (isset($checker['security']['recaptcha']['enabled']) && $checker['security']['recaptcha']['enabled'] === 'yes') { if (empty($checker['security']['recaptcha']['site_key'])) { $issues[] = 'reCAPTCHA enabled but site key not set'; } if (empty($checker['security']['recaptcha']['secret_key'])) { $issues[] = 'reCAPTCHA enabled but secret key not set'; } } // Check Turnstile if (isset($checker['security']['turnstile']['enabled']) && $checker['security']['turnstile']['enabled'] === 'yes') { if (empty($checker['security']['turnstile']['site_key'])) { $issues[] = 'Turnstile enabled but site key not set'; } if (empty($checker['security']['turnstile']['secret_key'])) { $issues[] = 'Turnstile enabled but secret key not set'; } } // Check if both CAPTCHAs are enabled $recaptcha_enabled = isset($checker['security']['recaptcha']['enabled']) && $checker['security']['recaptcha']['enabled'] === 'yes'; $turnstile_enabled = isset($checker['security']['turnstile']['enabled']) && $checker['security']['turnstile']['enabled'] === 'yes'; if ($recaptcha_enabled && $turnstile_enabled) { $issues[] = 'Both reCAPTCHA and Turnstile are enabled - only one should be used'; } return [ 'configured' => empty($issues), 'issues' => $issues ]; } /** * Sanitize and validate user input * * @param mixed $value Value to sanitize * @param string $type Type of value (text, email, url, etc.) * @return mixed Sanitized value */ public static function sanitize_input($value, $type = 'text') { if (!is_string($value)) { return $value; } switch ($type) { case 'email': return sanitize_email($value); case 'url': return esc_url_raw($value); case 'text': default: return sanitize_text_field($value); } } /** * Check if a security feature is enabled for a checker * * @param array $checker Checker settings array * @param string $feature Feature name: 'recaptcha', 'turnstile', 'rate_limit', 'honeypot' * @return bool */ public static function is_enabled($checker, $feature) { if (!is_array($checker) || !isset($checker['security'])) { return false; } $enabled = $checker['security'][$feature]['enabled'] ?? false; // Accept common truthy flags: 'yes', 'on', true, 1 return $enabled === 'yes' || $enabled === 'on' || $enabled === true || $enabled === 1 || $enabled === '1'; } /** * Get security setting value with default * * @param array $checker Checker settings array * @param string $feature Feature name * @param string $key Setting key * @param mixed $default Default value * @return mixed */ public static function get_setting($checker, $feature, $key, $default = '') { if (!is_array($checker) || !isset($checker['security'][$feature][$key])) { return $default; } return $checker['security'][$feature][$key]; } /** * Get custom error message with i18n support * * @param array $checker Checker settings array * @param string $feature Feature name * @param string $default_key Translation key for default message * @return string */ public static function get_error_message($checker, $feature, $default_key = '') { $custom_message = self::get_setting($checker, $feature, 'error_message', ''); if (!empty($custom_message)) { return $custom_message; } // Default translatable messages $defaults = [ 'recaptcha' => __('reCAPTCHA verification failed. Please try again.', 'sheet-data-checker-pro'), 'recaptcha_required' => __('reCAPTCHA verification required.', 'sheet-data-checker-pro'), 'turnstile' => __('Turnstile verification failed. Please try again.', 'sheet-data-checker-pro'), 'turnstile_required' => __('Turnstile verification required.', 'sheet-data-checker-pro'), 'rate_limit' => __('Too many attempts. Please try again later.', 'sheet-data-checker-pro'), 'honeypot' => __('Security validation failed.', 'sheet-data-checker-pro'), 'nonce_expired' => __('Session expired. Please refresh the page and try again.', 'sheet-data-checker-pro'), ]; return isset($defaults[$default_key]) ? $defaults[$default_key] : $defaults[$feature] ?? ''; } /** * Unified security verification for all CAPTCHA and security checks * Returns error response array if check fails, null if all checks pass * * @param int $checker_id Checker post ID * @param array $checker Checker settings * @param array $request Request data ($_REQUEST) * @param bool $skip_captcha_for_show_all Whether to skip CAPTCHA for show-all mode initial load * @return array|null Error response or null if passed */ public static function verify_all_security($checker_id, $checker, $request, $skip_captcha_for_show_all = false) { $ip = self::get_client_ip(); // Check honeypot first (fastest check) if (self::is_enabled($checker, 'honeypot')) { $honeypot_value = ''; if (isset($request['honeypot_name'], $request['honeypot_value'])) { $honeypot_value = $request['honeypot_value']; } elseif (isset($request['website_url_hp'])) { $honeypot_value = $request['website_url_hp']; } if (!empty($honeypot_value)) { // Log honeypot trigger if (class_exists('CHECKER_SECURITY_LOGGER')) { CHECKER_SECURITY_LOGGER::log_security_event($checker_id, 'honeypot_triggered', [ 'ip' => $ip ]); } return [ 'success' => false, 'message' => self::get_error_message($checker, 'honeypot', 'honeypot'), 'type' => 'honeypot' ]; } } // Check rate limit if (self::is_enabled($checker, 'rate_limit')) { $rate_limit = self::check_rate_limit($checker_id, $ip); if (!$rate_limit['allowed']) { return [ 'success' => false, 'message' => $rate_limit['message'], 'type' => 'rate_limit' ]; } } // Skip CAPTCHA checks for show-all initial load if configured if ($skip_captcha_for_show_all) { return null; } // If both CAPTCHAs are flagged, prefer Turnstile and skip reCAPTCHA to avoid double validation $turnstile_enabled = self::is_enabled($checker, 'turnstile'); $recaptcha_enabled = !$turnstile_enabled && self::is_enabled($checker, 'recaptcha'); // Check reCAPTCHA if enabled (and Turnstile not enabled) if ($recaptcha_enabled) { $token = isset($request['recaptcha_token']) ? $request['recaptcha_token'] : ''; if (empty($token)) { return [ 'success' => false, 'message' => self::get_error_message($checker, 'recaptcha', 'recaptcha_required'), 'type' => 'recaptcha' ]; } $recaptcha_action = isset($checker['security']['recaptcha']['action']) ? $checker['security']['recaptcha']['action'] : 'submit'; $recaptcha = self::verify_recaptcha($checker_id, $token, $recaptcha_action); if (!$recaptcha['success']) { return [ 'success' => false, 'message' => isset($recaptcha['message']) ? $recaptcha['message'] : self::get_error_message($checker, 'recaptcha', 'recaptcha'), 'type' => 'recaptcha' ]; } } // Check Turnstile if enabled if ($turnstile_enabled) { $token = isset($request['turnstile_token']) ? $request['turnstile_token'] : ''; if (empty($token)) { return [ 'success' => false, 'message' => self::get_error_message($checker, 'turnstile', 'turnstile_required'), 'type' => 'turnstile' ]; } $turnstile = self::verify_turnstile($checker_id, $token); if (!$turnstile['success']) { return [ 'success' => false, 'message' => isset($turnstile['message']) ? $turnstile['message'] : self::get_error_message($checker, 'turnstile', 'turnstile'), 'type' => 'turnstile' ]; } } return null; // All checks passed } /** * Check if nonce is expired and return appropriate error * * @param string $nonce Nonce value * @param string $action Nonce action * @return array ['valid' => bool, 'expired' => bool, 'message' => string] */ public static function check_nonce_status($nonce, $action = 'checker_ajax_nonce') { $verify = wp_verify_nonce($nonce, $action); if ($verify === false) { // Nonce is completely invalid or expired return [ 'valid' => false, 'expired' => true, 'message' => __('Session expired. Please refresh the page and try again.', 'sheet-data-checker-pro') ]; } if ($verify === 2) { // Nonce is valid but was generated 12-24 hours ago (expiring soon) return [ 'valid' => true, 'expired' => false, 'expiring_soon' => true, 'message' => '' ]; } // Nonce is fully valid (generated within 12 hours) return [ 'valid' => true, 'expired' => false, 'expiring_soon' => false, 'message' => '' ]; } }