feat: add WAF + Attack Intelligence system
- class-itk-waf.php: WordPress WAF scanning GET/POST/COOKIE/UA - class-itk-attacks-api.php: queue/flush/history client for Attack API - config/waf-rules.conf: 9 attack categories, 60+ WP-specific rules - class-itk-database.php: itk_attack_log table, DB version 2 - class-itk-admin.php: WAF tab (toggles, response settings, API card), Attack Logs tab (filterable table), attacks dispatch in AJAX handlers - informatiq-toolkit.php: wire WAF + Attacks API into plugin bootstrap - .gitignore: exclude attack-api/ (separate repo) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -135,6 +135,48 @@ class ITK_Admin {
|
||||
], []);
|
||||
$this->redirect(['tab' => 'honeypot', 'saved' => 1]);
|
||||
break;
|
||||
case 'save_settings_waf':
|
||||
$this->save_settings_form('itk_waf', [
|
||||
'action', 'response_code', 'redirect_url', 'custom_message',
|
||||
], [
|
||||
'enabled', 'log_attacks', 'scan_post', 'scan_cookies', 'scan_ua',
|
||||
'block_sqli', 'block_xss', 'block_lfi', 'block_rfi', 'block_cmdi',
|
||||
'block_xxe', 'block_php_inject', 'block_ssrf', 'block_wp_specific',
|
||||
]);
|
||||
(new ITK_WAF())->invalidate_cache();
|
||||
$this->redirect(['tab' => 'waf', 'saved' => 1]);
|
||||
break;
|
||||
case 'clear_attack_log':
|
||||
ITK_Database::clear_attack_log();
|
||||
$this->redirect(['tab' => 'attack-logs', 'cleared' => 1]);
|
||||
break;
|
||||
case 'save_attacks_api':
|
||||
$this->save_api_settings(ITK_Attacks_API::OPT_SETTINGS, 'itk_attacks_api_settings');
|
||||
$this->redirect(['tab' => 'waf', 'saved' => 1]);
|
||||
break;
|
||||
case 'test_attacks_api':
|
||||
$result = ITK_Attacks_API::test_connection();
|
||||
$s = ITK_Attacks_API::settings();
|
||||
$s['connection_ok'] = $result['ok']; $s['last_verified'] = time();
|
||||
$s['last_error'] = $result['ok'] ? '' : $result['message'];
|
||||
update_option(ITK_Attacks_API::OPT_SETTINGS, $s);
|
||||
set_transient('itk_attacks_api_test_result', $result, 60);
|
||||
$this->redirect(['tab' => 'waf', 'api_tested' => 1]);
|
||||
break;
|
||||
case 'flush_attacks_api':
|
||||
ITK_Attacks_API::flush();
|
||||
$this->redirect(['tab' => 'waf', 'api_flushed' => 1]);
|
||||
break;
|
||||
case 'send_attacks_history':
|
||||
$result = ITK_Attacks_API::send_history_batch();
|
||||
set_transient('itk_attacks_history_result', $result, 60);
|
||||
$this->redirect(['tab' => 'waf', 'history_sent' => 1]);
|
||||
break;
|
||||
case 'reset_attacks_history':
|
||||
delete_option('itk_attacks_history_last_id');
|
||||
delete_option('itk_attacks_history_sent');
|
||||
$this->redirect(['tab' => 'waf']);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,7 +221,11 @@ class ITK_Admin {
|
||||
check_ajax_referer(self::NONCE_ACTION, 'nonce');
|
||||
if (!current_user_can('manage_options')) wp_send_json_error('unauthorized');
|
||||
$which = sanitize_key($_POST['api'] ?? '');
|
||||
$result = $which === 'bot' ? ITK_Bot_API::test_connection() : ITK_HP_API::test_connection();
|
||||
$result = match ($which) {
|
||||
'bot' => ITK_Bot_API::test_connection(),
|
||||
'attacks' => ITK_Attacks_API::test_connection(),
|
||||
default => ITK_HP_API::test_connection(),
|
||||
};
|
||||
wp_send_json($result);
|
||||
}
|
||||
|
||||
@@ -189,7 +235,11 @@ class ITK_Admin {
|
||||
check_ajax_referer(self::NONCE_ACTION, 'nonce');
|
||||
if (!current_user_can('manage_options')) wp_send_json_error('unauthorized');
|
||||
$which = sanitize_key($_POST['api'] ?? '');
|
||||
if ($which === 'bot') ITK_Bot_API::flush(); else ITK_HP_API::flush();
|
||||
match ($which) {
|
||||
'bot' => ITK_Bot_API::flush(),
|
||||
'attacks' => ITK_Attacks_API::flush(),
|
||||
default => ITK_HP_API::flush(),
|
||||
};
|
||||
wp_send_json_success('Queue flushed.');
|
||||
}
|
||||
|
||||
@@ -208,7 +258,7 @@ class ITK_Admin {
|
||||
$setting = sanitize_key($_POST['setting'] ?? '');
|
||||
$value = (int)($_POST['value'] ?? 0);
|
||||
|
||||
$allowed = ['itk_security','itk_optimization','itk_honeypot'];
|
||||
$allowed = ['itk_security','itk_optimization','itk_honeypot','itk_waf'];
|
||||
if (!in_array($option, $allowed, true) || empty($setting)) {
|
||||
wp_send_json_error('invalid');
|
||||
}
|
||||
@@ -279,8 +329,10 @@ class ITK_Admin {
|
||||
'protection' => 'Protection',
|
||||
'optimization' => 'Optimization',
|
||||
'honeypot' => 'Honeypot',
|
||||
'waf' => 'WAF',
|
||||
'bot-logs' => 'Bot Logs',
|
||||
'honeypot-logs' => 'Honeypot Logs',
|
||||
'attack-logs' => 'Attack Logs',
|
||||
'config-files' => 'Config Files',
|
||||
];
|
||||
foreach ($tabs as $slug => $label):
|
||||
@@ -299,8 +351,10 @@ class ITK_Admin {
|
||||
'protection' => $this->tab_protection(),
|
||||
'optimization' => $this->tab_optimization(),
|
||||
'honeypot' => $this->tab_honeypot(),
|
||||
'waf' => $this->tab_waf(),
|
||||
'bot-logs' => $this->tab_bot_logs(),
|
||||
'honeypot-logs' => $this->tab_honeypot_logs(),
|
||||
'attack-logs' => $this->tab_attack_logs(),
|
||||
'config-files' => $this->tab_config_files(),
|
||||
default => $this->tab_dashboard(),
|
||||
};
|
||||
@@ -886,6 +940,276 @@ class ITK_Admin {
|
||||
<?php
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════════════════════════
|
||||
* TAB: WAF
|
||||
* ══════════════════════════════════════════════════════════ */
|
||||
|
||||
private function tab_waf(): void {
|
||||
$opts = get_option('itk_waf', []);
|
||||
?>
|
||||
<div class="itk-settings-grid">
|
||||
|
||||
<!-- ── WAF Enable + Category Toggles ── -->
|
||||
<section class="itk-card">
|
||||
<h2>Web Application Firewall</h2>
|
||||
<?php
|
||||
$toggles = [
|
||||
'enabled' => ['Enable WAF', 'Inspect incoming requests against all active rule categories'],
|
||||
'block_sqli' => ['SQL Injection', 'Block SQLi patterns in query parameters, POST fields, and URIs'],
|
||||
'block_xss' => ['Cross-Site Scripting', 'Block XSS payloads including script tags, event handlers, and JS URIs'],
|
||||
'block_lfi' => ['Local File Inclusion', 'Block path traversal and LFI patterns (../../etc/passwd etc.)'],
|
||||
'block_rfi' => ['Remote File Inclusion', 'Block attempts to include remote URLs as file paths'],
|
||||
'block_cmdi' => ['Command Injection', 'Block OS command injection patterns (;, |, backticks, etc.)'],
|
||||
'block_xxe' => ['XML External Entity', 'Block XXE payloads in request bodies and parameters'],
|
||||
'block_php_inject' => ['PHP Code Injection', 'Block PHP code execution attempts (eval, base64_decode, etc.)'],
|
||||
'block_ssrf' => ['SSRF', 'Block Server-Side Request Forgery patterns targeting internal hosts'],
|
||||
'block_wp_specific' => ['WordPress-Specific','Block WP-targeted probes (xmlrpc abuse, admin enumeration, etc.)'],
|
||||
];
|
||||
foreach ($toggles as $key => [$label, $desc]):
|
||||
$this->render_toggle('itk_waf', $key, $label, $desc, $opts);
|
||||
endforeach;
|
||||
?>
|
||||
</section>
|
||||
|
||||
<!-- ── Scan Scope + Action Settings ── -->
|
||||
<section class="itk-card">
|
||||
<h2>Scan Scope & Response</h2>
|
||||
<?php
|
||||
$scope_toggles = [
|
||||
'scan_post' => ['Scan POST Data', 'Inspect POST body fields for attack patterns'],
|
||||
'scan_cookies' => ['Scan Cookies', 'Inspect cookie values (adds overhead; off by default)'],
|
||||
'scan_ua' => ['Scan User-Agent', 'Inspect the User-Agent header'],
|
||||
'log_attacks' => ['Log Attacks', 'Record matched attacks to the local attack log table'],
|
||||
];
|
||||
foreach ($scope_toggles as $key => [$label, $desc]):
|
||||
$this->render_toggle('itk_waf', $key, $label, $desc, $opts);
|
||||
endforeach;
|
||||
?>
|
||||
|
||||
<form method="post" action="options-general.php?page=<?= self::MENU_SLUG ?>&tab=waf" style="margin-top:20px">
|
||||
<?php wp_nonce_field(self::NONCE_ACTION); ?>
|
||||
<input type="hidden" name="itk_action" value="save_settings_waf">
|
||||
<table class="form-table">
|
||||
<tr>
|
||||
<th>Action</th>
|
||||
<td>
|
||||
<select name="itk_waf[action]">
|
||||
<option value="block" <?= selected($opts['action'] ?? 'block', 'block', false) ?>>Block request</option>
|
||||
<option value="log_only" <?= selected($opts['action'] ?? 'block', 'log_only', false) ?>>Log only (no block)</option>
|
||||
</select>
|
||||
<p class="description">Choose whether to block the request or only log the attack.</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Response Code</th>
|
||||
<td>
|
||||
<select name="itk_waf[response_code]">
|
||||
<option value="403" <?= selected($opts['response_code'] ?? '403', '403', false) ?>>403 Forbidden</option>
|
||||
<option value="400" <?= selected($opts['response_code'] ?? '403', '400', false) ?>>400 Bad Request</option>
|
||||
<option value="301_custom" <?= selected($opts['response_code'] ?? '403', '301_custom', false) ?>>301 Redirect to URL</option>
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Redirect URL</th>
|
||||
<td>
|
||||
<input type="url" name="itk_waf[redirect_url]" value="<?= esc_attr($opts['redirect_url'] ?? '') ?>" class="regular-text" placeholder="https://example.com/blocked">
|
||||
<p class="description">Only used when response code is 301 redirect.</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Block Message</th>
|
||||
<td>
|
||||
<input type="text" name="itk_waf[custom_message]" value="<?= esc_attr($opts['custom_message'] ?? 'Access denied.') ?>" class="regular-text">
|
||||
<p class="description">Message shown on 403/400 responses.</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<?php submit_button('Save WAF Settings'); ?>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<!-- ── Central Attacks API card ── -->
|
||||
<?php
|
||||
$atk_api = ITK_Attacks_API::settings();
|
||||
$atk_queue = count((array) get_option(ITK_Attacks_API::OPT_QUEUE, []));
|
||||
$atk_total = ITK_Database::count_attack_rows();
|
||||
$atk_sent = (int) get_option('itk_attacks_history_sent', 0);
|
||||
$atk_rem = max(0, $atk_total - $atk_sent);
|
||||
$atk_ok = $atk_api['connection_ok'];
|
||||
$atk_cls = is_null($atk_ok) ? 'itk-api-unknown' : ($atk_ok ? 'itk-api-ok' : 'itk-api-err');
|
||||
$atk_lbl = is_null($atk_ok) ? 'Not tested' : ($atk_ok ? 'Connected' : 'Connection failed');
|
||||
$atk_test_r = get_transient('itk_attacks_api_test_result'); if ($atk_test_r) delete_transient('itk_attacks_api_test_result');
|
||||
$atk_hist_r = get_transient('itk_attacks_history_result'); if ($atk_hist_r) delete_transient('itk_attacks_history_result');
|
||||
?>
|
||||
<section class="itk-card itk-api-card">
|
||||
<h2>Central Attacks API</h2>
|
||||
<p class="description itk-api-desc">Send WAF attack events to your self-hosted Attack Intelligence Docker stack (port 3083).</p>
|
||||
|
||||
<div class="itk-api-status-bar">
|
||||
<span class="itk-api-badge <?= esc_attr($atk_cls) ?>"><?= esc_html($atk_lbl) ?></span>
|
||||
<?php if ($atk_api['last_verified'] > 0): ?>
|
||||
<span class="itk-api-time">Last tested <?= esc_html(human_time_diff((int)$atk_api['last_verified'])) ?> ago</span>
|
||||
<?php endif; ?>
|
||||
<?php if (!$atk_ok && !is_null($atk_ok) && !empty($atk_api['last_error'])): ?>
|
||||
<span class="itk-api-err-msg"><?= esc_html($atk_api['last_error']) ?></span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<?php if ($atk_test_r): ?>
|
||||
<div class="itk-api-notice <?= $atk_test_r['ok'] ? 'itk-api-notice-ok' : 'itk-api-notice-err' ?>"><?= esc_html($atk_test_r['message']) ?></div>
|
||||
<?php endif; ?>
|
||||
<?php if ($atk_hist_r): ?>
|
||||
<div class="itk-api-notice <?= $atk_hist_r['ok'] ? 'itk-api-notice-ok' : 'itk-api-notice-err' ?>"><?= esc_html($atk_hist_r['message']) ?></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<form method="post" action="options-general.php?page=<?= self::MENU_SLUG ?>&tab=waf">
|
||||
<?php wp_nonce_field(self::NONCE_ACTION); ?>
|
||||
<input type="hidden" name="itk_action" value="save_attacks_api">
|
||||
<table class="form-table itk-api-table">
|
||||
<tr>
|
||||
<th>Enable</th>
|
||||
<td><label><input type="checkbox" name="itk_attacks_api_settings[enabled]" value="1" <?= checked(!empty($atk_api['enabled'])) ?>> Send attacks to Central API</label></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>API URL</th>
|
||||
<td>
|
||||
<input type="url" name="itk_attacks_api_settings[api_url]" value="<?= esc_attr($atk_api['api_url'] ?? '') ?>" class="regular-text" placeholder="http://your-server:3083">
|
||||
<p class="description">Base URL of your Attack API stack (e.g. <code>http://localhost:3083</code>)</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>API Token</th>
|
||||
<td>
|
||||
<input type="password" name="itk_attacks_api_settings[api_token]" value="" class="regular-text"
|
||||
placeholder="<?= !empty($atk_api['api_token']) ? '●●●●●●●● (set — leave blank to keep)' : 'Enter bearer token' ?>"
|
||||
autocomplete="new-password">
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<div class="itk-api-form-actions">
|
||||
<?php submit_button('Save Attacks API Settings', 'primary', 'submit', false); ?>
|
||||
<button type="button" class="button itk-btn-test-api" data-api="attacks" style="margin-left:8px">Test Connection</button>
|
||||
<span class="itk-api-ajax-result" style="margin-left:10px;display:none"></span>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="itk-api-footer">
|
||||
<div class="itk-api-queue-row">
|
||||
<strong><?= (int) $atk_queue ?></strong> event(s) pending in queue
|
||||
<button type="button" class="button button-small itk-btn-flush-api" data-api="attacks" style="margin-left:8px">Flush Now</button>
|
||||
<span class="itk-api-flush-result" style="margin-left:8px;display:none"></span>
|
||||
</div>
|
||||
|
||||
<div class="itk-api-history-row">
|
||||
<strong>Historical sync:</strong>
|
||||
<?= number_format($atk_sent) ?> / <?= number_format($atk_total) ?> records sent
|
||||
<?php if ($atk_rem > 0): ?><em class="itk-api-rem">(<?= number_format($atk_rem) ?> remaining)</em><?php endif; ?>
|
||||
<form method="post" action="options-general.php?page=<?= self::MENU_SLUG ?>&tab=waf" style="display:inline;margin-left:10px">
|
||||
<?php wp_nonce_field(self::NONCE_ACTION); ?>
|
||||
<input type="hidden" name="itk_action" value="send_attacks_history">
|
||||
<input type="submit" class="button button-small" value="Send Next 50">
|
||||
</form>
|
||||
<?php if ($atk_sent > 0): ?>
|
||||
<form method="post" action="options-general.php?page=<?= self::MENU_SLUG ?>&tab=waf" style="display:inline;margin-left:6px" onsubmit="return confirm('Reset sync progress? No data is deleted.')">
|
||||
<?php wp_nonce_field(self::NONCE_ACTION); ?>
|
||||
<input type="hidden" name="itk_action" value="reset_attacks_history">
|
||||
<input type="submit" class="button button-small" value="Reset Progress">
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
<?php
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════════════════════════
|
||||
* TAB: ATTACK LOGS
|
||||
* ══════════════════════════════════════════════════════════ */
|
||||
|
||||
private function tab_attack_logs(): void {
|
||||
$search = sanitize_text_field($_GET['atk_search'] ?? '');
|
||||
$filter_ip = sanitize_text_field($_GET['atk_ip'] ?? '');
|
||||
$filter_type = sanitize_key($_GET['atk_type'] ?? '');
|
||||
$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_type) $args['attack_type'] = $filter_type;
|
||||
|
||||
$rows = ITK_Database::get_attack_rows($args);
|
||||
$total = ITK_Database::count_attack_rows($args);
|
||||
$attack_types = ITK_Database::get_attack_types();
|
||||
$total_pages = max(1, (int)ceil($total / self::PER_PAGE));
|
||||
|
||||
$base_url = admin_url('options-general.php?page=' . self::MENU_SLUG . '&tab=attack-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="attack-logs">
|
||||
<input type="text" name="atk_search" placeholder="Search IP, param, payload…" value="<?= esc_attr($search) ?>">
|
||||
<input type="text" name="atk_ip" placeholder="Filter by IP" value="<?= esc_attr($filter_ip) ?>">
|
||||
<select name="atk_type">
|
||||
<option value="">All attack types</option>
|
||||
<?php foreach ($attack_types as $at): ?>
|
||||
<option value="<?= esc_attr($at) ?>" <?= selected($filter_type, $at, false) ?>><?= esc_html($at) ?></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>Type</th>
|
||||
<th>Source</th><th>Param</th><th>Payload</th><th>URI</th><th>Method</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php if (empty($rows)): ?>
|
||||
<tr><td colspan="8" class="itk-no-results">No attacks logged yet.</td></tr>
|
||||
<?php else: ?>
|
||||
<?php foreach ($rows as $row): ?>
|
||||
<tr>
|
||||
<td class="itk-nowrap"><?= esc_html($row->logged_at) ?></td>
|
||||
<td>
|
||||
<?= esc_html($row->ip_address) ?>
|
||||
<a href="<?= esc_url($base_url . '&atk_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-block"><?= esc_html($row->attack_type) ?></span></td>
|
||||
<td><?= esc_html($row->source) ?></td>
|
||||
<td class="itk-uri"><?= esc_html(substr($row->param, 0, 60)) ?></td>
|
||||
<td class="itk-uri" title="<?= esc_attr($row->payload) ?>"><?= esc_html(substr($row->payload, 0, 80)) ?></td>
|
||||
<td class="itk-uri"><?= esc_html(substr($row->request_uri, 0, 80)) ?></td>
|
||||
<td><?= esc_html($row->method) ?></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 attack log entries?')">
|
||||
<?php wp_nonce_field(self::NONCE_ACTION); ?>
|
||||
<input type="hidden" name="itk_action" value="clear_attack_log">
|
||||
<input type="submit" class="button button-secondary itk-btn-danger" value="Clear All Attack Logs">
|
||||
</form>
|
||||
</div>
|
||||
<?php
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════════════════════════
|
||||
* TAB: BOT LOGS
|
||||
* ══════════════════════════════════════════════════════════ */
|
||||
|
||||
239
includes/class-itk-attacks-api.php
Normal file
239
includes/class-itk-attacks-api.php
Normal file
@@ -0,0 +1,239 @@
|
||||
<?php
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
/**
|
||||
* ITK Attacks Central API Client
|
||||
*
|
||||
* Queues WAF attack events locally and batch-submits them to the
|
||||
* central Attack Intelligence API Docker stack (port 3092).
|
||||
*/
|
||||
class ITK_Attacks_API {
|
||||
|
||||
const OPT_SETTINGS = 'itk_attacks_api_settings';
|
||||
const OPT_QUEUE = 'itk_attacks_api_queue';
|
||||
const CRON_HOOK = 'itk_attacks_api_flush';
|
||||
const QUEUE_MAX = 500;
|
||||
const BATCH_SIZE = 50;
|
||||
|
||||
/* ── Bootstrap ────────────────────────────────────────────── */
|
||||
|
||||
public static function register_cron(): void {
|
||||
if (!wp_next_scheduled(self::CRON_HOOK)) {
|
||||
wp_schedule_event(time(), 'itk_5min', self::CRON_HOOK);
|
||||
}
|
||||
add_action(self::CRON_HOOK, [self::class, 'flush']);
|
||||
add_action('shutdown', [self::class, 'flush_shutdown']);
|
||||
}
|
||||
|
||||
public static function clear_cron(): void {
|
||||
wp_clear_scheduled_hook(self::CRON_HOOK);
|
||||
}
|
||||
|
||||
/* ── Settings ─────────────────────────────────────────────── */
|
||||
|
||||
public static function defaults(): array {
|
||||
return [
|
||||
'enabled' => false,
|
||||
'api_url' => '',
|
||||
'api_token' => '',
|
||||
'last_sync' => 0,
|
||||
'sent_total' => 0,
|
||||
'connection_ok' => null,
|
||||
'last_verified' => 0,
|
||||
'last_error' => '',
|
||||
];
|
||||
}
|
||||
|
||||
public static function settings(): array {
|
||||
return array_merge(self::defaults(), (array)get_option(self::OPT_SETTINGS, []));
|
||||
}
|
||||
|
||||
private static function token(): string {
|
||||
if (defined('ITK_ATTACKS_API_TOKEN') && ITK_ATTACKS_API_TOKEN !== '') {
|
||||
return (string)ITK_ATTACKS_API_TOKEN;
|
||||
}
|
||||
return self::settings()['api_token'] ?? '';
|
||||
}
|
||||
|
||||
/* ── Queue ────────────────────────────────────────────────── */
|
||||
|
||||
public static function queue(array $data): void {
|
||||
$s = self::settings();
|
||||
if (empty($s['enabled']) || empty($s['api_url'])) return;
|
||||
|
||||
$queue = (array)get_option(self::OPT_QUEUE, []);
|
||||
if (count($queue) >= self::QUEUE_MAX) array_shift($queue);
|
||||
|
||||
$queue[] = [
|
||||
'ip' => sanitize_text_field($data['ip'] ?? ''),
|
||||
'attack_type' => sanitize_text_field($data['attack_type'] ?? ''),
|
||||
'rule_desc' => sanitize_text_field($data['rule_desc'] ?? ''),
|
||||
'source' => sanitize_text_field($data['source'] ?? ''),
|
||||
'param' => sanitize_text_field($data['param'] ?? ''),
|
||||
'payload' => sanitize_textarea_field(substr($data['payload'] ?? '', 0, 500)),
|
||||
'uri' => sanitize_text_field($data['uri'] ?? ''),
|
||||
'method' => sanitize_text_field($data['method'] ?? ''),
|
||||
'user_agent' => sanitize_textarea_field($data['ua'] ?? ''),
|
||||
'logged_at' => current_time('mysql'),
|
||||
];
|
||||
|
||||
update_option(self::OPT_QUEUE, $queue);
|
||||
|
||||
if (count($queue) >= self::BATCH_SIZE) {
|
||||
self::flush();
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Flush ────────────────────────────────────────────────── */
|
||||
|
||||
public static function flush(): void {
|
||||
$s = self::settings();
|
||||
if (empty($s['enabled']) || empty($s['api_url'])) return;
|
||||
|
||||
$queue = (array)get_option(self::OPT_QUEUE, []);
|
||||
if (empty($queue)) return;
|
||||
|
||||
$batch = array_splice($queue, 0, self::BATCH_SIZE);
|
||||
update_option(self::OPT_QUEUE, $queue);
|
||||
|
||||
$headers = ['Content-Type' => 'application/json'];
|
||||
$token = self::token();
|
||||
if ($token !== '') $headers['Authorization'] = 'Bearer ' . $token;
|
||||
|
||||
$response = wp_remote_post(
|
||||
trailingslashit(esc_url_raw($s['api_url'])) . 'api/v1/submit',
|
||||
[
|
||||
'timeout' => 15,
|
||||
'headers' => $headers,
|
||||
'body' => wp_json_encode([
|
||||
'site_hash' => hash('sha256', home_url()),
|
||||
'attacks' => $batch,
|
||||
]),
|
||||
]
|
||||
);
|
||||
|
||||
if (is_wp_error($response)) {
|
||||
update_option(self::OPT_QUEUE, array_merge($batch, $queue));
|
||||
return;
|
||||
}
|
||||
|
||||
$s['last_sync'] = time();
|
||||
$s['sent_total'] = ($s['sent_total'] ?? 0) + count($batch);
|
||||
update_option(self::OPT_SETTINGS, $s);
|
||||
}
|
||||
|
||||
public static function flush_shutdown(): void {
|
||||
$queue = (array)get_option(self::OPT_QUEUE, []);
|
||||
if (count($queue) >= 5) self::flush();
|
||||
}
|
||||
|
||||
/* ── Test connection ──────────────────────────────────────── */
|
||||
|
||||
public static function test_connection(): array {
|
||||
$s = self::settings();
|
||||
if (empty($s['api_url'])) {
|
||||
return ['ok' => false, 'message' => 'No API URL configured.'];
|
||||
}
|
||||
|
||||
$base = trailingslashit(esc_url_raw($s['api_url']));
|
||||
$headers = ['Content-Type' => 'application/json'];
|
||||
$token = self::token();
|
||||
if ($token !== '') $headers['Authorization'] = 'Bearer ' . $token;
|
||||
|
||||
$health = wp_remote_get($base . 'api/v1/health', ['timeout' => 8]);
|
||||
if (is_wp_error($health)) {
|
||||
return ['ok' => false, 'message' => 'Cannot reach API: ' . $health->get_error_message()];
|
||||
}
|
||||
if (wp_remote_retrieve_response_code($health) !== 200) {
|
||||
return ['ok' => false, 'message' => 'API returned HTTP ' . wp_remote_retrieve_response_code($health)];
|
||||
}
|
||||
|
||||
$auth = wp_remote_post($base . 'api/v1/submit', [
|
||||
'timeout' => 8,
|
||||
'headers' => $headers,
|
||||
'body' => wp_json_encode(['site_hash' => 'connectivity_test', 'attacks' => []]),
|
||||
]);
|
||||
if (is_wp_error($auth)) {
|
||||
return ['ok' => false, 'message' => 'Token check failed: ' . $auth->get_error_message()];
|
||||
}
|
||||
if (wp_remote_retrieve_response_code($auth) === 403) {
|
||||
return ['ok' => false, 'message' => 'API reachable but token rejected (HTTP 403).'];
|
||||
}
|
||||
|
||||
return ['ok' => true, 'message' => 'Connection verified. API is reachable and token accepted.'];
|
||||
}
|
||||
|
||||
/* ── Send history batch ───────────────────────────────────── */
|
||||
|
||||
public static function send_history_batch(int $batch_size = 50): array {
|
||||
$s = self::settings();
|
||||
if (empty($s['api_url'])) {
|
||||
return ['ok' => false, 'message' => 'No API URL configured.'];
|
||||
}
|
||||
|
||||
global $wpdb;
|
||||
$table = ITK_Database::attack_table();
|
||||
$last_id = (int)get_option('itk_attacks_history_last_id', 0);
|
||||
$total = ITK_Database::count_attack_rows();
|
||||
|
||||
$rows = $wpdb->get_results($wpdb->prepare(
|
||||
"SELECT * FROM {$table} WHERE id > %d ORDER BY id ASC LIMIT %d",
|
||||
$last_id, $batch_size
|
||||
), ARRAY_A);
|
||||
|
||||
if (empty($rows)) {
|
||||
return ['ok' => true, 'sent' => 0, 'remaining' => 0, 'has_more' => false,
|
||||
'message' => 'All records have already been sent.'];
|
||||
}
|
||||
|
||||
$attacks = array_map(fn($r) => [
|
||||
'ip' => $r['ip_address'],
|
||||
'attack_type' => $r['attack_type'],
|
||||
'rule_desc' => $r['rule_desc'],
|
||||
'source' => $r['source'],
|
||||
'param' => $r['param'],
|
||||
'payload' => $r['payload'],
|
||||
'uri' => $r['request_uri'],
|
||||
'method' => $r['method'],
|
||||
'user_agent' => $r['user_agent'],
|
||||
'logged_at' => $r['logged_at'],
|
||||
], $rows);
|
||||
|
||||
$headers = ['Content-Type' => 'application/json'];
|
||||
$token = self::token();
|
||||
if ($token !== '') $headers['Authorization'] = 'Bearer ' . $token;
|
||||
|
||||
$response = wp_remote_post(
|
||||
trailingslashit(esc_url_raw($s['api_url'])) . 'api/v1/submit',
|
||||
[
|
||||
'timeout' => 30,
|
||||
'headers' => $headers,
|
||||
'body' => wp_json_encode([
|
||||
'site_hash' => hash('sha256', home_url()),
|
||||
'attacks' => $attacks,
|
||||
]),
|
||||
]
|
||||
);
|
||||
|
||||
if (is_wp_error($response)) {
|
||||
return ['ok' => false, 'message' => 'Request failed: ' . $response->get_error_message()];
|
||||
}
|
||||
if (wp_remote_retrieve_response_code($response) !== 200) {
|
||||
return ['ok' => false, 'message' => 'API returned HTTP ' . wp_remote_retrieve_response_code($response)];
|
||||
}
|
||||
|
||||
$new_last = (int)end($rows)['id'];
|
||||
$sent_total = (int)get_option('itk_attacks_history_sent', 0) + count($rows);
|
||||
update_option('itk_attacks_history_last_id', $new_last);
|
||||
update_option('itk_attacks_history_sent', $sent_total);
|
||||
$remaining = max(0, $total - $sent_total);
|
||||
|
||||
return [
|
||||
'ok' => true,
|
||||
'sent' => count($rows),
|
||||
'remaining' => $remaining,
|
||||
'has_more' => $remaining > 0,
|
||||
'message' => sprintf('Sent %d records. %d remaining.', count($rows), $remaining),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ if (!defined('ABSPATH')) exit;
|
||||
*/
|
||||
class ITK_Database {
|
||||
|
||||
const DB_VERSION = 1;
|
||||
const DB_VERSION = 2;
|
||||
const DB_VERSION_OPTION = 'itk_db_version';
|
||||
|
||||
/* ── Table names ──────────────────────────────────────────── */
|
||||
@@ -22,6 +22,11 @@ class ITK_Database {
|
||||
return $wpdb->prefix . 'itk_honeypot_log';
|
||||
}
|
||||
|
||||
public static function attack_table(): string {
|
||||
global $wpdb;
|
||||
return $wpdb->prefix . 'itk_attack_log';
|
||||
}
|
||||
|
||||
/* ── Install / upgrade ────────────────────────────────────── */
|
||||
|
||||
public static function install() {
|
||||
@@ -59,9 +64,28 @@ class ITK_Database {
|
||||
KEY form_type (form_type)
|
||||
) {$charset};";
|
||||
|
||||
$sql_atk = "CREATE TABLE " . self::attack_table() . " (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
logged_at DATETIME NOT NULL,
|
||||
ip_address VARCHAR(45) NOT NULL DEFAULT '',
|
||||
attack_type VARCHAR(50) NOT NULL DEFAULT '',
|
||||
rule_desc VARCHAR(255) NOT NULL DEFAULT '',
|
||||
source VARCHAR(20) NOT NULL DEFAULT '',
|
||||
param VARCHAR(200) NOT NULL DEFAULT '',
|
||||
payload TEXT NOT NULL,
|
||||
request_uri VARCHAR(1000) NOT NULL DEFAULT '',
|
||||
method VARCHAR(10) NOT NULL DEFAULT '',
|
||||
user_agent TEXT NOT NULL,
|
||||
PRIMARY KEY (id),
|
||||
KEY ip_address (ip_address),
|
||||
KEY logged_at (logged_at),
|
||||
KEY attack_type (attack_type)
|
||||
) {$charset};";
|
||||
|
||||
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
|
||||
dbDelta($sql_bot);
|
||||
dbDelta($sql_hp);
|
||||
dbDelta($sql_atk);
|
||||
|
||||
update_option(self::DB_VERSION_OPTION, self::DB_VERSION);
|
||||
}
|
||||
@@ -280,4 +304,88 @@ class ITK_Database {
|
||||
$days
|
||||
));
|
||||
}
|
||||
|
||||
/* ── Attack log ───────────────────────────────────────────── */
|
||||
|
||||
public static function log_attack(array $data): void {
|
||||
global $wpdb;
|
||||
$wpdb->insert(
|
||||
self::attack_table(),
|
||||
[
|
||||
'logged_at' => current_time('mysql'),
|
||||
'ip_address' => sanitize_text_field($data['ip'] ?? ''),
|
||||
'attack_type' => sanitize_text_field($data['attack_type'] ?? ''),
|
||||
'rule_desc' => sanitize_text_field($data['rule_desc'] ?? ''),
|
||||
'source' => sanitize_text_field($data['source'] ?? ''),
|
||||
'param' => sanitize_text_field($data['param'] ?? ''),
|
||||
'payload' => sanitize_textarea_field(substr($data['payload'] ?? '', 0, 500)),
|
||||
'request_uri' => sanitize_text_field(substr($data['uri'] ?? '', 0, 1000)),
|
||||
'method' => sanitize_text_field($data['method'] ?? ''),
|
||||
'user_agent' => sanitize_textarea_field($data['ua'] ?? ''),
|
||||
],
|
||||
['%s','%s','%s','%s','%s','%s','%s','%s','%s','%s']
|
||||
);
|
||||
}
|
||||
|
||||
public static function get_attack_rows(array $args = []): array {
|
||||
global $wpdb;
|
||||
$table = self::attack_table();
|
||||
$limit = max(1, (int)($args['per_page'] ?? 25));
|
||||
$offset = max(0, (int)($args['offset'] ?? 0));
|
||||
$where = '1=1';
|
||||
$params = [];
|
||||
|
||||
if (!empty($args['attack_type'])) {
|
||||
$where .= ' AND attack_type = %s';
|
||||
$params[] = $args['attack_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 param LIKE %s OR payload LIKE %s OR rule_desc LIKE %s)';
|
||||
$params[] = $like; $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_attack_rows(array $args = []): int {
|
||||
global $wpdb;
|
||||
$table = self::attack_table();
|
||||
$where = '1=1';
|
||||
$params = [];
|
||||
|
||||
if (!empty($args['attack_type'])) {
|
||||
$where .= ' AND attack_type = %s';
|
||||
$params[] = $args['attack_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 param LIKE %s OR payload LIKE %s OR rule_desc LIKE %s)';
|
||||
$params[] = $like; $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_attack_types(): array {
|
||||
global $wpdb;
|
||||
return $wpdb->get_col("SELECT DISTINCT attack_type FROM " . self::attack_table() . " WHERE attack_type != '' ORDER BY attack_type ASC") ?: [];
|
||||
}
|
||||
|
||||
public static function clear_attack_log(): void {
|
||||
global $wpdb;
|
||||
$wpdb->query("TRUNCATE TABLE " . self::attack_table());
|
||||
}
|
||||
}
|
||||
|
||||
264
includes/class-itk-waf.php
Normal file
264
includes/class-itk-waf.php
Normal file
@@ -0,0 +1,264 @@
|
||||
<?php
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
/**
|
||||
* ITK WAF — Web Application Firewall
|
||||
*
|
||||
* Loads rules from config/waf-rules.conf and inspects incoming requests
|
||||
* (GET params, REQUEST_URI, optionally POST, cookies, and User-Agent) for
|
||||
* known attack signatures. On a match it logs, notifies the attacks API,
|
||||
* and either blocks or logs-only depending on the configured action.
|
||||
*
|
||||
* Options key: itk_waf (array)
|
||||
* enabled bool Master switch.
|
||||
* action string 'block' (default) or 'log_only'.
|
||||
* scan_post bool Also inspect $_POST values.
|
||||
* scan_cookies bool Also inspect $_COOKIE values.
|
||||
* scan_ua bool Also inspect HTTP_USER_AGENT.
|
||||
* log_attacks bool Persist matches to the DB via ITK_Database::log_attack().
|
||||
* response_code string HTTP status code to send when blocking (e.g. '403', '301_custom').
|
||||
* redirect_url string Destination URL when response_code === '301_custom'.
|
||||
* custom_message string Body text sent with non-redirect block responses.
|
||||
* block_{cat} bool Per-category enable flag (e.g. block_sqli, block_xss, …).
|
||||
*/
|
||||
class ITK_WAF {
|
||||
|
||||
/** Transient key for the cached parsed ruleset. */
|
||||
const TRANSIENT_KEY = 'itk_waf_rules';
|
||||
|
||||
/** Transient lifetime in seconds. */
|
||||
const CACHE_TTL = 300;
|
||||
|
||||
/** Absolute path to the rules file. */
|
||||
private string $rules_file;
|
||||
|
||||
/* ── Bootstrap ────────────────────────────────────────────── */
|
||||
|
||||
public function __construct() {
|
||||
$options = get_option('itk_waf', []);
|
||||
if (empty($options['enabled'])) return;
|
||||
|
||||
$this->rules_file = ITK_PATH . 'config/waf-rules.conf';
|
||||
|
||||
add_action('init', [$this, 'inspect'], 0);
|
||||
}
|
||||
|
||||
/* ── Main inspection hook ─────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* Runs at priority 0 on 'init'. Inspects the current request against
|
||||
* all enabled WAF rules and calls handle_match() on the first hit.
|
||||
*/
|
||||
public function inspect(): void {
|
||||
// Never block logged-in administrators or wp-admin non-AJAX requests.
|
||||
if (is_admin() && !wp_doing_ajax()) return;
|
||||
if (function_exists('current_user_can') && current_user_can('manage_options')) return;
|
||||
|
||||
$options = get_option('itk_waf', []);
|
||||
$rules = $this->load_rules();
|
||||
if (empty($rules)) return;
|
||||
|
||||
// ── Build input map ────────────────────────────────────
|
||||
$inputs = [];
|
||||
|
||||
// Always scan GET params.
|
||||
foreach ($_GET as $key => $value) {
|
||||
$inputs['GET'][(string)$key] = (string)$value;
|
||||
}
|
||||
|
||||
// Always scan REQUEST_URI.
|
||||
$inputs['URI']['REQUEST_URI'] = (string)($_SERVER['REQUEST_URI'] ?? '');
|
||||
|
||||
// Optionally scan POST.
|
||||
if (!empty($options['scan_post'])) {
|
||||
foreach ($_POST as $key => $value) {
|
||||
$inputs['POST'][(string)$key] = (string)$value;
|
||||
}
|
||||
}
|
||||
|
||||
// Optionally scan cookies.
|
||||
if (!empty($options['scan_cookies'])) {
|
||||
foreach ($_COOKIE as $key => $value) {
|
||||
$inputs['COOKIE'][(string)$key] = (string)$value;
|
||||
}
|
||||
}
|
||||
|
||||
// Optionally scan User-Agent.
|
||||
if (!empty($options['scan_ua'])) {
|
||||
$ua = (string)($_SERVER['HTTP_USER_AGENT'] ?? '');
|
||||
if ($ua !== '') {
|
||||
$inputs['UA']['HTTP_USER_AGENT'] = $ua;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Match loop ─────────────────────────────────────────
|
||||
foreach ($inputs as $source => $params) {
|
||||
foreach ($params as $key => $raw_value) {
|
||||
// Decode URL-encoding so encoded payloads are matched.
|
||||
$value = rawurldecode(urldecode($raw_value));
|
||||
|
||||
foreach ($rules as $rule) {
|
||||
// Skip categories disabled in options.
|
||||
$opt_key = 'block_' . $rule['category'];
|
||||
if (empty($options[$opt_key])) continue;
|
||||
|
||||
if (@preg_match($rule['pattern'], $value)) {
|
||||
$this->handle_match($rule, $source, (string)$key, $raw_value);
|
||||
return; // Stop on first match; handle_match() may exit().
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Rule loading ─────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* Reads and parses config/waf-rules.conf.
|
||||
* Results are cached in a transient for CACHE_TTL seconds.
|
||||
*
|
||||
* @return array<int, array{category: string, pattern: string, desc: string}>
|
||||
*/
|
||||
public function load_rules(): array {
|
||||
$cached = get_transient(self::TRANSIENT_KEY);
|
||||
if (is_array($cached)) return $cached;
|
||||
|
||||
$rules = [];
|
||||
|
||||
if (!file_exists($this->rules_file)) {
|
||||
set_transient(self::TRANSIENT_KEY, $rules, self::CACHE_TTL);
|
||||
return $rules;
|
||||
}
|
||||
|
||||
$lines = file($this->rules_file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
||||
foreach ($lines as $line) {
|
||||
$line = trim($line);
|
||||
|
||||
// Skip comment and header lines.
|
||||
if ($line === '' || $line[0] === '#') continue;
|
||||
|
||||
$parts = explode('|', $line, 3);
|
||||
if (count($parts) < 3) continue;
|
||||
|
||||
[$category, $pattern, $desc] = $parts;
|
||||
$category = trim($category);
|
||||
$pattern = trim($pattern);
|
||||
$desc = trim($desc);
|
||||
|
||||
if ($category === '' || $pattern === '') continue;
|
||||
|
||||
// Wrap bare patterns (no delimiter) with # delimiters so they are
|
||||
// valid PCRE. Patterns that already start with a delimiter are
|
||||
// used as-is.
|
||||
$delimiters = ['/', '#', '~', '!', '@', '%'];
|
||||
if (!in_array($pattern[0], $delimiters, true)) {
|
||||
$pattern = '#' . $pattern . '#';
|
||||
}
|
||||
|
||||
// Validate the regex before adding it to the ruleset.
|
||||
if (@preg_match($pattern, '') === false) continue;
|
||||
|
||||
$rules[] = [
|
||||
'category' => $category,
|
||||
'pattern' => $pattern,
|
||||
'desc' => $desc,
|
||||
];
|
||||
}
|
||||
|
||||
set_transient(self::TRANSIENT_KEY, $rules, self::CACHE_TTL);
|
||||
return $rules;
|
||||
}
|
||||
|
||||
/* ── Match handler ────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* Handles a rule match: logs the event, queues it for the attacks API,
|
||||
* then either returns (log-only mode) or terminates the request.
|
||||
*
|
||||
* @param array $rule Parsed rule: category, pattern, desc.
|
||||
* @param string $source Input source label: GET, POST, COOKIE, URI, UA.
|
||||
* @param string $key Parameter name that triggered the rule.
|
||||
* @param string $value Raw parameter value.
|
||||
*/
|
||||
public function handle_match(array $rule, string $source, string $key, string $value): void {
|
||||
$options = get_option('itk_waf', []);
|
||||
|
||||
$event = [
|
||||
'ip' => $this->get_ip(),
|
||||
'attack_type' => $rule['category'],
|
||||
'rule_desc' => $rule['desc'],
|
||||
'source' => $source,
|
||||
'param' => $key,
|
||||
'payload' => substr($value, 0, 500),
|
||||
'uri' => (string)($_SERVER['REQUEST_URI'] ?? ''),
|
||||
'method' => (string)($_SERVER['REQUEST_METHOD'] ?? ''),
|
||||
'ua' => (string)($_SERVER['HTTP_USER_AGENT'] ?? ''),
|
||||
];
|
||||
|
||||
// Persist to local DB if logging is enabled.
|
||||
if (!empty($options['log_attacks']) && class_exists('ITK_Database') &&
|
||||
method_exists('ITK_Database', 'log_attack')) {
|
||||
ITK_Database::log_attack($event);
|
||||
}
|
||||
|
||||
// Queue for the attacks API.
|
||||
if (class_exists('ITK_Attacks_API') && method_exists('ITK_Attacks_API', 'queue')) {
|
||||
ITK_Attacks_API::queue($event);
|
||||
}
|
||||
|
||||
// Log-only mode: record the event but do not block the request.
|
||||
$action = $options['action'] ?? 'block';
|
||||
if ($action === 'log_only') return;
|
||||
|
||||
// ── Block the request ──────────────────────────────────
|
||||
$response_code = $options['response_code'] ?? '403';
|
||||
$redirect_url = $options['redirect_url'] ?? '';
|
||||
$custom_message = $options['custom_message'] ?? 'Access denied.';
|
||||
|
||||
if ($response_code === '301_custom' && !empty($redirect_url)) {
|
||||
header('Location: ' . esc_url_raw($redirect_url), true, 301);
|
||||
} else {
|
||||
status_header((int)$response_code ?: 403);
|
||||
echo esc_html($custom_message);
|
||||
}
|
||||
exit;
|
||||
}
|
||||
|
||||
/* ── IP resolution ────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* Returns the best available client IP address, checking proxy headers
|
||||
* in order of trustworthiness. Mirrors the pattern used by other ITK
|
||||
* classes.
|
||||
*/
|
||||
public 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 $key) {
|
||||
if (empty($_SERVER[$key])) continue;
|
||||
// X-Forwarded-For may contain a comma-separated list; take first.
|
||||
$ip = trim(explode(',', $_SERVER[$key])[0]);
|
||||
if (filter_var($ip, FILTER_VALIDATE_IP)) return $ip;
|
||||
}
|
||||
|
||||
return 'UNKNOWN';
|
||||
}
|
||||
|
||||
/* ── Cache management ─────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* Deletes the cached ruleset transient so the next request re-parses
|
||||
* the rules file. Call this after saving updated rules.
|
||||
*/
|
||||
public function invalidate_cache(): void {
|
||||
delete_transient(self::TRANSIENT_KEY);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user