Files
InformatiQ-Toolkit/includes/class-itk-honeypot.php
Malin 1b9504e5f9 feat: comment spam content checks (URL-in-email, link limits)
- 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>
2026-04-17 07:18:49 +02:00

329 lines
13 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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;
}
}