diff --git a/.gitignore b/.gitignore
index 3b4d139..95814b6 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1 +1,2 @@
bot-api/
+attack-api/
diff --git a/config/waf-rules.conf b/config/waf-rules.conf
new file mode 100644
index 0000000..c94d2f0
--- /dev/null
+++ b/config/waf-rules.conf
@@ -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)]{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|]{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
diff --git a/includes/class-itk-admin.php b/includes/class-itk-admin.php
index aa0d861..6f870e3 100644
--- a/includes/class-itk-admin.php
+++ b/includes/class-itk-admin.php
@@ -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 {
+
+
+
+
+ Web Application Firewall
+ ['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;
+ ?>
+
+
+
+
+ Scan Scope & Response
+ ['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;
+ ?>
+
+
+
+
+
+
+
+ Central Attacks API
+ Send WAF attack events to your self-hosted Attack Intelligence Docker stack (port 3083).
+
+
+ = esc_html($atk_lbl) ?>
+ 0): ?>
+ Last tested = esc_html(human_time_diff((int)$atk_api['last_verified'])) ?> ago
+
+
+ = esc_html($atk_api['last_error']) ?>
+
+
+
+
+ = esc_html($atk_test_r['message']) ?>
+
+
+ = esc_html($atk_hist_r['message']) ?>
+
+
+
+
+
+
+
+
+ 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');
+ ?>
+
+
+
+
+
Showing = count($rows) ?> of = number_format($total) ?> result(s)
+
+
+
+
+ | Date / Time | IP | Type |
+ Source | Param | Payload | URI | Method |
+
+
+
+
+ | No attacks logged yet. |
+
+
+
+ | = esc_html($row->logged_at) ?> |
+
+ = esc_html($row->ip_address) ?>
+ [filter]
+ [lookup]
+ |
+ = esc_html($row->attack_type) ?> |
+ = esc_html($row->source) ?> |
+ = esc_html(substr($row->param, 0, 60)) ?> |
+ = esc_html(substr($row->payload, 0, 80)) ?> |
+ = esc_html(substr($row->request_uri, 0, 80)) ?> |
+ = esc_html($row->method) ?> |
+
+
+
+
+
+
+ render_pager($paged, $total_pages, $base_url); ?>
+
+
+
+
+ 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),
+ ];
+ }
+}
diff --git a/includes/class-itk-database.php b/includes/class-itk-database.php
index b773b8b..0b3f5bd 100644
--- a/includes/class-itk-database.php
+++ b/includes/class-itk-database.php
@@ -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());
+ }
}
diff --git a/includes/class-itk-waf.php b/includes/class-itk-waf.php
new file mode 100644
index 0000000..afc91a5
--- /dev/null
+++ b/includes/class-itk-waf.php
@@ -0,0 +1,264 @@
+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
+ */
+ 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);
+ }
+}
diff --git a/informatiq-toolkit.php b/informatiq-toolkit.php
index df9d611..e6f7c84 100644
--- a/informatiq-toolkit.php
+++ b/informatiq-toolkit.php
@@ -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();
}
}