feat: initial InformatiQ Toolkit plugin

Merges informatiq-wp-secure + informatiq-utils + HoneypotFields into
a single unified plugin with the following improvements:

- Fixed deactivation bug: all protection methods now guard themselves
  with their own option check so toggling off via AJAX takes effect
  immediately without any hook re-registration.
- Added rate-limiting for good/legitimate bots (Googlebot, Bingbot,
  DuckDuckBot, Yandex, etc.) via transient sliding-window counters;
  configurable per-bot limits in goodbots.conf (BotName|req/min);
  returns HTTP 429 with Retry-After: 60 when over limit.
- Unified MySQL-backed logging (itk_bot_log + itk_honeypot_log tables)
  replaces the old wp_options-based 100-entry cap.
- New Dashboard tab with terminal-style bot activity monitor: total
  blocked, today's count, rate-limited hits, top threat sources
  (bar chart), top IPs, top honeypot form types, active-module
  status panel.
- All optimizations from utils.php merged into Optimization tab as
  toggleable settings (was always-on before).
- Single admin page (Settings → InformatiQ Toolkit) with 8 tabs:
  Dashboard | Bot Blocker | Protection | Optimization | Honeypot |
  Bot Logs | Honeypot Logs | Config Files.
- Config file editor for badbots.conf, goodbots.conf, referrers.conf,
  networks.conf, allowed-ips.conf with AJAX save and transient flush.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-09 11:45:26 +02:00
commit 6d4349ff7b
17 changed files with 3739 additions and 0 deletions

View File

@@ -0,0 +1,862 @@
<?php
if (!defined('ABSPATH')) exit;
/**
* ITK Admin
*
* Single admin page with tabs:
* Dashboard | Bot Blocker | Protection | Optimization | Honeypot | Bot Logs | Honeypot Logs | Config Files
*/
class ITK_Admin {
const MENU_SLUG = 'informatiq-toolkit';
const NONCE_ACTION = 'itk_admin';
const PER_PAGE = 25;
public function __construct() {
add_action('admin_menu', [$this, 'add_menu']);
add_action('admin_enqueue_scripts', [$this, 'enqueue_assets']);
add_action('admin_init', [$this, 'handle_actions']);
add_action('wp_ajax_itk_save_setting', [$this, 'ajax_save_setting']);
add_action('wp_ajax_itk_save_config_file',[$this, 'ajax_save_config_file']);
}
public function add_menu(): void {
add_options_page(
'InformatiQ Toolkit',
'InformatiQ Toolkit',
'manage_options',
self::MENU_SLUG,
[$this, 'render_page']
);
}
public function enqueue_assets(string $hook): void {
if ($hook !== 'settings_page_' . self::MENU_SLUG) return;
wp_enqueue_style('itk-admin', ITK_URL . 'assets/css/admin.css', [], ITK_VERSION);
wp_enqueue_script('itk-admin', ITK_URL . 'assets/js/admin.js', ['jquery'], ITK_VERSION, true);
wp_localize_script('itk-admin', 'itkAdmin', [
'nonce' => wp_create_nonce(self::NONCE_ACTION),
'ajaxUrl' => admin_url('admin-ajax.php'),
]);
}
/* ── Actions (form submissions) ───────────────────────────── */
public function handle_actions(): void {
if (!isset($_POST['itk_action']) || !check_admin_referer(self::NONCE_ACTION)) return;
if (!current_user_can('manage_options')) wp_die('Unauthorized');
switch ($_POST['itk_action']) {
case 'clear_bot_log':
ITK_Database::clear_bot_log();
$this->redirect(['tab' => 'bot-logs', 'cleared' => 1]);
break;
case 'clear_honeypot_log':
ITK_Database::clear_honeypot_log();
$this->redirect(['tab' => 'honeypot-logs', 'cleared' => 1]);
break;
case 'save_settings_security':
$this->save_settings_form('itk_security', [
'response_code', 'redirect_url', 'custom_message',
], [
'log_blocked_attempts',
]);
$this->redirect(['tab' => 'bot-blocker', 'saved' => 1]);
break;
case 'save_settings_login':
$this->save_settings_form('itk_security', [
'custom_login_slug',
], [
'enable_custom_login',
]);
$this->redirect(['tab' => 'protection', 'saved' => 1]);
break;
case 'save_settings_honeypot':
$this->save_settings_form('itk_honeypot', [
'min_time', 'max_time', 'retain_days',
], []);
$this->redirect(['tab' => 'honeypot', 'saved' => 1]);
break;
}
}
/**
* Save a subset of fields from $_POST['itk_*'] into a WP option.
* $text_fields = scalar fields (sanitize_text_field)
* $toggle_fields = checkbox fields (0 or 1)
*/
private function save_settings_form(string $option, array $text_fields, array $toggle_fields): void {
$opts = get_option($option, []);
$posted = $_POST[$option] ?? [];
foreach ($text_fields as $key) {
if (isset($posted[$key])) {
$opts[$key] = sanitize_text_field(wp_unslash($posted[$key]));
}
}
foreach ($toggle_fields as $key) {
$opts[$key] = !empty($posted[$key]) ? 1 : 0;
}
update_option($option, $opts);
}
private function redirect(array $args): void {
wp_redirect(add_query_arg(array_merge(['page' => self::MENU_SLUG], $args), admin_url('options-general.php')));
exit;
}
/* ── AJAX: save single toggle setting ─────────────────────── */
public function ajax_save_setting(): void {
check_ajax_referer(self::NONCE_ACTION, 'nonce');
if (!current_user_can('manage_options')) wp_send_json_error('unauthorized');
$option = sanitize_key($_POST['option'] ?? '');
$setting = sanitize_key($_POST['setting'] ?? '');
$value = (int)($_POST['value'] ?? 0);
$allowed = ['itk_security','itk_optimization','itk_honeypot'];
if (!in_array($option, $allowed, true) || empty($setting)) {
wp_send_json_error('invalid');
}
$opts = get_option($option, []);
$opts[$setting] = $value;
update_option($option, $opts);
wp_send_json_success();
}
/* ── AJAX: save config file ───────────────────────────────── */
public function ajax_save_config_file(): void {
check_ajax_referer(self::NONCE_ACTION, 'nonce');
if (!current_user_can('manage_options')) wp_send_json_error('unauthorized');
$file = sanitize_key($_POST['file'] ?? '');
$content = wp_unslash($_POST['content'] ?? '');
$allowed = [
'badbots' => ITK_PATH . 'config/badbots.conf',
'goodbots' => ITK_PATH . 'config/goodbots.conf',
'referrers' => ITK_PATH . 'config/referrers.conf',
'networks' => ITK_PATH . 'config/networks.conf',
'allowed-ips'=> ITK_PATH . 'config/allowed-ips.conf',
];
if (!isset($allowed[$file])) wp_send_json_error('invalid file');
$result = file_put_contents($allowed[$file], sanitize_textarea_field($content));
if ($result === false) {
wp_send_json_error('write failed');
}
// Clear transient caches
delete_transient('itk_bots_list');
delete_transient('itk_referrers_list');
delete_transient('itk_networks_list');
delete_transient('itk_goodbots_list');
wp_send_json_success();
}
/* ── Main page render ─────────────────────────────────────── */
public function render_page(): void {
if (!current_user_can('manage_options')) return;
$tab = sanitize_key($_GET['tab'] ?? 'dashboard');
?>
<div class="wrap itk-wrap">
<h1 class="itk-page-title">
<span class="itk-logo">IQ</span> InformatiQ Toolkit
</h1>
<?php if (!empty($_GET['cleared'])): ?>
<div class="notice notice-success is-dismissible"><p>Logs cleared successfully.</p></div>
<?php endif; ?>
<?php if (!empty($_GET['saved'])): ?>
<div class="notice notice-success is-dismissible"><p>Settings saved.</p></div>
<?php endif; ?>
<nav class="itk-tabs">
<?php
$tabs = [
'dashboard' => 'Dashboard',
'bot-blocker' => 'Bot Blocker',
'protection' => 'Protection',
'optimization' => 'Optimization',
'honeypot' => 'Honeypot',
'bot-logs' => 'Bot Logs',
'honeypot-logs' => 'Honeypot Logs',
'config-files' => 'Config Files',
];
foreach ($tabs as $slug => $label):
$active = $tab === $slug ? 'itk-tab-active' : '';
$url = admin_url('options-general.php?page=' . self::MENU_SLUG . '&tab=' . $slug);
?>
<a href="<?= esc_url($url) ?>" class="itk-tab <?= $active ?>"><?= esc_html($label) ?></a>
<?php endforeach; ?>
</nav>
<div class="itk-tab-content">
<?php
match ($tab) {
'dashboard' => $this->tab_dashboard(),
'bot-blocker' => $this->tab_bot_blocker(),
'protection' => $this->tab_protection(),
'optimization' => $this->tab_optimization(),
'honeypot' => $this->tab_honeypot(),
'bot-logs' => $this->tab_bot_logs(),
'honeypot-logs' => $this->tab_honeypot_logs(),
'config-files' => $this->tab_config_files(),
default => $this->tab_dashboard(),
};
?>
</div>
</div>
<?php
}
/* ══════════════════════════════════════════════════════════
* TAB: DASHBOARD
* ══════════════════════════════════════════════════════════ */
private function tab_dashboard(): void {
$bot_stats = ITK_Database::get_bot_stats();
$hp_stats = ITK_Database::get_honeypot_stats();
?>
<div class="itk-dashboard">
<!-- ── Bot Activity Panel ── -->
<section class="itk-monitor-panel">
<div class="itk-monitor-header">
<span class="itk-monitor-title">&#9632; BOT ACTIVITY MONITOR</span>
<span class="itk-monitor-blink">LIVE</span>
</div>
<div class="itk-stat-row">
<div class="itk-stat-card">
<div class="itk-stat-num"><?= number_format($bot_stats['total']) ?></div>
<div class="itk-stat-lbl">Total Blocked</div>
</div>
<div class="itk-stat-card">
<div class="itk-stat-num itk-green"><?= number_format($bot_stats['today']) ?></div>
<div class="itk-stat-lbl">Today</div>
</div>
<div class="itk-stat-card">
<div class="itk-stat-num itk-yellow"><?= number_format($bot_stats['rate_limited']) ?></div>
<div class="itk-stat-lbl">Rate Limited</div>
</div>
<div class="itk-stat-card">
<div class="itk-stat-num"><?= number_format($hp_stats['total']) ?></div>
<div class="itk-stat-lbl">Honeypot Catches</div>
</div>
<div class="itk-stat-card">
<div class="itk-stat-num itk-green"><?= number_format($hp_stats['today']) ?></div>
<div class="itk-stat-lbl">Honeypot Today</div>
</div>
</div>
<!-- Top bot types bar chart -->
<?php if (!empty($bot_stats['top_bot_types'])): ?>
<div class="itk-chart-section">
<div class="itk-chart-title">TOP THREAT SOURCES</div>
<div class="itk-bar-chart">
<?php
$max = max(1, (int)$bot_stats['top_bot_types'][0]->cnt);
foreach ($bot_stats['top_bot_types'] as $row):
$pct = round(($row->cnt / $max) * 100);
?>
<div class="itk-bar-row">
<span class="itk-bar-label"><?= esc_html($row->bot_type ?: 'Unknown') ?></span>
<div class="itk-bar-track">
<div class="itk-bar-fill" style="width:<?= $pct ?>%"></div>
</div>
<span class="itk-bar-count"><?= number_format($row->cnt) ?></span>
</div>
<?php endforeach; ?>
</div>
</div>
<?php endif; ?>
<!-- Top honeypot form types -->
<?php if (!empty($hp_stats['top_forms'])): ?>
<div class="itk-chart-section">
<div class="itk-chart-title">HONEYPOT TOP TARGETED FORMS</div>
<div class="itk-bar-chart">
<?php
$max = max(1, (int)$hp_stats['top_forms'][0]->cnt);
foreach ($hp_stats['top_forms'] as $row):
$pct = round(($row->cnt / $max) * 100);
?>
<div class="itk-bar-row">
<span class="itk-bar-label"><?= esc_html($row->form_type) ?></span>
<div class="itk-bar-track">
<div class="itk-bar-fill itk-bar-hp" style="width:<?= $pct ?>%"></div>
</div>
<span class="itk-bar-count"><?= number_format($row->cnt) ?></span>
</div>
<?php endforeach; ?>
</div>
</div>
<?php endif; ?>
<!-- Top IPs -->
<?php if (!empty($bot_stats['top_ips'])): ?>
<div class="itk-chart-section">
<div class="itk-chart-title">TOP OFFENDER IPs</div>
<table class="itk-mini-table">
<tr><th>IP Address</th><th>Hits</th></tr>
<?php foreach ($bot_stats['top_ips'] as $row): ?>
<tr>
<td>
<a href="<?= esc_url(admin_url('options-general.php?page=' . self::MENU_SLUG . '&tab=bot-logs&hp_ip=' . urlencode($row->ip_address))) ?>"><?= esc_html($row->ip_address) ?></a>
&nbsp;<a href="https://ipinfo.io/<?= urlencode($row->ip_address) ?>" target="_blank" class="itk-lookup">[lookup]</a>
</td>
<td><?= number_format($row->cnt) ?></td>
</tr>
<?php endforeach; ?>
</table>
</div>
<?php endif; ?>
</section>
<!-- ── Quick Settings Status ── -->
<section class="itk-quick-status">
<div class="itk-chart-title">ACTIVE MODULES</div>
<?php
$sec = get_option('itk_security', []);
$opt = get_option('itk_optimization', []);
$hp = get_option('itk_honeypot', []);
$modules = [
'Bot Blocker' => !empty($sec['block_malicious_bots']),
'OpenAI Block' => !empty($sec['block_openai_bots']),
'Good Bot Rate Limit'=> !empty($sec['rate_limit_good_bots']),
'Network Block' => !empty($sec['block_bad_networks']),
'Login Protection' => !empty($sec['protect_wp_login']),
'Security Headers' => !empty($sec['add_security_headers']),
'Block XML-RPC' => !empty($sec['block_xmlrpc']),
'Honeypot' => !empty($hp['enabled']),
'Remove WP Version' => !empty($opt['remove_wp_version']),
'Disable Emoji' => !empty($opt['remove_emoji']),
'Admin Branding' => !empty($opt['admin_branding']),
];
foreach ($modules as $label => $active):
?>
<div class="itk-module-row">
<span class="itk-module-dot <?= $active ? 'itk-dot-on' : 'itk-dot-off' ?>"></span>
<span class="itk-module-label"><?= esc_html($label) ?></span>
<span class="itk-module-status"><?= $active ? 'ACTIVE' : 'off' ?></span>
</div>
<?php endforeach; ?>
</section>
</div>
<?php
}
/* ══════════════════════════════════════════════════════════
* TAB: BOT BLOCKER
* ══════════════════════════════════════════════════════════ */
private function tab_bot_blocker(): void {
$opts = get_option('itk_security', []);
?>
<div class="itk-settings-grid">
<section class="itk-card">
<h2>Bot Blocking</h2>
<?php
$toggles = [
'block_openai_bots' => ['OpenAI / GPT Bots', 'Block GPTBot, ChatGPT-User, OAI-SearchBot'],
'block_malicious_bots' => ['Malicious Bots', 'Block bots listed in badbots.conf'],
'block_bad_referrers' => ['Bad Referrers', 'Block requests from spam referrer domains'],
'block_bad_networks' => ['Bad Networks', 'Block IP ranges listed in networks.conf'],
'rate_limit_good_bots' => ['Rate-Limit Good Bots', 'Apply crawl-rate limits to Googlebot, Bingbot, etc. (configurable in goodbots.conf)'],
];
foreach ($toggles as $key => [$label, $desc]):
$this->render_toggle('itk_security', $key, $label, $desc, $opts);
endforeach;
?>
</section>
<section class="itk-card">
<h2>Response Settings</h2>
<form method="post" action="options-general.php?page=<?= self::MENU_SLUG ?>&tab=bot-blocker">
<?php wp_nonce_field(self::NONCE_ACTION); ?>
<input type="hidden" name="itk_action" value="save_settings_security">
<table class="form-table">
<tr>
<th>Response Code</th>
<td>
<select name="itk_security[response_code]">
<?php
$codes = ['301_custom' => '301 Redirect to custom URL', '403' => '403 Forbidden', '410' => '410 Gone', '503' => '503 Service Unavailable'];
$cur = $opts['response_code'] ?? '301_custom';
foreach ($codes as $val => $lbl):
?>
<option value="<?= esc_attr($val) ?>" <?= selected($cur, $val, false) ?>><?= esc_html($lbl) ?></option>
<?php endforeach; ?>
</select>
</td>
</tr>
<tr>
<th>Redirect URL</th>
<td><input type="url" name="itk_security[redirect_url]" value="<?= esc_attr($opts['redirect_url'] ?? '') ?>" class="regular-text"></td>
</tr>
<tr>
<th>Custom Message</th>
<td><input type="text" name="itk_security[custom_message]" value="<?= esc_attr($opts['custom_message'] ?? 'Access denied.') ?>" class="regular-text"></td>
</tr>
<tr>
<th>Log Blocked Attempts</th>
<td>
<label>
<input type="checkbox" name="itk_security[log_blocked_attempts]" value="1" <?= checked(!empty($opts['log_blocked_attempts'])) ?>>
Log all blocked attempts to the database
</label>
</td>
</tr>
</table>
<?php submit_button('Save Response Settings'); ?>
</form>
</section>
</div>
<?php
}
/* ══════════════════════════════════════════════════════════
* TAB: PROTECTION
* ══════════════════════════════════════════════════════════ */
private function tab_protection(): void {
$opts = get_option('itk_security', []);
?>
<div class="itk-settings-grid">
<section class="itk-card">
<h2>WordPress Protection</h2>
<?php
$toggles = [
'protect_wp_login' => ['WP Login IP Whitelist', 'Restrict wp-login.php to IPs in allowed-ips.conf'],
'add_security_headers' => ['Security Headers', 'Add X-Content-Type-Options, X-Frame-Options, X-XSS-Protection headers'],
'protect_wp_includes' => ['Protect WP Core Files', 'Block direct access to wp-includes, wp-admin/includes'],
'protect_uploads' => ['Block PHP in Uploads', 'Deny PHP file access and uploads in /wp-content/uploads/'],
'block_xmlrpc' => ['Block XML-RPC', 'Deny all access to xmlrpc.php'],
'block_malicious_queries' => ['Block Malicious Queries', 'Detect and block SQLi, XSS, and command injection in query strings'],
'block_author_scans' => ['Block Author Scans', 'Redirect ?author=N requests to prevent username enumeration'],
];
foreach ($toggles as $key => [$label, $desc]):
$this->render_toggle('itk_security', $key, $label, $desc, $opts);
endforeach;
?>
</section>
<section class="itk-card">
<h2>Custom Login URL</h2>
<form method="post" action="options-general.php?page=<?= self::MENU_SLUG ?>&tab=protection">
<?php wp_nonce_field(self::NONCE_ACTION); ?>
<input type="hidden" name="itk_action" value="save_settings_login">
<?php $this->render_toggle('itk_security', 'enable_custom_login', 'Enable Custom Login URL', 'Replace /wp-login.php with a custom slug', $opts); ?>
<table class="form-table">
<tr>
<th>Login Slug</th>
<td>
<code><?= esc_html(home_url('/')) ?></code>
<input type="text" name="itk_security[custom_login_slug]" value="<?= esc_attr($opts['custom_login_slug'] ?? 'thoushallpass') ?>" style="width:200px">
<p class="description">Characters: letters, numbers, dashes only.</p>
</td>
</tr>
</table>
<?php submit_button('Save Login Settings'); ?>
</form>
</section>
</div>
<?php
}
/* ══════════════════════════════════════════════════════════
* TAB: OPTIMIZATION
* ══════════════════════════════════════════════════════════ */
private function tab_optimization(): void {
$opts = get_option('itk_optimization', []);
?>
<div class="itk-settings-grid">
<section class="itk-card">
<h2>Performance &amp; Cleanup</h2>
<?php
$toggles = [
'remove_wp_version' => ['Remove WP Version', 'Strip WordPress version from <head> and all enqueued assets'],
'remove_script_versions' => ['Remove Asset Versions', 'Remove ?ver= query string from CSS/JS URLs'],
'remove_emoji' => ['Remove Emojis', 'Disable WordPress emoji scripts and styles'],
'deregister_wp_embed' => ['Remove WP Embed', 'Deregister the wp-embed script'],
'remove_wp_head_noise' => ['Clean WP Head', 'Remove RSD, wlwmanifest, feed links, and adjacent post links from <head>'],
'limit_revisions' => ['Limit Revisions', 'Keep only 3 post revisions and autosave every 5 minutes'],
'defer_js' => ['Defer JavaScript', 'Add defer attribute to non-critical scripts'],
'limit_heartbeat' => ['Limit Heartbeat', 'Restrict WordPress heartbeat to post editor pages only'],
'stop_empty_search_redirect'=> ['Fix Empty Search', 'Prevent redirect loop on empty search queries'],
'use_google_jquery' => ['Use Google jQuery', 'Load jQuery from Google CDN instead of local'],
'dns_prefetch' => ['DNS Prefetch', 'Enable DNS prefetching via meta header'],
];
foreach ($toggles as $key => [$label, $desc]):
$this->render_toggle('itk_optimization', $key, $label, $desc, $opts);
endforeach;
?>
</section>
<section class="itk-card">
<h2>Security Tweaks</h2>
<?php
$toggles = [
'hide_login_errors' => ['Hide Login Errors', 'Replace specific login error messages with a generic one'],
'remove_author_class' => ['Remove Author Class', 'Strip admin username from comment CSS classes'],
'remove_default_userfields'=> ['Remove User Fields', 'Remove AIM, Jabber, YIM from user profiles'],
'clean_bad_content' => ['Clean Bad Content', 'Remove empty tags, inline styles, and font tags on save'],
'change_author_base' => ['Change Author URL Base', "Change /author/ to /writer/ in author archive URLs"],
'disable_xml_rpc' => ['Disable XML-RPC (via filter)', 'Filter-based XML-RPC disable (additional to the blocker)'],
];
foreach ($toggles as $key => [$label, $desc]):
$this->render_toggle('itk_optimization', $key, $label, $desc, $opts);
endforeach;
?>
</section>
<section class="itk-card">
<h2>UI / Branding</h2>
<?php
$toggles = [
'disable_dashboard_widgets' => ['Disable Core Widgets', 'Remove WordPress News and WPEngine news dashboard widgets'],
'unregister_default_widgets'=> ['Unregister Sidebar Widgets', 'Remove Calendar, Archives, Meta, Search, Tag Cloud widgets'],
'disable_comments_url' => ['Remove Comment URL Field', 'Hide the website URL field from comment forms'],
'remove_admin_bar_links' => ['Clean Admin Bar', 'Remove WordPress logo, links, and noisy items from the toolbar'],
'admin_branding' => ['InformatiQ Branding', 'Add InformatiQ logo widget, toolbar link, admin notice, and custom footer'],
'disable_floc' => ['Disable FLoC', 'Add Permissions-Policy: interest-cohort=() header'],
'lightbox_images' => ['Lightbox Images', 'Add rel="lightbox" to image links in post content'],
'featured_image_rss' => ['Featured Image in RSS', 'Include featured image in RSS feed entries'],
];
foreach ($toggles as $key => [$label, $desc]):
$this->render_toggle('itk_optimization', $key, $label, $desc, $opts);
endforeach;
?>
</section>
</div>
<?php
}
/* ══════════════════════════════════════════════════════════
* TAB: HONEYPOT
* ══════════════════════════════════════════════════════════ */
private function tab_honeypot(): void {
$opts = get_option('itk_honeypot', []);
?>
<div class="itk-settings-grid">
<section class="itk-card">
<h2>Honeypot Fields</h2>
<?php
$toggles = [
'enabled' => ['Enable Honeypot', 'Inject invisible honeypot fields into all protected forms'],
'protect_comments' => ['Comments', 'Protect comment forms'],
'protect_login' => ['Login Form', 'Protect wp-login.php login'],
'protect_register' => ['Registration', 'Protect user registration'],
'protect_lost_password' => ['Lost Password', 'Protect lost password form'],
'protect_woocommerce' => ['WooCommerce', 'Protect WooCommerce checkout and registration'],
'protect_cf7' => ['Contact Form 7', 'Protect CF7 forms'],
'protect_elementor' => ['Elementor Forms', 'Protect Elementor Pro form widget'],
'protect_gravity' => ['Gravity Forms', 'Protect Gravity Forms'],
'protect_search' => ['Search Form', 'Protect the WordPress search form'],
];
foreach ($toggles as $key => [$label, $desc]):
$this->render_toggle('itk_honeypot', $key, $label, $desc, $opts);
endforeach;
?>
</section>
<section class="itk-card">
<h2>Timing Rules</h2>
<form method="post" action="options-general.php?page=<?= self::MENU_SLUG ?>&tab=honeypot">
<?php wp_nonce_field(self::NONCE_ACTION); ?>
<input type="hidden" name="itk_action" value="save_settings_honeypot">
<table class="form-table">
<tr>
<th>Minimum Submit Time (seconds)</th>
<td>
<input type="number" name="itk_honeypot[min_time]" value="<?= (int)($opts['min_time'] ?? 3) ?>" min="1" max="60">
<p class="description">Block submissions faster than this (bots submit instantly).</p>
</td>
</tr>
<tr>
<th>Maximum Submit Time (seconds)</th>
<td>
<input type="number" name="itk_honeypot[max_time]" value="<?= (int)($opts['max_time'] ?? 7200) ?>" min="60">
<p class="description">Block submissions older than this (stale/replayed forms).</p>
</td>
</tr>
<tr>
<th>Retain Logs (days)</th>
<td>
<input type="number" name="itk_honeypot[retain_days]" value="<?= (int)($opts['retain_days'] ?? 90) ?>" min="1">
<p class="description">Automatically prune logs older than this many days.</p>
</td>
</tr>
</table>
<?php submit_button('Save Honeypot Settings'); ?>
</form>
</section>
</div>
<?php
}
/* ══════════════════════════════════════════════════════════
* TAB: BOT LOGS
* ══════════════════════════════════════════════════════════ */
private function tab_bot_logs(): void {
$search = sanitize_text_field($_GET['hp_search'] ?? '');
$filter_ip= sanitize_text_field($_GET['hp_ip'] ?? '');
$filter_bt= sanitize_text_field($_GET['hp_bot'] ?? '');
$filter_ac= sanitize_key($_GET['hp_action'] ?? '');
$paged = max(1, (int)($_GET['paged'] ?? 1));
$offset = ($paged - 1) * self::PER_PAGE;
$args = ['per_page' => self::PER_PAGE, 'offset' => $offset];
if ($search) $args['search'] = $search;
if ($filter_ip) $args['ip'] = $filter_ip;
if ($filter_bt) $args['bot_type'] = $filter_bt;
if ($filter_ac) $args['action'] = $filter_ac;
$rows = ITK_Database::get_bot_rows($args);
$total = ITK_Database::count_bot_rows($args);
$bot_types = ITK_Database::get_bot_types();
$total_pages= max(1, (int)ceil($total / self::PER_PAGE));
$base_url = admin_url('options-general.php?page=' . self::MENU_SLUG . '&tab=bot-logs');
?>
<div class="itk-log-page">
<!-- Filters -->
<form method="get" class="itk-filters">
<input type="hidden" name="page" value="<?= self::MENU_SLUG ?>">
<input type="hidden" name="tab" value="bot-logs">
<input type="text" name="hp_search" placeholder="Search IP, UA, reason…" value="<?= esc_attr($search) ?>">
<input type="text" name="hp_ip" placeholder="Filter by IP" value="<?= esc_attr($filter_ip) ?>">
<select name="hp_bot">
<option value="">All bot types</option>
<?php foreach ($bot_types as $bt): ?>
<option value="<?= esc_attr($bt) ?>" <?= selected($filter_bt, $bt, false) ?>><?= esc_html($bt) ?></option>
<?php endforeach; ?>
</select>
<select name="hp_action">
<option value="">All actions</option>
<option value="blocked" <?= selected($filter_ac, 'blocked', false) ?>>Blocked</option>
<option value="rate_limited" <?= selected($filter_ac, 'rate_limited', false) ?>>Rate Limited</option>
</select>
<input type="submit" class="button" value="Filter">
<a href="<?= esc_url($base_url) ?>" class="button">Reset</a>
</form>
<p class="itk-count">Showing <?= count($rows) ?> of <?= number_format($total) ?> result(s)</p>
<table class="itk-log-table widefat striped">
<thead>
<tr>
<th>Date / Time</th><th>IP</th><th>Bot Type</th>
<th>Action</th><th>Reason</th><th>URI</th><th>User Agent</th>
</tr>
</thead>
<tbody>
<?php if (empty($rows)): ?>
<tr><td colspan="7" class="itk-no-results">No bot activity logged yet.</td></tr>
<?php else: ?>
<?php foreach ($rows as $row):
$action_class = $row->action === 'rate_limited' ? 'itk-badge-warn' : 'itk-badge-block';
?>
<tr>
<td class="itk-nowrap"><?= esc_html($row->logged_at) ?></td>
<td>
<?= esc_html($row->ip_address) ?>
<a href="<?= esc_url($base_url . '&hp_ip=' . urlencode($row->ip_address)) ?>" class="itk-filter-link">[filter]</a>
<a href="https://ipinfo.io/<?= urlencode($row->ip_address) ?>" target="_blank" class="itk-filter-link">[lookup]</a>
</td>
<td><?= esc_html($row->bot_type) ?></td>
<td><span class="itk-badge <?= $action_class ?>"><?= esc_html($row->action) ?></span></td>
<td><?= esc_html($row->reason) ?></td>
<td class="itk-uri"><?= esc_html(substr($row->request_uri, 0, 80)) ?></td>
<td class="itk-ua"><?= esc_html(substr($row->user_agent, 0, 100)) ?></td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
<?php $this->render_pager($paged, $total_pages, $base_url); ?>
<!-- Clear logs -->
<form method="post" style="margin-top:16px" onsubmit="return confirm('Delete ALL bot log entries?')">
<?php wp_nonce_field(self::NONCE_ACTION); ?>
<input type="hidden" name="itk_action" value="clear_bot_log">
<input type="submit" class="button button-secondary itk-btn-danger" value="Clear All Bot Logs">
</form>
</div>
<?php
}
/* ══════════════════════════════════════════════════════════
* TAB: HONEYPOT LOGS
* ══════════════════════════════════════════════════════════ */
private function tab_honeypot_logs(): void {
$search = sanitize_text_field($_GET['hp_search'] ?? '');
$filter_ip = sanitize_text_field($_GET['hp_ip'] ?? '');
$filter_form = sanitize_text_field($_GET['hp_form'] ?? '');
$paged = max(1, (int)($_GET['paged'] ?? 1));
$offset = ($paged - 1) * self::PER_PAGE;
$args = ['per_page' => self::PER_PAGE, 'offset' => $offset];
if ($search) $args['search'] = $search;
if ($filter_ip) $args['ip'] = $filter_ip;
if ($filter_form) $args['form'] = $filter_form;
$rows = ITK_Database::get_honeypot_rows($args);
$total = ITK_Database::count_honeypot_rows($args);
$form_types = ITK_Database::get_honeypot_form_types();
$total_pages = max(1, (int)ceil($total / self::PER_PAGE));
$base_url = admin_url('options-general.php?page=' . self::MENU_SLUG . '&tab=honeypot-logs');
?>
<div class="itk-log-page">
<form method="get" class="itk-filters">
<input type="hidden" name="page" value="<?= self::MENU_SLUG ?>">
<input type="hidden" name="tab" value="honeypot-logs">
<input type="text" name="hp_search" placeholder="Search IP, UA, reason…" value="<?= esc_attr($search) ?>">
<input type="text" name="hp_ip" placeholder="Filter by IP" value="<?= esc_attr($filter_ip) ?>">
<select name="hp_form">
<option value="">All form types</option>
<?php foreach ($form_types as $ft): ?>
<option value="<?= esc_attr($ft) ?>" <?= selected($filter_form, $ft, false) ?>><?= esc_html($ft) ?></option>
<?php endforeach; ?>
</select>
<input type="submit" class="button" value="Filter">
<a href="<?= esc_url($base_url) ?>" class="button">Reset</a>
</form>
<p class="itk-count">Showing <?= count($rows) ?> of <?= number_format($total) ?> result(s)</p>
<table class="itk-log-table widefat striped">
<thead>
<tr>
<th>Date / Time</th><th>IP</th><th>Form Type</th>
<th>Reason</th><th>URI</th><th>User Agent</th>
</tr>
</thead>
<tbody>
<?php if (empty($rows)): ?>
<tr><td colspan="6" class="itk-no-results">No honeypot catches yet.</td></tr>
<?php else: ?>
<?php foreach ($rows as $row): ?>
<tr>
<td class="itk-nowrap"><?= esc_html($row->blocked_at) ?></td>
<td>
<?= esc_html($row->ip_address) ?>
<a href="<?= esc_url($base_url . '&hp_ip=' . urlencode($row->ip_address)) ?>" class="itk-filter-link">[filter]</a>
<a href="https://ipinfo.io/<?= urlencode($row->ip_address) ?>" target="_blank" class="itk-filter-link">[lookup]</a>
</td>
<td><span class="itk-badge itk-badge-hp"><?= esc_html($row->form_type) ?></span></td>
<td><?= esc_html($row->reason) ?></td>
<td class="itk-uri"><?= esc_html(substr($row->request_uri, 0, 80)) ?></td>
<td class="itk-ua"><?= esc_html(substr($row->user_agent, 0, 100)) ?></td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
<?php $this->render_pager($paged, $total_pages, $base_url); ?>
<form method="post" style="margin-top:16px" onsubmit="return confirm('Delete ALL honeypot log entries?')">
<?php wp_nonce_field(self::NONCE_ACTION); ?>
<input type="hidden" name="itk_action" value="clear_honeypot_log">
<input type="submit" class="button button-secondary itk-btn-danger" value="Clear All Honeypot Logs">
</form>
</div>
<?php
}
/* ══════════════════════════════════════════════════════════
* TAB: CONFIG FILES
* ══════════════════════════════════════════════════════════ */
private function tab_config_files(): void {
$files = [
'badbots' => ['Bad Bots', 'config/badbots.conf', 'One bot user-agent substring per line. Lines starting with # are comments.'],
'goodbots' => ['Good Bots', 'config/goodbots.conf', 'Format: BotName|rate_per_minute (0 = always block)'],
'referrers' => ['Bad Referrers', 'config/referrers.conf', 'One domain substring per line.'],
'networks' => ['Bad Networks', 'config/networks.conf', 'One IP or CIDR range per line (e.g. 1.2.3.0/24).'],
'allowed-ips' => ['Allowed IPs', 'config/allowed-ips.conf','IPs/CIDRs allowed to access wp-login.php (one per line).'],
];
$active_file = sanitize_key($_GET['file'] ?? 'badbots');
if (!isset($files[$active_file])) $active_file = 'badbots';
[$title, $path, $desc] = $files[$active_file];
$full_path = ITK_PATH . $path;
$content = file_exists($full_path) ? file_get_contents($full_path) : '';
?>
<div class="itk-config-editor">
<div class="itk-config-tabs">
<?php foreach ($files as $slug => [$label]): ?>
<a href="<?= esc_url(admin_url('options-general.php?page=' . self::MENU_SLUG . '&tab=config-files&file=' . $slug)) ?>"
class="itk-config-tab <?= $slug === $active_file ? 'active' : '' ?>"><?= esc_html($label) ?></a>
<?php endforeach; ?>
</div>
<div class="itk-config-editor-area">
<h3><?= esc_html($title) ?> <code><?= esc_html($path) ?></code></h3>
<p class="description"><?= esc_html($desc) ?></p>
<textarea id="itk-config-content" rows="25" class="itk-config-textarea"><?= esc_textarea($content) ?></textarea>
<p>
<button id="itk-save-config" class="button button-primary" data-file="<?= esc_attr($active_file) ?>">Save File</button>
<span id="itk-config-status" style="margin-left:10px;color:green;display:none">Saved!</span>
</p>
</div>
</div>
<?php
}
/* ── Shared helpers ───────────────────────────────────────── */
private function render_toggle(string $option, string $key, string $label, string $desc, array $opts): void {
$checked = !empty($opts[$key]);
?>
<div class="itk-toggle-row">
<div class="itk-toggle-info">
<span class="itk-toggle-label"><?= esc_html($label) ?></span>
<span class="itk-toggle-desc"><?= esc_html($desc) ?></span>
</div>
<label class="itk-switch">
<input type="checkbox"
class="itk-toggle-input"
data-option="<?= esc_attr($option) ?>"
data-setting="<?= esc_attr($key) ?>"
<?= $checked ? 'checked' : '' ?>>
<span class="itk-slider"></span>
</label>
</div>
<?php
}
private function render_pager(int $paged, int $total_pages, string $base_url): void {
if ($total_pages <= 1) return;
echo '<div class="itk-pager">';
if ($paged > 1) {
echo '<a href="' . esc_url(add_query_arg('paged', $paged - 1, $base_url)) . '" class="button">&laquo; Prev</a>';
}
echo '<span>' . sprintf('Page %d of %d', $paged, $total_pages) . '</span>';
if ($paged < $total_pages) {
echo '<a href="' . esc_url(add_query_arg('paged', $paged + 1, $base_url)) . '" class="button">Next &raquo;</a>';
}
echo '</div>';
}
}

View File

@@ -0,0 +1,305 @@
<?php
if (!defined('ABSPATH')) exit;
/**
* ITK Bot Blocker
*
* Handles detection and blocking of malicious bots, bad referrers, and bad
* networks. Good/legitimate bots are rate-limited instead of blocked.
*
* Deactivation bug fix: every check method reads options at call time so
* toggling a setting via AJAX takes effect immediately without any hook
* re-registration.
*/
class ITK_Bot_Blocker {
private string $badbots_file;
private string $referrers_file;
private string $networks_file;
private string $goodbots_file;
public function __construct() {
$this->badbots_file = ITK_PATH . 'config/badbots.conf';
$this->referrers_file = ITK_PATH . 'config/referrers.conf';
$this->networks_file = ITK_PATH . 'config/networks.conf';
$this->goodbots_file = ITK_PATH . 'config/goodbots.conf';
// Always hook; each method guards itself with its own option check.
add_action('init', [$this, 'check_request'], 1);
add_filter('robots_txt', [$this, 'modify_robots_txt'], 10, 2);
}
/* ── Main entry point ─────────────────────────────────────── */
public function check_request(): void {
// Never block logged-in admins.
if (is_admin() || (function_exists('current_user_can') && current_user_can('manage_options'))) {
return;
}
$options = get_option('itk_security', []);
$ua = $_SERVER['HTTP_USER_AGENT'] ?? '';
$referrer = $_SERVER['HTTP_REFERER'] ?? '';
$ip = $this->get_client_ip();
$uri = $_SERVER['REQUEST_URI'] ?? '';
// ── 1. Rate-limit good/legitimate bots ─────────────────
if (!empty($options['rate_limit_good_bots'])) {
$good_bot = $this->identify_good_bot($ua);
if ($good_bot !== null) {
$this->handle_good_bot($good_bot, $ua, $ip, $uri);
return; // Handled don't fall through to block checks.
}
}
// ── 2. Block OpenAI bots ───────────────────────────────
if (!empty($options['block_openai_bots']) && $this->is_openai_bot($ua)) {
$this->block('OpenAI bot detected', 'openai', $ua, $referrer, $ip, $uri, $options);
}
// ── 3. Block malicious bots ────────────────────────────
if (!empty($options['block_malicious_bots']) && $this->is_malicious_bot($ua)) {
$this->block('Malicious bot detected', 'malicious_bot', $ua, $referrer, $ip, $uri, $options);
}
// ── 4. Block bad referrers ─────────────────────────────
if (!empty($options['block_bad_referrers']) && $this->is_bad_referrer($referrer)) {
$this->block('Bad referrer detected', 'bad_referrer', $ua, $referrer, $ip, $uri, $options);
}
// ── 5. Block bad networks ──────────────────────────────
if (!empty($options['block_bad_networks']) && $this->is_bad_network($ip)) {
$this->block('IP in blocked network', 'bad_network', $ua, $referrer, $ip, $uri, $options);
}
}
/* ── Good-bot rate limiting ───────────────────────────────── */
/**
* Returns ['name' => string, 'limit' => int] or null if not a known good bot.
* A limit of 0 means "never allow" (treat as blocked).
*/
private function identify_good_bot(string $ua): ?array {
if (empty($ua)) return null;
$cache_key = 'itk_goodbots_list';
$list = get_transient($cache_key);
if ($list === false) {
$list = [];
if (file_exists($this->goodbots_file)) {
foreach (file($this->goodbots_file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) {
$line = trim($line);
if ($line === '' || $line[0] === '#') continue;
$parts = explode('|', $line, 2);
$list[] = ['name' => trim($parts[0]), 'limit' => isset($parts[1]) ? (int)$parts[1] : 30];
}
}
set_transient($cache_key, $list, 300);
}
foreach ($list as $entry) {
if (stripos($ua, $entry['name']) !== false) {
return $entry;
}
}
return null;
}
private function handle_good_bot(array $bot, string $ua, string $ip, string $uri): void {
$options = get_option('itk_security', []);
$name = $bot['name'];
$limit = (int)$bot['limit'];
// Limit of 0 = always block this "good" bot (e.g. GPTBot still in goodbots.conf)
if ($limit === 0) {
$this->block("Good bot with limit 0: {$name}", $name, $ua, '', $ip, $uri, $options);
return;
}
// Sliding window: track hits per bot per minute using transients.
$window = (int)(time() / 60); // 1-minute window
$tk_key = 'itk_rl_' . md5($name) . '_' . $window;
$count = (int)get_transient($tk_key);
if ($count >= $limit) {
// Over the limit log and send 429.
if (!empty($options['log_blocked_attempts'])) {
ITK_Database::log_bot([
'ip' => $ip,
'ua' => $ua,
'referrer' => '',
'uri' => $uri,
'bot_type' => $name,
'reason' => "Rate limited: {$count}/{$limit} req/min",
'action' => 'rate_limited',
]);
}
status_header(429);
header('Retry-After: 60');
header('X-ITK-Rate-Limit: ' . $limit);
echo 'Too Many Requests. Crawl-delay: 60';
exit;
}
// Under the limit increment counter and allow through.
set_transient($tk_key, $count + 1, 120);
}
/* ── Blocking ─────────────────────────────────────────────── */
private function block(
string $reason,
string $bot_type,
string $ua,
string $referrer,
string $ip,
string $uri,
array $options
): void {
if (!empty($options['log_blocked_attempts'])) {
ITK_Database::log_bot([
'ip' => $ip,
'ua' => $ua,
'referrer' => $referrer,
'uri' => $uri,
'bot_type' => $bot_type,
'reason' => $reason,
'action' => 'blocked',
]);
}
$code = $options['response_code'] ?? '403';
$message = $options['custom_message'] ?? 'Access denied.';
$redir = $options['redirect_url'] ?? '';
if ($code === '301_custom' && !empty($redir)) {
header('Location: ' . esc_url_raw($redir), true, 301);
} else {
status_header((int)$code ?: 403);
echo esc_html($message);
}
exit;
}
/* ── Detection helpers ────────────────────────────────────── */
private function is_openai_bot(string $ua): bool {
if (empty($ua)) return false;
foreach (['GPTBot', 'ChatGPT-User', 'OAI-SearchBot', 'whisper'] as $b) {
if (stripos($ua, $b) !== false) return true;
}
return false;
}
private function is_malicious_bot(string $ua): bool {
if (empty($ua)) return false;
foreach ($this->load_conf_list($this->badbots_file, 'itk_bots_list') as $bot) {
if (stripos($ua, $bot) !== false) return true;
}
return false;
}
private function is_bad_referrer(string $referrer): bool {
if (empty($referrer)) return false;
foreach ($this->load_conf_list($this->referrers_file, 'itk_referrers_list') as $ref) {
if (stripos($referrer, $ref) !== false) return true;
}
return false;
}
private function is_bad_network(string $ip): bool {
if (empty($ip) || $ip === 'UNKNOWN') return false;
foreach ($this->load_conf_list($this->networks_file, 'itk_networks_list') as $network) {
if (filter_var($network, FILTER_VALIDATE_IP)) {
if ($ip === $network) return true;
} elseif (strpos($network, '/') !== false) {
if ($this->ip_in_cidr($ip, $network)) return true;
}
}
return false;
}
/* ── Robots.txt ───────────────────────────────────────────── */
public function modify_robots_txt(string $output, string $public): string {
if ($public === '0') return $output;
$options = get_option('itk_security', []);
if (empty($options['block_openai_bots'])) return $output;
$output .= "\n# InformatiQ Toolkit AI bot disallow\n";
foreach (['GPTBot', 'ChatGPT-User', 'OAI-SearchBot'] as $bot) {
$output .= "User-agent: {$bot}\nDisallow: /\n\n";
}
return $output;
}
/* ── Config file readers ──────────────────────────────────── */
private function load_conf_list(string $file, string $cache_key): array {
$cached = get_transient($cache_key);
if ($cached !== false) return $cached;
if (!file_exists($file) || filesize($file) > 1048576) return [];
$lines = file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
$list = [];
foreach ($lines as $line) {
$line = trim($line);
if ($line === '' || $line[0] === '#') continue;
if (strlen($line) <= 200 && !preg_match('/[<>"\']/', $line)) {
$list[] = $line;
}
}
set_transient($cache_key, $list, 300);
return $list;
}
public function invalidate_cache(): void {
delete_transient('itk_bots_list');
delete_transient('itk_referrers_list');
delete_transient('itk_networks_list');
delete_transient('itk_goodbots_list');
}
/* ── IP utilities ─────────────────────────────────────────── */
public function get_client_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 $key) {
if (empty($_SERVER[$key])) continue;
$ip = trim(explode(',', $_SERVER[$key])[0]);
if ($key !== 'REMOTE_ADDR' && filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) {
return $ip;
}
if ($key === 'REMOTE_ADDR' && filter_var($ip, FILTER_VALIDATE_IP)) {
return $ip;
}
}
return 'UNKNOWN';
}
private function ip_in_cidr(string $ip, string $cidr): bool {
if (strpos($cidr, '/') === false) return false;
[$subnet, $mask] = explode('/', $cidr, 2);
if (!filter_var($subnet, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) return false;
if (!is_numeric($mask) || $mask < 0 || $mask > 32) return false;
if (!filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) return false;
$ip_long = ip2long($ip);
$sub_long = ip2long($subnet);
$mask_dec = ~((1 << (32 - (int)$mask)) - 1);
return ($ip_long & $mask_dec) === ($sub_long & $mask_dec);
}
/* ── Accessors for admin ──────────────────────────────────── */
public function get_badbots_file(): string { return $this->badbots_file; }
public function get_referrers_file(): string { return $this->referrers_file; }
public function get_networks_file(): string { return $this->networks_file; }
public function get_goodbots_file(): string { return $this->goodbots_file; }
}

View File

@@ -0,0 +1,283 @@
<?php
if (!defined('ABSPATH')) exit;
/**
* Database helper for InformatiQ Toolkit.
* Manages two log tables: bot_log and honeypot_log.
*/
class ITK_Database {
const DB_VERSION = 1;
const DB_VERSION_OPTION = 'itk_db_version';
/* ── Table names ──────────────────────────────────────────── */
public static function bot_table(): string {
global $wpdb;
return $wpdb->prefix . 'itk_bot_log';
}
public static function honeypot_table(): string {
global $wpdb;
return $wpdb->prefix . 'itk_honeypot_log';
}
/* ── Install / upgrade ────────────────────────────────────── */
public static function install() {
global $wpdb;
$charset = $wpdb->get_charset_collate();
$sql_bot = "CREATE TABLE " . self::bot_table() . " (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
logged_at DATETIME NOT NULL,
ip_address VARCHAR(45) NOT NULL DEFAULT '',
user_agent TEXT NOT NULL,
referrer VARCHAR(1000) NOT NULL DEFAULT '',
request_uri VARCHAR(1000) NOT NULL DEFAULT '',
bot_type VARCHAR(100) NOT NULL DEFAULT '',
reason VARCHAR(255) NOT NULL DEFAULT '',
action VARCHAR(20) NOT NULL DEFAULT 'blocked',
PRIMARY KEY (id),
KEY ip_address (ip_address),
KEY logged_at (logged_at),
KEY bot_type (bot_type),
KEY action (action)
) {$charset};";
$sql_hp = "CREATE TABLE " . self::honeypot_table() . " (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
blocked_at DATETIME NOT NULL,
ip_address VARCHAR(45) NOT NULL DEFAULT '',
form_type VARCHAR(100) NOT NULL DEFAULT '',
reason VARCHAR(255) NOT NULL DEFAULT '',
request_uri VARCHAR(1000) NOT NULL DEFAULT '',
user_agent TEXT NOT NULL,
PRIMARY KEY (id),
KEY ip_address (ip_address),
KEY blocked_at (blocked_at),
KEY form_type (form_type)
) {$charset};";
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
dbDelta($sql_bot);
dbDelta($sql_hp);
update_option(self::DB_VERSION_OPTION, self::DB_VERSION);
}
/* ── Bot log ──────────────────────────────────────────────── */
public static function log_bot(array $data): void {
global $wpdb;
$wpdb->insert(
self::bot_table(),
[
'logged_at' => current_time('mysql'),
'ip_address' => sanitize_text_field($data['ip'] ?? ''),
'user_agent' => sanitize_textarea_field($data['ua'] ?? ''),
'referrer' => esc_url_raw(substr($data['referrer'] ?? '', 0, 1000)),
'request_uri' => esc_url_raw(substr($data['uri'] ?? '', 0, 1000)),
'bot_type' => sanitize_text_field($data['bot_type'] ?? ''),
'reason' => sanitize_text_field($data['reason'] ?? ''),
'action' => sanitize_text_field($data['action'] ?? 'blocked'),
],
['%s','%s','%s','%s','%s','%s','%s','%s']
);
}
public static function get_bot_rows(array $args = []): array {
global $wpdb;
$table = self::bot_table();
$limit = max(1, (int)($args['per_page'] ?? 25));
$offset = max(0, (int)($args['offset'] ?? 0));
$where = '1=1';
$params = [];
if (!empty($args['action'])) {
$where .= ' AND action = %s';
$params[] = $args['action'];
}
if (!empty($args['bot_type'])) {
$where .= ' AND bot_type = %s';
$params[] = $args['bot_type'];
}
if (!empty($args['ip'])) {
$where .= ' AND ip_address = %s';
$params[] = $args['ip'];
}
if (!empty($args['search'])) {
$like = '%' . $wpdb->esc_like($args['search']) . '%';
$where .= ' AND (ip_address LIKE %s OR user_agent LIKE %s OR reason LIKE %s)';
$params[] = $like; $params[] = $like; $params[] = $like;
}
$params[] = $limit;
$params[] = $offset;
$sql = "SELECT * FROM {$table} WHERE {$where} ORDER BY logged_at DESC LIMIT %d OFFSET %d";
return $wpdb->get_results($wpdb->prepare($sql, $params)) ?: [];
}
public static function count_bot_rows(array $args = []): int {
global $wpdb;
$table = self::bot_table();
$where = '1=1';
$params = [];
if (!empty($args['action'])) {
$where .= ' AND action = %s';
$params[] = $args['action'];
}
if (!empty($args['bot_type'])) {
$where .= ' AND bot_type = %s';
$params[] = $args['bot_type'];
}
if (!empty($args['ip'])) {
$where .= ' AND ip_address = %s';
$params[] = $args['ip'];
}
if (!empty($args['search'])) {
$like = '%' . $wpdb->esc_like($args['search']) . '%';
$where .= ' AND (ip_address LIKE %s OR user_agent LIKE %s OR reason LIKE %s)';
$params[] = $like; $params[] = $like; $params[] = $like;
}
$sql = "SELECT COUNT(*) FROM {$table} WHERE {$where}";
return (int)($params ? $wpdb->get_var($wpdb->prepare($sql, $params)) : $wpdb->get_var($sql));
}
public static function get_bot_stats(): array {
global $wpdb;
$table = self::bot_table();
$today = current_time('Y-m-d');
return [
'total' => (int)$wpdb->get_var("SELECT COUNT(*) FROM {$table}"),
'today' => (int)$wpdb->get_var($wpdb->prepare("SELECT COUNT(*) FROM {$table} WHERE DATE(logged_at)=%s", $today)),
'blocked' => (int)$wpdb->get_var("SELECT COUNT(*) FROM {$table} WHERE action='blocked'"),
'rate_limited' => (int)$wpdb->get_var("SELECT COUNT(*) FROM {$table} WHERE action='rate_limited'"),
'top_bot_types' => $wpdb->get_results("SELECT bot_type, COUNT(*) as cnt FROM {$table} WHERE bot_type != '' GROUP BY bot_type ORDER BY cnt DESC LIMIT 8") ?: [],
'top_ips' => $wpdb->get_results("SELECT ip_address, COUNT(*) as cnt FROM {$table} GROUP BY ip_address ORDER BY cnt DESC LIMIT 5") ?: [],
'last_24h_counts' => $wpdb->get_results("SELECT DATE_FORMAT(logged_at,'%H:00') as hour, COUNT(*) as cnt FROM {$table} WHERE logged_at >= DATE_SUB(NOW(), INTERVAL 24 HOUR) GROUP BY hour ORDER BY hour ASC") ?: [],
];
}
public static function get_bot_types(): array {
global $wpdb;
return $wpdb->get_col("SELECT DISTINCT bot_type FROM " . self::bot_table() . " WHERE bot_type != '' ORDER BY bot_type ASC") ?: [];
}
public static function clear_bot_log(): void {
global $wpdb;
$wpdb->query("TRUNCATE TABLE " . self::bot_table());
}
public static function prune_bot_log(int $days): void {
global $wpdb;
$wpdb->query($wpdb->prepare(
"DELETE FROM " . self::bot_table() . " WHERE logged_at < DATE_SUB(NOW(), INTERVAL %d DAY)",
$days
));
}
/* ── Honeypot log ─────────────────────────────────────────── */
public static function log_honeypot(array $data): void {
global $wpdb;
$wpdb->insert(
self::honeypot_table(),
[
'blocked_at' => current_time('mysql'),
'ip_address' => sanitize_text_field($data['ip'] ?? ''),
'form_type' => sanitize_text_field($data['form'] ?? 'Unknown'),
'reason' => sanitize_text_field($data['reason'] ?? ''),
'request_uri' => esc_url_raw(substr($data['uri'] ?? '', 0, 1000)),
'user_agent' => sanitize_textarea_field($data['ua'] ?? ''),
],
['%s','%s','%s','%s','%s','%s']
);
}
public static function get_honeypot_rows(array $args = []): array {
global $wpdb;
$table = self::honeypot_table();
$limit = max(1, (int)($args['per_page'] ?? 25));
$offset = max(0, (int)($args['offset'] ?? 0));
$where = '1=1';
$params = [];
if (!empty($args['form'])) {
$where .= ' AND form_type = %s';
$params[] = $args['form'];
}
if (!empty($args['ip'])) {
$where .= ' AND ip_address = %s';
$params[] = $args['ip'];
}
if (!empty($args['search'])) {
$like = '%' . $wpdb->esc_like($args['search']) . '%';
$where .= ' AND (ip_address LIKE %s OR user_agent LIKE %s OR reason LIKE %s)';
$params[] = $like; $params[] = $like; $params[] = $like;
}
$params[] = $limit;
$params[] = $offset;
$sql = "SELECT * FROM {$table} WHERE {$where} ORDER BY blocked_at DESC LIMIT %d OFFSET %d";
return $wpdb->get_results($wpdb->prepare($sql, $params)) ?: [];
}
public static function count_honeypot_rows(array $args = []): int {
global $wpdb;
$table = self::honeypot_table();
$where = '1=1';
$params = [];
if (!empty($args['form'])) {
$where .= ' AND form_type = %s';
$params[] = $args['form'];
}
if (!empty($args['ip'])) {
$where .= ' AND ip_address = %s';
$params[] = $args['ip'];
}
if (!empty($args['search'])) {
$like = '%' . $wpdb->esc_like($args['search']) . '%';
$where .= ' AND (ip_address LIKE %s OR user_agent LIKE %s OR reason LIKE %s)';
$params[] = $like; $params[] = $like; $params[] = $like;
}
$sql = "SELECT COUNT(*) FROM {$table} WHERE {$where}";
return (int)($params ? $wpdb->get_var($wpdb->prepare($sql, $params)) : $wpdb->get_var($sql));
}
public static function get_honeypot_stats(): array {
global $wpdb;
$table = self::honeypot_table();
$today = current_time('Y-m-d');
return [
'total' => (int)$wpdb->get_var("SELECT COUNT(*) FROM {$table}"),
'today' => (int)$wpdb->get_var($wpdb->prepare("SELECT COUNT(*) FROM {$table} WHERE DATE(blocked_at)=%s", $today)),
'top_forms' => $wpdb->get_results("SELECT form_type, COUNT(*) as cnt FROM {$table} GROUP BY form_type ORDER BY cnt DESC LIMIT 8") ?: [],
'top_ips' => $wpdb->get_results("SELECT ip_address, COUNT(*) as cnt FROM {$table} GROUP BY ip_address ORDER BY cnt DESC LIMIT 5") ?: [],
];
}
public static function get_honeypot_form_types(): array {
global $wpdb;
return $wpdb->get_col("SELECT DISTINCT form_type FROM " . self::honeypot_table() . " ORDER BY form_type ASC") ?: [];
}
public static function clear_honeypot_log(): void {
global $wpdb;
$wpdb->query("TRUNCATE TABLE " . self::honeypot_table());
}
public static function prune_honeypot_log(int $days): void {
global $wpdb;
$wpdb->query($wpdb->prepare(
"DELETE FROM " . self::honeypot_table() . " WHERE blocked_at < DATE_SUB(NOW(), INTERVAL %d DAY)",
$days
));
}
}

View File

@@ -0,0 +1,277 @@
<?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 {
// 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 {
ITK_Database::log_honeypot([
'ip' => $this->get_ip(),
'form' => $form_type,
'reason' => $reason,
'uri' => $_SERVER['REQUEST_URI'] ?? '',
'ua' => $_SERVER['HTTP_USER_AGENT'] ?? '',
]);
}
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]);
}
return $comment_data;
}
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;
}
}

View File

@@ -0,0 +1,359 @@
<?php
if (!defined('ABSPATH')) exit;
/**
* ITK Optimization
*
* Merged from informatiq-wp-secure optimization class and informatiq-utils.
* All features are toggleable via itk_optimization option.
*/
class ITK_Optimization {
private array $opts;
public function __construct() {
$this->opts = get_option('itk_optimization', []);
$this->init();
}
private function on(string $key): bool {
return !empty($this->opts[$key]);
}
private function init(): void {
// ── Version / meta ──────────────────────────────────────
if ($this->on('remove_wp_version')) {
remove_action('wp_head', 'wp_generator');
add_filter('the_generator', '__return_empty_string');
}
// ── Login errors ────────────────────────────────────────
if ($this->on('hide_login_errors')) {
add_filter('login_errors', fn() => 'Something went wrong.');
}
// ── Comment class ───────────────────────────────────────
if ($this->on('remove_author_class')) {
add_filter('comment_class', [$this, 'remove_comment_author_class']);
}
// ── Script / style versions ─────────────────────────────
if ($this->on('remove_script_versions')) {
add_filter('style_loader_src', [$this, 'remove_version_param'], 999);
add_filter('script_loader_src', [$this, 'remove_version_param'], 999);
}
// ── Author base ─────────────────────────────────────────
if ($this->on('change_author_base')) {
add_action('init', [$this, 'change_author_base']);
}
// ── Revisions ───────────────────────────────────────────
if ($this->on('limit_revisions') && !defined('WP_POST_REVISIONS')) {
define('WP_POST_REVISIONS', 3);
}
if (!defined('AUTOSAVE_INTERVAL')) {
define('AUTOSAVE_INTERVAL', 300);
}
// ── Emoji ───────────────────────────────────────────────
if ($this->on('remove_emoji')) {
add_action('init', [$this, 'disable_emojis']);
}
// ── User fields ─────────────────────────────────────────
if ($this->on('remove_default_userfields')) {
add_filter('user_contactmethods', [$this, 'remove_default_userfields']);
}
// ── Content cleanup ─────────────────────────────────────
if ($this->on('clean_bad_content')) {
add_filter('content_save_pre', [$this, 'clean_bad_content']);
}
// ── WP head noise ───────────────────────────────────────
if ($this->on('remove_wp_head_noise')) {
$this->remove_wp_head_noise();
}
// ── XML-RPC ─────────────────────────────────────────────
if ($this->on('disable_xml_rpc')) {
add_filter('xmlrpc_enabled', '__return_false');
}
// ── WP Embed ────────────────────────────────────────────
if ($this->on('deregister_wp_embed')) {
add_action('wp_footer', [$this, 'deregister_wp_embed']);
}
// ── Empty search ────────────────────────────────────────
if ($this->on('stop_empty_search_redirect')) {
add_filter('request', [$this, 'stop_empty_search']);
}
// ── Widgets ─────────────────────────────────────────────
if ($this->on('unregister_default_widgets')) {
add_action('widgets_init', [$this, 'unregister_default_widgets'], 11);
}
// ── Defer JS ────────────────────────────────────────────
if ($this->on('defer_js')) {
add_filter('script_loader_tag', [$this, 'defer_js'], 10);
}
// ── Heartbeat ───────────────────────────────────────────
if ($this->on('limit_heartbeat')) {
add_filter('wpe_heartbeat_allowed_pages', [$this, 'limit_heartbeat_pages']);
}
// ── Dashboard widgets ───────────────────────────────────
if ($this->on('disable_dashboard_widgets')) {
add_action('admin_init', [$this, 'disable_dashboard_widgets'], 9999);
}
add_action('wp_dashboard_setup', [$this, 'add_itq_dashboard_widget']);
// ── Comment URL field ───────────────────────────────────
if ($this->on('disable_comments_url')) {
add_filter('comment_form_default_fields', [$this, 'remove_comment_url']);
}
// ── Google FLoC / Permissions-Policy ────────────────────
if ($this->on('disable_floc')) {
add_filter('wp_headers', [$this, 'disable_floc']);
}
// ── Lightbox images ─────────────────────────────────────
if ($this->on('lightbox_images')) {
add_filter('the_content', [$this, 'add_lightbox_rel']);
}
// ── Admin bar cleanup ───────────────────────────────────
if ($this->on('remove_admin_bar_links')) {
add_action('wp_before_admin_bar_render', [$this, 'remove_admin_bar_links']);
}
// ── Admin branding ──────────────────────────────────────
if ($this->on('admin_branding')) {
add_filter('admin_footer_text', [$this, 'admin_footer_text']);
add_action('admin_bar_menu', [$this, 'toolbar_link'], 999);
add_action('admin_bar_menu', [$this, 'remove_wp_logo'], 999);
add_action('admin_notices', [$this, 'admin_notice']);
}
// ── RSS featured image ──────────────────────────────────
if ($this->on('featured_image_rss')) {
add_filter('the_excerpt_rss', [$this, 'featured_to_rss']);
add_filter('the_content_feed', [$this, 'featured_to_rss']);
}
// ── DNS prefetch ────────────────────────────────────────
if ($this->on('dns_prefetch')) {
add_action('wp_head', [$this, 'dns_prefetch'], 1);
}
// ── Google jQuery ───────────────────────────────────────
if ($this->on('use_google_jquery')) {
add_action('init', [$this, 'use_google_jquery']);
}
}
/* ── Callback implementations ─────────────────────────────── */
public function remove_comment_author_class(array $classes): array {
return array_filter($classes, fn($c) => strpos($c, 'comment-author-') === false);
}
public function remove_version_param(string $src): string {
return strpos($src, 'ver=') ? remove_query_arg('ver', $src) : $src;
}
public function change_author_base(): void {
global $wp_rewrite;
$wp_rewrite->author_base = 'writer';
}
public function disable_emojis(): void {
remove_action('wp_head', 'print_emoji_detection_script', 7);
remove_action('admin_print_scripts','print_emoji_detection_script');
remove_action('wp_print_styles', 'print_emoji_styles');
remove_action('admin_print_styles','print_emoji_styles');
remove_filter('the_content_feed', 'wp_staticize_emoji');
remove_filter('comment_text_rss', 'wp_staticize_emoji');
remove_filter('wp_mail', 'wp_staticize_emoji_for_email');
add_filter('tiny_mce_plugins', fn($p) => is_array($p) ? array_diff($p, ['wpemoji']) : []);
}
public function remove_default_userfields(array $fields): array {
foreach (['aim','jabber','yim'] as $f) unset($fields[$f]);
return $fields;
}
public function clean_bad_content(string $content): string {
return preg_replace([
"~<p[^>]*>\s?</p>~",
"~<a[^>]*>\s?</a>~",
"~<font[^>]*>~",
"~<\/font>~",
"~style\=\"[^\"]*\"~",
"~<span[^>]*>\s?</span>~",
], '', $content) ?? $content;
}
private function remove_wp_head_noise(): void {
remove_action('wp_head', 'wlwmanifest_link');
remove_action('wp_head', 'rsd_link');
remove_action('wp_head', 'wp_generator');
remove_action('wp_head', 'start_post_rel_link');
remove_action('wp_head', 'index_rel_link');
remove_action('wp_head', 'feed_links_extra', 3);
remove_action('wp_head', 'feed_links', 2);
remove_action('wp_head', 'parent_post_rel_link', 10, 0);
remove_action('wp_head', 'start_post_rel_link', 10, 0);
remove_action('wp_head', 'adjacent_posts_rel_link_wp_head', 10, 0);
}
public function deregister_wp_embed(): void {
wp_deregister_script('wp-embed');
}
public function stop_empty_search(array $vars): array {
if (isset($_GET['s']) && empty($_GET['s'])) $vars['s'] = ' ';
return $vars;
}
public function unregister_default_widgets(): void {
foreach ([
'WP_Widget_Calendar', 'WP_Widget_Archives', 'WP_Widget_Meta',
'WP_Widget_Search', 'WP_Widget_Tag_Cloud',
] as $w) {
if (class_exists($w)) unregister_widget($w);
}
}
public function defer_js(string $tag): string {
$defer = [
'owl-carousel.min.js','mansonry.js','imgloaded.js',
'jquery.magnific-popup.min.js','bgswitcher.js','exit.js',
'lazyload.js','app.js',
'add-to-cart.min.js','cart-fragments.min.js','woocommerce.min.js',
'wp-embed.min.js',
];
foreach ($defer as $s) {
if (strpos($tag, $s) !== false) {
return str_replace(' src', ' defer="defer" src', $tag);
}
}
return $tag;
}
public function limit_heartbeat_pages(array $allowed): array {
return ['index.php','admin.php','edit.php','post.php','post-new.php'];
}
public function disable_dashboard_widgets(): void {
remove_meta_box('dashboard_primary', 'dashboard', 'core');
remove_meta_box('wpe_dify_news_feed', 'dashboard', 'normal');
global $wp_meta_boxes;
unset($wp_meta_boxes['dashboard']['normal']['core']['dashboard_right_now']);
unset($wp_meta_boxes['dashboard']['side']['core']['dashboard_secondary']);
unset($wp_meta_boxes['dashboard']['side']['core']['dashboard_quick_press']);
}
public function add_itq_dashboard_widget(): void {
wp_add_dashboard_widget(
'itk_info_widget',
'Developed & Maintained by',
[$this, 'itq_dashboard_widget_content']
);
}
public function itq_dashboard_widget_content(): void {
echo '<div style="text-align:center">'
. '<a href="https://informatiq.services" target="_blank">'
. '<img src="https://informatiq.services/images/logo_IQ_transparentAsset_1.png" width="200"></a>'
. '<br><strong>Strategic Solutions, Intelligent IT Services</strong>'
. '<br><br>Email: <a href="mailto:support@informatiq.services">support@informatiq.services</a>'
. '<br>Phone: (+34) 971 560 060 &nbsp;|&nbsp; Emergency: (+34) 643 732 407'
. '</div>';
}
public function remove_comment_url(array $fields): array {
unset($fields['url']);
return $fields;
}
public function disable_floc(array $headers): array {
$headers['Permissions-Policy'] = 'interest-cohort=()';
return $headers;
}
public function add_lightbox_rel(string $content): string {
global $post;
$title = isset($post->post_title) ? esc_attr($post->post_title) : '';
$pattern = '/<a(.*?)href=([\'"])(.*?)\.(bmp|gif|jpeg|jpg|png)([\'"])(.*?)>/i';
$replace = '<a$1href=$2$3.$4$5 rel="lightbox" title="' . $title . '"$6>';
return preg_replace($pattern, $replace, $content) ?? $content;
}
public function remove_admin_bar_links(): void {
global $wp_admin_bar;
foreach (['wp-logo','about','wporg','documentation','support-forums','feedback','comments'] as $node) {
$wp_admin_bar->remove_menu($node);
}
}
public function admin_footer_text(): string {
return '<a href="https://wordpress.org" target="_blank">WordPress</a> Core | '
. 'Customizations by <a href="https://informatiq.services/" target="_blank"><strong>InformatiQ Services</strong></a>';
}
public function toolbar_link(\WP_Admin_Bar $bar): void {
$bar->add_node([
'id' => 'itq-support',
'title' => 'InformatiQ Services',
'href' => 'https://informatiq.services',
'meta' => ['class' => 'itq-support', 'title' => 'Strategic Solutions, Intelligent IT Services'],
]);
}
public function remove_wp_logo(\WP_Admin_Bar $bar): void {
$bar->remove_node('wp-logo');
}
public function admin_notice(): void {
echo '<div class="updated notice"><p>'
. esc_html__('This website has been developed and is being hosted and maintained by', 'informatiq-toolkit')
. ' <a href="https://informatiq.services" target="_blank">InformatiQ</a></p></div>';
}
public function featured_to_rss(string $content): string {
global $post;
if (!empty($post->ID) && has_post_thumbnail($post->ID)) {
$content = get_the_post_thumbnail($post->ID, 'thumbnail', ['style' => 'float:left;margin:0 15px 15px 0']) . $content;
}
return $content;
}
public function dns_prefetch(): void {
echo "\n<!-- DNS Prefetch -->\n"
. '<meta http-equiv="x-dns-prefetch-control" content="on">' . "\n"
. "<!-- /DNS Prefetch -->\n";
}
public function use_google_jquery(): void {
if (is_admin()) return;
wp_deregister_script('jquery');
wp_register_script('jquery', 'https://ajax.googleapis.com/ajax/libs/jquery/3.7.1/jquery.min.js', [], null, true);
wp_enqueue_script('jquery');
}
/* ── Option update (called by admin AJAX) ─────────────────── */
public function update_options(array $opts): void {
$this->opts = $opts;
}
public function get_options(): array {
return $this->opts;
}
}

View File

@@ -0,0 +1,306 @@
<?php
if (!defined('ABSPATH')) exit;
/**
* ITK Protection
*
* Handles wp-login protection, security headers, sensitive file blocking,
* malicious query blocking, and custom login URL.
*
* Deactivation bug fix: every method checks its own option key before acting.
* Hooks are always registered; guards live inside the callbacks.
*/
class ITK_Protection {
private array $allowed_ips = [];
private string $allowed_ips_file;
public function __construct() {
$this->allowed_ips_file = ITK_PATH . 'config/allowed-ips.conf';
$this->load_allowed_ips();
add_action('init', [$this, 'protect_wp_login'], 0);
add_action('init', [$this, 'block_sensitive_files'], 0);
add_action('init', [$this, 'block_malicious_queries'], 0);
add_action('init', [$this, 'block_author_scans'], 0);
add_action('init', [$this, 'custom_login_url'], 0);
add_action('send_headers', [$this, 'add_security_headers']);
add_action('wp_loaded', [$this, 'wp_loaded_custom_login']);
add_filter('the_generator', '__return_empty_string');
add_filter('wp_redirect', [$this, 'redirect_filter'], 10, 2);
add_filter('network_site_url', [$this, 'network_url_filter'], 10, 3);
add_filter('site_url', [$this, 'site_url_filter'], 10, 4);
add_filter('wp_handle_upload_prefilter', [$this, 'filter_uploaded_files']);
}
/* ── wp-login protection ──────────────────────────────────── */
public function protect_wp_login(): void {
$options = get_option('itk_security', []);
if (empty($options['protect_wp_login'])) return;
$uri = $_SERVER['REQUEST_URI'] ?? '';
if (strpos($uri, 'wp-login.php') === false) return;
$ip = $this->get_client_ip();
$is_allowed = false;
foreach ($this->allowed_ips as $allowed) {
if ($ip === $allowed) { $is_allowed = true; break; }
if (strpos($allowed, '/') !== false && $this->ip_in_cidr($ip, $allowed)) {
$is_allowed = true; break;
}
}
if (!$is_allowed) {
$this->send_403($options, 'Access to login page not allowed from your IP address.');
}
$ua = $_SERVER['HTTP_USER_AGENT'] ?? '';
$protocol = $_SERVER['SERVER_PROTOCOL'] ?? '';
if (empty($ua) || $protocol === 'HTTP/1.0') {
$this->send_403($options, 'Invalid request detected.');
}
}
/* ── Security headers ─────────────────────────────────────── */
public function add_security_headers(): void {
$options = get_option('itk_security', []);
if (empty($options['add_security_headers'])) return;
if (headers_sent()) return;
header_remove('X-Powered-By');
header('X-Content-Type-Options: nosniff');
header('X-Frame-Options: SAMEORIGIN');
header('X-XSS-Protection: 1; mode=block');
header('Referrer-Policy: strict-origin-when-cross-origin');
}
/* ── Sensitive file blocking ──────────────────────────────── */
public function block_sensitive_files(): void {
$options = get_option('itk_security', []);
$uri = $_SERVER['REQUEST_URI'] ?? '';
if (!empty($options['protect_wp_includes'])) {
if (preg_match('#^/wp-includes/[^/]+\.php$#i', $uri)) {
$this->send_403($options, 'Access to this file is not allowed.');
}
if (preg_match('#^/wp-admin/includes/#i', $uri)) {
$this->send_403($options, 'Access to this file is not allowed.');
}
if (preg_match('#^/wp-includes/theme-compat/#i', $uri)) {
$this->send_403($options, 'Access to this file is not allowed.');
}
if (preg_match('#/wp-includes/js/tinymce/langs/.+\.php#i', $uri)) {
$this->send_403($options, 'Access to this file is not allowed.');
}
if (preg_match('#(license\.txt|wp-config-sample\.php|readme\.html)$#i', $uri)) {
$this->send_403($options, 'Access to this file is not allowed.');
}
if (preg_match('#(?:^|/)\.(?!well-known)#', $uri)) {
$this->send_403($options, 'Access to hidden files is not allowed.');
}
}
if (!empty($options['protect_uploads'])) {
if (preg_match('#^/wp-content/uploads/.*\.(?:php[1-6]?|pht|phtml?)$#i', $uri)) {
$this->send_403($options, 'PHP files are not allowed in the uploads directory.');
}
}
if (!empty($options['block_xmlrpc'])) {
if (strpos($uri, 'xmlrpc.php') !== false) {
$this->send_403($options, 'XML-RPC is disabled on this site.');
}
}
}
/* ── Malicious query blocking ─────────────────────────────── */
public function block_malicious_queries(): void {
$options = get_option('itk_security', []);
if (empty($options['block_malicious_queries'])) return;
$qs = $_SERVER['QUERY_STRING'] ?? '';
if (empty($qs)) return;
$patterns = [
'(eval\()',
'(127\.0\.0\.1)',
'([a-z0-9]{2000})',
'(javascript:)(.*)(;)',
'(base64_encode)(.*)(\()',
'(GLOBALS|REQUEST)(=|\[|%)',
'(<|%3C)(.*)script(.*)(>|%3)',
'(boot\.ini|etc/passwd|self/environ)',
'(thumbs?(_editor|open)?|tim(thumb)?)\.php',
'(\'|\\")(.*)(drop|insert|md5|select|union)',
];
foreach ($patterns as $pattern) {
if (preg_match('#' . $pattern . '#i', $qs)) {
$this->send_403($options, 'Malicious query detected.');
}
}
$method = strtolower($_SERVER['REQUEST_METHOD'] ?? '');
if (preg_match('#^(connect|debug|delete|move|put|trace|track)$#', $method)) {
$this->send_403($options, 'This request method is not allowed.');
}
}
/* ── Author scan blocking ─────────────────────────────────── */
public function block_author_scans(): void {
$options = get_option('itk_security', []);
if (empty($options['block_author_scans'])) return;
$uri = $_SERVER['REQUEST_URI'] ?? '';
$qs = $_SERVER['QUERY_STRING'] ?? '';
if (strpos($uri, '/wp-admin') === false && preg_match('/author=\d+/i', $qs)) {
wp_redirect(home_url(), 301);
exit;
}
}
/* ── Custom login URL ─────────────────────────────────────── */
public function custom_login_url(): void {
$options = get_option('itk_security', []);
if (empty($options['enable_custom_login'])) return;
$slug = $this->custom_slug($options);
$path = parse_url($_SERVER['REQUEST_URI'] ?? '', PHP_URL_PATH) ?? '';
$qs = parse_url($_SERVER['REQUEST_URI'] ?? '', PHP_URL_QUERY) ?? '';
if (strpos($path, '/' . $slug) !== false) {
if (!session_id()) session_start();
$_SESSION['itk_login_access'] = time();
require_once ABSPATH . 'wp-login.php';
exit;
}
$blocked = ['/wp-login.php', '/wp-admin/', '/login/', '/admin/'];
foreach ($blocked as $b) {
if (strpos($path, $b) !== false) {
if (defined('DOING_AJAX') && DOING_AJAX) return;
if (!session_id()) session_start();
if (isset($_SESSION['itk_login_access']) && (time() - $_SESSION['itk_login_access']) < 300) return;
if (is_user_logged_in()) return;
$this->send_403($options, 'Access denied. Please use the correct login URL.');
}
}
}
public function wp_loaded_custom_login(): void {
$options = get_option('itk_security', []);
if (empty($options['enable_custom_login'])) return;
global $pagenow;
if ($pagenow === 'wp-login.php') {
if (!session_id()) session_start();
if (!isset($_SESSION['itk_login_access'])) {
$this->send_403($options, 'Access denied. Please use the correct login URL.');
}
}
}
public function redirect_filter(string $location, int $status): string {
$options = get_option('itk_security', []);
if (empty($options['enable_custom_login'])) return $location;
return str_replace('wp-login.php', $this->custom_slug($options), $location);
}
public function network_url_filter(string $url, string $path): string {
$options = get_option('itk_security', []);
if (empty($options['enable_custom_login'])) return $url;
if (strpos($path, 'wp-login.php') !== false) {
return str_replace('wp-login.php', $this->custom_slug($options), $url);
}
return $url;
}
public function site_url_filter(string $url, string $path): string {
$options = get_option('itk_security', []);
if (empty($options['enable_custom_login'])) return $url;
if (strpos($path, 'wp-login.php') !== false) {
return str_replace('wp-login.php', $this->custom_slug($options), $url);
}
return $url;
}
/* ── File upload filter ───────────────────────────────────── */
public function filter_uploaded_files(array $file): array {
$options = get_option('itk_security', []);
if (empty($options['protect_uploads'])) return $file;
if (preg_match('/\.(php|phtml|php\d|pht|exe|dll|asp|aspx|jsp|cgi|pl)$/i', $file['name'] ?? '')) {
$file['error'] = 'PHP and executable files cannot be uploaded.';
}
return $file;
}
/* ── Helpers ──────────────────────────────────────────────── */
private function send_403(array $options, string $message): void {
$code = $options['response_code'] ?? '403';
$redir = $options['redirect_url'] ?? '';
if ($code === '301_custom' && !empty($redir)) {
header('Location: ' . esc_url_raw($redir), true, 301);
} else {
status_header(403);
echo esc_html($message);
}
exit;
}
private function custom_slug(array $options): string {
return !empty($options['custom_login_slug']) ? $options['custom_login_slug'] : 'thoushallpass';
}
private function load_allowed_ips(): void {
$defaults = ['127.0.0.1', '::1'];
if (file_exists($this->allowed_ips_file)) {
$lines = file($this->allowed_ips_file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
foreach ($lines as $line) {
$line = trim($line);
if ($line !== '' && $line[0] !== '#') {
$this->allowed_ips[] = $line;
}
}
}
if (empty($this->allowed_ips)) {
$this->allowed_ips = $defaults;
}
}
private function get_client_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 $key) {
if (empty($_SERVER[$key])) continue;
$ip = trim(explode(',', $_SERVER[$key])[0]);
if (filter_var($ip, FILTER_VALIDATE_IP)) return $ip;
}
return 'UNKNOWN';
}
private function ip_in_cidr(string $ip, string $cidr): bool {
[$subnet, $mask] = explode('/', $cidr, 2);
if (!filter_var($subnet, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) return false;
if (!is_numeric($mask) || $mask < 0 || $mask > 32) return false;
if (!filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) return false;
$mask_dec = ~((1 << (32 - (int)$mask)) - 1);
return (ip2long($ip) & $mask_dec) === (ip2long($subnet) & $mask_dec);
}
public function get_allowed_ips(): array { return $this->allowed_ips; }
public function get_allowed_ips_file(): string { return $this->allowed_ips_file; }
}