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:
862
includes/class-itk-admin.php
Normal file
862
includes/class-itk-admin.php
Normal 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">■ 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>
|
||||
<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 & 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">« 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 »</a>';
|
||||
}
|
||||
echo '</div>';
|
||||
}
|
||||
}
|
||||
305
includes/class-itk-bot-blocker.php
Normal file
305
includes/class-itk-bot-blocker.php
Normal 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; }
|
||||
}
|
||||
283
includes/class-itk-database.php
Normal file
283
includes/class-itk-database.php
Normal 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
|
||||
));
|
||||
}
|
||||
}
|
||||
277
includes/class-itk-honeypot.php
Normal file
277
includes/class-itk-honeypot.php
Normal 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;
|
||||
}
|
||||
}
|
||||
359
includes/class-itk-optimization.php
Normal file
359
includes/class-itk-optimization.php
Normal 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 | 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;
|
||||
}
|
||||
}
|
||||
306
includes/class-itk-protection.php
Normal file
306
includes/class-itk-protection.php
Normal 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; }
|
||||
}
|
||||
Reference in New Issue
Block a user