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:
2026-04-10 09:37:31 +02:00
parent a8d7972ad7
commit 742047915f
7 changed files with 1093 additions and 4 deletions

1
.gitignore vendored
View File

@@ -1 +1,2 @@
bot-api/
attack-api/

124
config/waf-rules.conf Normal file
View File

@@ -0,0 +1,124 @@
# InformatiQ Toolkit — WAF Rules
# Format: category|regex_pattern|description
# Lines starting with # are comments.
# Regexes are PHP PCRE, used with preg_match().
# (?i) flag used where case-insensitive matching is needed.
# =============================================================================
# SQL INJECTION
# =============================================================================
sqli|(?i)\bunion\s+(?:all\s+)?select\b|UNION SELECT — classic column-extraction attack
sqli|(?i)\bselect\b.{0,60}\bfrom\b.{0,60}\b(?:information_schema|mysql\.user|sys\.tables|sysobjects)\b|SELECT FROM system/info tables — schema enumeration
sqli|(?i)(?:sleep|benchmark|pg_sleep|waitfor\s+delay)\s*\([\d\s,\.]+\)|Time-based blind SQLi — sleep/benchmark/waitfor
sqli|(?i)(?:\bextractvalue\s*\(|\bupdatexml\s*\(|\bexp\s*\(\s*~)|Error-based SQLi — extractvalue/updatexml/exp
sqli|(?i);\s*(?:drop|truncate|alter|create|rename|insert|update|delete)\s+(?:table|database|index)\b|Stacked query — DDL/DML after semicolon
sqli|(?i)\binto\s+(?:outfile|dumpfile)\s*['"]|INTO OUTFILE — file-write via SQLi
sqli|(?i)\bload_file\s*\(|LOAD_FILE() — read arbitrary files via SQL
sqli|(?i)(?:'\s*(?:or|and)\s*'?\d+'?\s*(?:=|<|>|like)\s*'?\d+|"\s*(?:or|and)\s*"?\d+"?\s*(?:=|<|>|like)\s*"?\d+)|Boolean-based SQLi — OR/AND equality tautology
sqli|(?i)(?:--\s*$|--\+|#\s*$|\/\*[\s\S]*?\*\/)\s*(?:union|select|drop|insert|update|delete|sleep)|SQL comment followed by keyword — comment-based injection
sqli|(?i)\b(?:char|nchar|varchar)\s*\(\s*\d{1,3}\s*(?:,\s*\d{1,3}\s*)*\)|CHAR() encoding — obfuscated SQLi payload
# =============================================================================
# CROSS-SITE SCRIPTING (XSS)
# =============================================================================
xss|(?i)<\s*script[\s>\/]|Script tag opening — classic XSS vector
xss|(?i)<\s*\/\s*script\s*>|Closing script tag — XSS payload termination
xss|(?i)<\s*(?:iframe|frame|object|embed|applet|base)\s|Dangerous HTML tag — script-execution embedding
xss|(?i)\bon(?:load|error|click|mouseover|mouseout|focus|blur|input|change|keyup|keydown|submit|reset|scroll|resize|animationend|transitionend)\s*=|DOM event handler attribute — inline JS execution
xss|(?i)javascript\s*:|JavaScript protocol — href/src/action XSS
xss|(?i)<\s*svg[\s>][^>]*>[\s\S]{0,200}(?:onload|script|alert|eval)|SVG element with script content — SVG XSS vector
xss|(?i)document\s*\.\s*(?:cookie|write|writeln|location|domain|referrer)|DOM sink access — data theft or redirect
xss|(?i)(?:alert|prompt|confirm|eval)\s*\((?:[^)]{0,100}document\.|[^)]{0,100}window\.|[^)]{0,100}location)|JS execution function with sensitive sink
xss|(?i)<\s*img\s[^>]*\bsrc\s*=\s*['"]\s*(?:javascript:|data:text\/html|vbscript:)|IMG tag with dangerous src protocol
xss|(?i)(?:expression\s*\(|behavior\s*:)|CSS expression/behavior — IE XSS execution vector
# =============================================================================
# LOCAL FILE INCLUSION & PATH TRAVERSAL (LFI)
# =============================================================================
lfi|(?:\.\.\/){2,}|Directory traversal — repeated ../ sequences (2+)
lfi|(?i)(?:%2e%2e%2f|%2e%2e\/|\.\.%2f|%252e%252e%252f){1,}|URL-encoded path traversal — encoded ../
lfi|(?i)(?:%2e%2e%5c|%2e%2e\\|\.\.%5c|%252e%252e%255c){1,}|URL-encoded Windows path traversal — encoded ..\
lfi|(?i)(?:\/|%2f)etc(?:\/|%2f)(?:passwd|shadow|group|hosts|hostname|issue|motd|crontab)|Direct /etc/ access — sensitive Unix system files
lfi|(?i)(?:\/|%2f)proc(?:\/|%2f)(?:self|version|cmdline|environ|mounts|net)|/proc/self access — process information leakage
lfi|(?i)wp-config(?:\.php(?:\.bak|\.old|\.save|\.swp|~)?|\.txt|\.bak|\.orig)|wp-config.php access/backup — credential exposure
lfi|(?i)\.env(?:\.bak|\.old|\.save|\.local|\.production|\.staging)?(?:\?|$|&|\s)|.env file access — environment variable leakage
lfi|(?i)(?:\/|%2f)(?:windows|winnt)(?:\/|%2f)(?:system32|win\.ini|boot\.ini)|Windows system path traversal — win.ini/system32
lfi|(?i)(?:php(?:://)?filter\/(?:read=)?|php:\/\/input|php:\/\/fd)|PHP stream wrapper — filter/input LFI
lfi|(?i)(?:access\.log|error\.log|debug\.log|wp-debug\.log|\.htaccess|\.htpasswd)(?:\?|$|&|\s|%)|Log/config file access — information disclosure
# =============================================================================
# REMOTE FILE INCLUSION (RFI)
# =============================================================================
rfi|(?i)(?:file|page|path|url|include|require|src|dest|redirect)\s*=\s*(?:https?|ftp|ftps):\/\/(?!(?:localhost|127\.|10\.|192\.168\.|172\.(?:1[6-9]|2\d|3[01])\.))[\w\-\.]+\.[a-z]{2,}|Remote URL in file-inclusion parameter
rfi|(?i)(?:file|page|path|url|include|require|src)\s*=\s*(?:php:\/\/(?:input|filter|memory|temp|fd)|data:text\/(?:php|html)|expect:\/\/|zip:\/\/|phar:\/\/|glob:\/\/)|Dangerous PHP stream wrapper in param
rfi|(?i)(?:file|page|path|url|include|require|src)\s*=\s*(?:ftp|ftps):\/\/|FTP wrapper in file-inclusion parameter
rfi|(?i)(?:file|page|path|url|include)\s*=\s*(?:\.|%2e){2,}(?:\/|%2f|\\|%5c)|Path traversal in file-inclusion parameter (combined RFI/LFI)
rfi|(?i)(?:file|page|path|url|include|require)\s*=\s*\\\\[a-z0-9_\-]{1,50}\\|UNC/SMB path in include parameter — Windows RFI
rfi|(?i)=\s*(?:https?|ftp):\/\/[^\s&]{10,}\?[^\s&]{0,200}(?:exec|system|passthru|eval|assert)|RFI URL containing PHP execution function
# =============================================================================
# COMMAND INJECTION (CMDi)
# =============================================================================
cmdi|(?i)[;&|`]\s*(?:wget|curl|lwp-request|fetch)\s+(?:https?|ftp):\/\/|Shell-piped downloader — wget/curl after metacharacter
cmdi|(?i)[;&|`]\s*(?:bash|sh|ksh|zsh|dash|tcsh|csh|ash)\s+(?:-[a-z]{1,4}\s+)?['"\/\$]|Shell invocation after metacharacter
cmdi|(?i)[;&|`]\s*(?:nc|ncat|netcat)\s+(?:-[a-z]{0,5}\s+)?[\d\.]{7,}|Netcat reverse/bind shell after metacharacter
cmdi|(?i)[;&|`]\s*(?:python|python3|perl|ruby|php|lua)\s+-[ce]\s+['"]|Scripting language one-liner — code execution
cmdi|(?i)\$\((?:[^)]{0,100}(?:wget|curl|cat|id|uname|whoami|ls|bash|sh|nc|python|perl)[^)]{0,100})\)|Command substitution with dangerous command
cmdi|(?i)`[^`]{0,100}(?:wget|curl|cat|id|uname|whoami|ls|bash|sh|nc|python|perl)[^`]{0,100}`|Backtick command substitution with dangerous command
cmdi|(?i)(?:;|\|)\s*(?:cat\s+(?:\/etc\/|~\/|\.\.\/)|id\b|uname\b|whoami\b|hostname\b)|Classic command injection — system recon commands
cmdi|(?i)(?:\/bin\/|\/usr\/bin\/)(?:bash|sh|ksh|zsh|wget|curl|nc|netcat|python|perl)\b|Absolute path to shell binary — direct execution
# =============================================================================
# XML EXTERNAL ENTITY (XXE)
# =============================================================================
xxe|(?i)<!DOCTYPE\s+[^\[]{0,50}\[[\s\S]{0,500}<!ENTITY|DOCTYPE with inline DTD containing ENTITY — classic XXE
xxe|(?i)<!ENTITY\s+[%\s]*\w+\s+SYSTEM\s+['"]|ENTITY SYSTEM declaration — file disclosure or SSRF via XXE
xxe|(?i)<!ENTITY\s+[%\s]*\w+\s+PUBLIC\s+|ENTITY PUBLIC declaration — external DTD XXE
xxe|(?i)SYSTEM\s+['"](?:file|http|ftp|php|expect|gopher|dict):\/\/|XXE SYSTEM with dangerous URI scheme
xxe|(?i)<!DOCTYPE\s+[^\>]{0,100}SYSTEM\s+['"]|DOCTYPE SYSTEM — external DTD loading
xxe|(?i)(?:&\w{1,30};){3,}|Multiple entity references in sequence — entity expansion / billion-laughs DoS
# =============================================================================
# PHP OBJECT INJECTION & EVAL ABUSE
# =============================================================================
php_inject|(?i)\bunserialize\s*\(\s*(?:\$_(?:GET|POST|REQUEST|COOKIE|SERVER|FILES)|base64_decode\s*\(|gzinflate\s*\(|str_rot13\s*\()|Unserialize with tainted input — PHP object injection
php_inject|(?i)(?:base64_decode|base64decode)\s*\(\s*['"$][\s\S]{0,200}\)\s*\)|base64_decode chain — obfuscated payload decoding
php_inject|(?i)\beval\s*\(\s*(?:base64_decode|str_rot13|gzinflate|gzuncompress|str_replace|hex2bin)\s*\(|eval() wrapping decode function — multi-layer obfuscation
php_inject|(?i)\bassert\s*\(\s*(?:\$_(?:GET|POST|REQUEST|COOKIE)|base64_decode\s*\(|stripslashes\s*\()|assert() with tainted input — code execution via assertion
php_inject|(?i)preg_replace\s*\(\s*['"](?:[^'"]{0,50})\/e['"\s,]|preg_replace /e modifier — deprecated RCE vector
php_inject|(?i)(?:system|exec|shell_exec|passthru|popen|proc_open|pcntl_exec)\s*\(\s*(?:\$_(?:GET|POST|REQUEST|COOKIE)|base64_decode)|System execution function with tainted input
php_inject|(?i)(?:O:\d+:"[a-zA-Z_][a-zA-Z0-9_]*":\d+:\{)|PHP serialized object string — deserialization payload
php_inject|(?i)(?:call_user_func(?:_array)?|create_function|usort|uasort|uksort)\s*\(\s*(?:\$_(?:GET|POST|REQUEST|COOKIE)|['"][^'"]{0,50}system)|Callback function with tainted callable — code execution
# =============================================================================
# SERVER-SIDE REQUEST FORGERY (SSRF)
# =============================================================================
ssrf|(?i)(?:url|uri|endpoint|dest|redirect|path|proxy|target|src|href|feed|link|image)\s*=\s*(?:https?):\/\/(?:169\.254\.169\.254|metadata\.google\.internal|100\.100\.100\.200)|AWS/GCP/Alibaba metadata endpoint in URL parameter
ssrf|(?i)(?:url|uri|endpoint|dest|redirect|path|proxy|target|src|href|feed|link|image)\s*=\s*https?:\/\/(?:127\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}|localhost|0\.0\.0\.0)|Loopback address in URL parameter — SSRF localhost
ssrf|(?i)(?:url|uri|endpoint|dest|redirect|path|proxy|target|src|href|feed|link|image)\s*=\s*https?:\/\/(?:10\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}|172\.(?:1[6-9]|2[0-9]|3[01])\.[0-9]{1,3}\.[0-9]{1,3}|192\.168\.[0-9]{1,3}\.[0-9]{1,3})|Private RFC-1918 IP range in URL parameter — internal network SSRF
ssrf|(?i)(?:url|uri|endpoint|dest|redirect|path|proxy|target)\s*=\s*(?:file|gopher|dict|ftp|ldap|sftp):\/\/|Non-HTTP scheme in URL parameter — protocol-based SSRF
ssrf|(?i)(?:url|uri|endpoint|dest|redirect|path|proxy|target)\s*=\s*(?:https?):\/\/[^\s&]{0,30}@|Credentials in URL (@ sign) — SSRF with auth bypass
ssrf|(?i)(?:url|uri|endpoint|dest|redirect|path|proxy|target)\s*=\s*(?:0x[0-9a-f]{8}|0[0-7]{9,}|(?:\d+\.){3}\d+)\b|Octal/hex IP encoding in URL parameter — SSRF filter bypass
# =============================================================================
# WORDPRESS-SPECIFIC ATTACKS
# =============================================================================
wp_specific|(?i)\/wp-json\/(?:wp\/v[12]\/)?\.\.\/|wp-json REST API path traversal — directory escape
wp_specific|(?i)\/wp-json\/[^\s?#]{0,200}(?:%2e%2e|%252e%252e|\.\.)[^\s?#]{0,100}|wp-json encoded path traversal — encoded ../ in REST API
wp_specific|(?i)xmlrpc\.php[\s\S]{0,200}(?:system\.multicall|<?xml[^>]{0,200}methodName[^>]{0,200}system\.multicall)|xmlrpc.php multicall abuse — brute-force amplification
wp_specific|(?i)\?author=\d+(?:&|$|\s)|Author ID enumeration via /?author= parameter
wp_specific|(?i)(?:\/|^)wp-config(?:\.php(?:~|\.bak|\.old|\.save|\.orig)?|\.txt|\.bak)(?:\?|#|$|\s|%)|Direct wp-config.php access or backup filename
wp_specific|(?i)(?:\/|^)(?:readme\.html|license\.txt|wp-activate\.php|wp-cron\.php|xmlrpc\.php)(?:\?|#|$|\s)|Exposure of WordPress disclosure/utility files
wp_specific|(?i)(?:\/|^)(?:debug\.log|\.debug\.log|php_error\.log|error_log)(?:\?|#|$|\s)|WordPress debug log file access — information disclosure
wp_specific|(?i)(?:timthumb|thumb)\.php[\s\S]{0,200}\bsrc\s*=\s*https?:\/\/|TimThumb exploit — remote file fetch via src parameter
wp_specific|(?i)\/wp-includes\/(?:js\/tinymce|theme-compat|class-wp-hook|class-wp-list-table)\/[^\s?#]{0,100}\.php|Direct PHP access to wp-includes subdirectories
wp_specific|(?i)\/wp-content\/uploads\/[^\s?#]{0,200}\.php(?:\d?|ml)(?:\?|#|$|\s|%)|PHP file execution in uploads directory — webshell access

View File

@@ -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 &amp; 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
* ══════════════════════════════════════════════════════════ */

View 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),
];
}
}

View File

@@ -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
View 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);
}
}

View File

@@ -23,10 +23,12 @@ define('ITK_BASENAME', plugin_basename(__FILE__));
require_once ITK_PATH . 'includes/class-itk-database.php';
require_once ITK_PATH . 'includes/class-itk-hp-api.php';
require_once ITK_PATH . 'includes/class-itk-bot-api.php';
require_once ITK_PATH . 'includes/class-itk-attacks-api.php';
require_once ITK_PATH . 'includes/class-itk-bot-blocker.php';
require_once ITK_PATH . 'includes/class-itk-protection.php';
require_once ITK_PATH . 'includes/class-itk-optimization.php';
require_once ITK_PATH . 'includes/class-itk-honeypot.php';
require_once ITK_PATH . 'includes/class-itk-waf.php';
require_once ITK_PATH . 'includes/class-itk-admin.php';
class InformatiQ_Toolkit {
@@ -52,11 +54,13 @@ class InformatiQ_Toolkit {
// Boot API cron flushers
ITK_HP_API::register_cron();
ITK_Bot_API::register_cron();
ITK_Attacks_API::register_cron();
new ITK_Bot_Blocker();
new ITK_Protection();
new ITK_Optimization();
new ITK_Honeypot();
new ITK_WAF();
if (is_admin()) {
new ITK_Admin();
@@ -147,12 +151,37 @@ class InformatiQ_Toolkit {
]);
}
// Default WAF settings
if (!get_option('itk_waf')) {
add_option('itk_waf', [
'enabled' => 1,
'action' => 'block',
'response_code' => '403',
'redirect_url' => '',
'custom_message' => 'Access denied.',
'log_attacks' => 1,
'scan_post' => 1,
'scan_cookies' => 0,
'scan_ua' => 1,
'block_sqli' => 1,
'block_xss' => 1,
'block_lfi' => 1,
'block_rfi' => 1,
'block_cmdi' => 1,
'block_xxe' => 1,
'block_php_inject' => 1,
'block_ssrf' => 1,
'block_wp_specific' => 1,
]);
}
flush_rewrite_rules();
}
public static function deactivate() {
ITK_HP_API::clear_cron();
ITK_Bot_API::clear_cron();
ITK_Attacks_API::clear_cron();
flush_rewrite_rules();
}
}