Files
dw-sheet-data-checker/includes/class-Security.php

639 lines
25 KiB
PHP

<?php
class CHECKER_SECURITY {
/**
* Check rate limit for an IP address using improved method
*
* @param int $checker_id Checker post ID
* @param string $ip IP address to check
* @return array ['allowed' => 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' => ''
];
}
}