From 742047915f6c83c84cc8a44015521b128e5efdd0 Mon Sep 17 00:00:00 2001 From: Malin Date: Fri, 10 Apr 2026 09:37:31 +0200 Subject: [PATCH] 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 --- .gitignore | 1 + config/waf-rules.conf | 124 +++++++++++ includes/class-itk-admin.php | 330 ++++++++++++++++++++++++++++- includes/class-itk-attacks-api.php | 239 +++++++++++++++++++++ includes/class-itk-database.php | 110 +++++++++- includes/class-itk-waf.php | 264 +++++++++++++++++++++++ informatiq-toolkit.php | 29 +++ 7 files changed, 1093 insertions(+), 4 deletions(-) create mode 100644 config/waf-rules.conf create mode 100644 includes/class-itk-attacks-api.php create mode 100644 includes/class-itk-waf.php 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; + ?> + +
+ + + + + + + + + + + + + + + + + + + +
Action + +

Choose whether to block the request or only log the attack.

+
Response Code + +
Redirect URL + +

Only used when response code is 301 redirect.

+
Block Message + +

Message shown on 403/400 responses.

+
+ +
+
+ + + +
+

Central Attacks API

+

Send WAF attack events to your self-hosted Attack Intelligence Docker stack (port 3083).

+ +
+ + 0): ?> + Last tested ago + + + + +
+ + +
+ + +
+ + +
+ + + + + + + + + + + + + + + +
Enable
API URL + +

Base URL of your Attack API stack (e.g. http://localhost:3083)

+
API Token + +
+
+ + + +
+
+ + +
+ +
+ 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'); + ?> +
+ +
+ + + + + + + Reset +
+ +

Showing of result(s)

+ + + + + + + + + + + + + + + + + + + + + + + + + + +
Date / TimeIPTypeSourceParamPayloadURIMethod
No attacks logged yet.
logged_at) ?> + ip_address) ?> + [filter] + [lookup] + attack_type) ?>source) ?>param, 0, 60)) ?>payload, 0, 80)) ?>request_uri, 0, 80)) ?>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(); } }