- check_comment_content(): new method called from validate_comment() - Detects URL in email field (binance.info/register?ref=... pattern) - Blocks comments with more URLs than max_links threshold - Blocks any link from first-time commenters (0 approved comments) - New options: block_url_in_email, block_links_new_commenters, max_links - Admin: new "Comment Spam Content" card in Honeypot tab with toggles and max_links numeric input Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
329 lines
13 KiB
PHP
329 lines
13 KiB
PHP
<?php
|
||
if (!defined('ABSPATH')) exit;
|
||
|
||
/**
|
||
* ITK Honeypot
|
||
*
|
||
* Ported from HoneypotFields v2.4.0.
|
||
* Injects invisible honeypot fields into all major form types and blocks
|
||
* submissions that fill them (bots) or arrive too fast/too slow.
|
||
* Uses ITK_Database for logging instead of a separate table.
|
||
*/
|
||
class ITK_Honeypot {
|
||
|
||
const FIELD_PREFIX = '_hp_trap_';
|
||
const TOKEN_FIELD = '_hp_token';
|
||
const TIME_FIELD = '_hp_ts';
|
||
|
||
private static array $opts = [];
|
||
|
||
public function __construct() {
|
||
self::$opts = get_option('itk_honeypot', []);
|
||
|
||
if (empty(self::$opts['enabled'])) return;
|
||
|
||
// Inject honeypot into forms
|
||
add_action('comment_form', [$this, 'inject_comment']);
|
||
add_action('login_form', [$this, 'inject_generic']);
|
||
add_action('register_form', [$this, 'inject_generic']);
|
||
add_action('lostpassword_form', [$this, 'inject_generic']);
|
||
add_action('wp_head', [$this, 'inject_honeypot_style']);
|
||
|
||
// Validate on submission
|
||
add_filter('preprocess_comment', [$this, 'validate_comment'], 1);
|
||
add_action('authenticate', [$this, 'validate_login'], 1, 3);
|
||
add_action('register_post', [$this, 'validate_register'], 1, 3);
|
||
add_action('lostpassword_post', [$this, 'validate_lost_password'], 1);
|
||
|
||
// WooCommerce
|
||
if (!empty(self::$opts['protect_woocommerce']) && class_exists('WooCommerce')) {
|
||
add_action('woocommerce_checkout_before_customer_details', [$this, 'inject_generic']);
|
||
add_action('woocommerce_checkout_process', [$this, 'validate_woo_checkout']);
|
||
add_action('woocommerce_register_form', [$this, 'inject_generic']);
|
||
add_action('woocommerce_process_registration_errors', [$this, 'validate_woo_registration'], 10, 3);
|
||
add_action('woocommerce_login_form', [$this, 'inject_generic']);
|
||
}
|
||
|
||
// Contact Form 7
|
||
if (!empty(self::$opts['protect_cf7']) && class_exists('WPCF7')) {
|
||
add_action('wpcf7_form_elements', [$this, 'inject_cf7']);
|
||
add_filter('wpcf7_before_send_mail', [$this, 'validate_cf7'], 10, 3);
|
||
}
|
||
|
||
// Elementor forms
|
||
if (!empty(self::$opts['protect_elementor']) && defined('ELEMENTOR_VERSION')) {
|
||
add_action('elementor/frontend/after_enqueue_scripts', [$this, 'elementor_enqueue']);
|
||
add_action('elementor_pro/forms/validation', [$this, 'validate_elementor'], 10, 2);
|
||
}
|
||
|
||
// Gravity Forms
|
||
if (!empty(self::$opts['protect_gravity']) && class_exists('GFForms')) {
|
||
add_filter('gform_form_tag', [$this, 'inject_gravity'], 10, 2);
|
||
add_filter('gform_validation', [$this, 'validate_gravity']);
|
||
}
|
||
|
||
// Search form
|
||
if (!empty(self::$opts['protect_search'])) {
|
||
add_action('get_search_form', [$this, 'inject_search']);
|
||
}
|
||
|
||
// Enqueue JS token generator
|
||
add_action('wp_enqueue_scripts', [$this, 'enqueue_token_script']);
|
||
}
|
||
|
||
/* ── Style (hide honeypot fields) ─────────────────────────── */
|
||
|
||
public function inject_honeypot_style(): void {
|
||
echo '<style>.itk-hp-field{display:none!important;visibility:hidden!important;opacity:0!important;position:absolute!important;left:-9999px!important;top:-9999px!important;}</style>' . "\n";
|
||
}
|
||
|
||
/* ── JS token (HMAC-based anti-CSRF) ─────────────────────── */
|
||
|
||
public function enqueue_token_script(): void {
|
||
$secret = $this->get_page_secret();
|
||
wp_add_inline_script('jquery-core', "
|
||
(function(){
|
||
var s='" . esc_js($secret) . "',n='" . esc_js(wp_create_nonce('itk_hp')) . "';
|
||
document.querySelectorAll('." . self::TOKEN_FIELD . "').forEach(function(f){
|
||
f.value=btoa(s+'|'+Date.now()+'|'+n);
|
||
});
|
||
document.querySelectorAll('." . self::TIME_FIELD . "').forEach(function(f){
|
||
f.value=Math.floor(Date.now()/1000);
|
||
});
|
||
})();
|
||
", 'after');
|
||
}
|
||
|
||
/* ── Field HTML generator ─────────────────────────────────── */
|
||
|
||
private function honeypot_html(string $form_type = ''): string {
|
||
$field = self::FIELD_PREFIX . substr(md5(uniqid()), 0, 8);
|
||
$ts = time();
|
||
$label = ['Your email address', 'Website URL', 'Full name'][array_rand(['a','b','c'])];
|
||
return sprintf(
|
||
'<div class="itk-hp-field" aria-hidden="true" tabindex="-1" style="display:none!important">
|
||
<label>%s <input type="text" name="%s" value="" autocomplete="off" tabindex="-1"></label>
|
||
<input type="hidden" name="%s" class="%s" value="">
|
||
<input type="hidden" name="%s" class="%s" value="%d">
|
||
</div>',
|
||
esc_html($label),
|
||
esc_attr($field),
|
||
self::TOKEN_FIELD, self::TOKEN_FIELD,
|
||
self::TIME_FIELD, self::TIME_FIELD,
|
||
$ts
|
||
);
|
||
}
|
||
|
||
/* ── Injectors ────────────────────────────────────────────── */
|
||
|
||
public function inject_generic(): void {
|
||
if (empty(self::$opts['enabled'])) return;
|
||
echo $this->honeypot_html(); // phpcs:ignore
|
||
}
|
||
|
||
public function inject_comment(): void {
|
||
if (empty(self::$opts['protect_comments'])) return;
|
||
echo $this->honeypot_html('comment'); // phpcs:ignore
|
||
}
|
||
|
||
public function inject_search(string $form): string {
|
||
return $form . $this->honeypot_html('search');
|
||
}
|
||
|
||
public function inject_cf7(string $content): string {
|
||
return $content . $this->honeypot_html('cf7');
|
||
}
|
||
|
||
public function inject_gravity(string $tag, array $form): string {
|
||
return $tag . $this->honeypot_html('gravity');
|
||
}
|
||
|
||
public function elementor_enqueue(): void {
|
||
// Elementor injects via JS – add hidden fields via wp_footer
|
||
add_action('wp_footer', [$this, 'inject_generic']);
|
||
}
|
||
|
||
/* ── Validators ───────────────────────────────────────────── */
|
||
|
||
private function check_honeypot(string $form_type): bool {
|
||
// Whitelisted IPs/UAs always pass honeypot validation.
|
||
if (ITK_Whitelist::allowed()) return true;
|
||
|
||
// 1. Honeypot field must be empty
|
||
foreach ($_POST as $key => $val) {
|
||
if (strpos($key, self::FIELD_PREFIX) === 0 && !empty($val)) {
|
||
$this->log_block($form_type, 'Honeypot field filled');
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// 2. Timing check
|
||
$opts = get_option('itk_honeypot', []);
|
||
$min_t = max(1, (int)($opts['min_time'] ?? 3));
|
||
$max_t = max(60, (int)($opts['max_time'] ?? 7200));
|
||
$ts = (int)($_POST[self::TIME_FIELD] ?? 0);
|
||
$elapsed = time() - $ts;
|
||
|
||
if ($ts > 0 && $elapsed < $min_t) {
|
||
$this->log_block($form_type, "Submitted too fast ({$elapsed}s)");
|
||
return false;
|
||
}
|
||
if ($ts > 0 && $elapsed > $max_t) {
|
||
$this->log_block($form_type, "Submitted too slow ({$elapsed}s > {$max_t}s)");
|
||
return false;
|
||
}
|
||
|
||
return true;
|
||
}
|
||
|
||
private function log_block(string $form_type, string $reason): void {
|
||
$event = [
|
||
'ip' => $this->get_ip(),
|
||
'form' => $form_type,
|
||
'reason' => $reason,
|
||
'uri' => $_SERVER['REQUEST_URI'] ?? '',
|
||
'ua' => $_SERVER['HTTP_USER_AGENT'] ?? '',
|
||
];
|
||
ITK_Database::log_honeypot($event);
|
||
ITK_HP_API::queue($event);
|
||
}
|
||
|
||
public function validate_comment(array $comment_data): array {
|
||
if (empty(self::$opts['protect_comments'])) return $comment_data;
|
||
if (!$this->check_honeypot('comment')) {
|
||
wp_die('Spam detected. Please go back and try again.', 'Spam Blocked', ['response' => 403]);
|
||
}
|
||
if (!$this->check_comment_content($comment_data)) {
|
||
wp_die('Your comment was flagged as spam. Please remove any links and try again.', 'Spam Blocked', ['response' => 403]);
|
||
}
|
||
return $comment_data;
|
||
}
|
||
|
||
/* ── Comment content spam checks ─────────────────────────── */
|
||
|
||
private function check_comment_content(array $data): bool {
|
||
if (ITK_Whitelist::allowed()) return true;
|
||
|
||
$email = trim($data['comment_author_email'] ?? '');
|
||
$content = $data['comment_content'] ?? '';
|
||
$url = trim($data['comment_author_url'] ?? '');
|
||
|
||
// 1. URL in email field — real emails never contain / or ?
|
||
if (!empty(self::$opts['block_url_in_email']) && $email !== '') {
|
||
$is_url_in_email = !filter_var($email, FILTER_VALIDATE_EMAIL)
|
||
&& preg_match('#(https?://|[a-z0-9-]+\.[a-z]{2,}/|[?&]ref=)#i', $email);
|
||
if ($is_url_in_email) {
|
||
$this->log_block('comment', 'URL in email field: ' . substr($email, 0, 100));
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// 2. Count URLs in comment body
|
||
$url_count = preg_match_all('#https?://\S+#i', $content);
|
||
$max_links = (int)(self::$opts['max_links'] ?? 2);
|
||
if ($max_links > 0 && $url_count > $max_links) {
|
||
$this->log_block('comment', "Too many URLs in comment ({$url_count})");
|
||
return false;
|
||
}
|
||
|
||
// 3. Any URL from a first-time commenter (0 approved comments)
|
||
if (!empty(self::$opts['block_links_new_commenters']) && ($url_count > 0 || $url !== '')) {
|
||
$approved = (int) get_comments([
|
||
'author_email' => $email,
|
||
'status' => 'approve',
|
||
'count' => true,
|
||
]);
|
||
if ($approved === 0) {
|
||
$this->log_block('comment', 'Link from first-time commenter');
|
||
return false;
|
||
}
|
||
}
|
||
|
||
return true;
|
||
}
|
||
|
||
public function validate_login($user, string $username, string $password) {
|
||
if (empty(self::$opts['protect_login'])) return $user;
|
||
if (!empty($username) && !$this->check_honeypot('login')) {
|
||
return new WP_Error('honeypot_blocked', 'Access denied.');
|
||
}
|
||
return $user;
|
||
}
|
||
|
||
public function validate_register($sanitized_user_login, $user_email, \WP_Error $errors): void {
|
||
if (empty(self::$opts['protect_register'])) return;
|
||
if (!$this->check_honeypot('register')) {
|
||
$errors->add('honeypot_blocked', 'Spam registration detected.');
|
||
}
|
||
}
|
||
|
||
public function validate_lost_password(\WP_Error $errors): void {
|
||
if (empty(self::$opts['protect_lost_password'])) return;
|
||
if (!$this->check_honeypot('lostpassword')) {
|
||
$errors->add('honeypot_blocked', 'Access denied.');
|
||
}
|
||
}
|
||
|
||
public function validate_woo_checkout(): void {
|
||
if (!$this->check_honeypot('woo_checkout')) {
|
||
wc_add_notice('Spam submission detected. Please refresh and try again.', 'error');
|
||
}
|
||
}
|
||
|
||
public function validate_woo_registration(\WP_Error $errors, $username, $email): \WP_Error {
|
||
if (!$this->check_honeypot('woo_register')) {
|
||
$errors->add('honeypot_blocked', 'Spam registration detected.');
|
||
}
|
||
return $errors;
|
||
}
|
||
|
||
public function validate_cf7($contact_form, &$abort, $submission): void {
|
||
if (!$this->check_honeypot('cf7')) {
|
||
$abort = true;
|
||
$contact_form->set_status('spam');
|
||
}
|
||
}
|
||
|
||
public function validate_elementor($record, $ajax_handler): void {
|
||
if (!$this->check_honeypot('elementor')) {
|
||
$ajax_handler->add_error_message('Spam detected. Please try again.');
|
||
}
|
||
}
|
||
|
||
public function validate_gravity(array $validation_result): array {
|
||
if (!$this->check_honeypot('gravity')) {
|
||
$validation_result['is_valid'] = false;
|
||
foreach ($validation_result['form']['fields'] as &$field) {
|
||
$field->failed_validation = true;
|
||
$field->validation_message = 'Spam detected.';
|
||
}
|
||
}
|
||
return $validation_result;
|
||
}
|
||
|
||
/* ── Helpers ──────────────────────────────────────────────── */
|
||
|
||
private function get_ip(): string {
|
||
$keys = [
|
||
'HTTP_CLIENT_IP','HTTP_X_FORWARDED_FOR','HTTP_X_FORWARDED',
|
||
'HTTP_X_CLUSTER_CLIENT_IP','HTTP_FORWARDED_FOR','HTTP_FORWARDED','REMOTE_ADDR',
|
||
];
|
||
foreach ($keys as $k) {
|
||
if (!empty($_SERVER[$k])) {
|
||
$ip = trim(explode(',', $_SERVER[$k])[0]);
|
||
if (filter_var($ip, FILTER_VALIDATE_IP)) return $ip;
|
||
}
|
||
}
|
||
return 'UNKNOWN';
|
||
}
|
||
|
||
private function get_page_secret(): string {
|
||
$secret = get_option('itk_hp_secret');
|
||
if (!$secret) {
|
||
$secret = wp_generate_password(32, false);
|
||
update_option('itk_hp_secret', $secret);
|
||
}
|
||
return $secret;
|
||
}
|
||
}
|