Changeset 3495943
- Timestamp:
- 03/31/2026 07:06:53 PM (2 days ago)
- Location:
- vulntitan
- Files:
-
- 28 edited
- 1 copied
-
tags/2.1.17 (copied) (copied from vulntitan/trunk)
-
tags/2.1.17/CHANGELOG.md (modified) (1 diff)
-
tags/2.1.17/assets/js/malware-scanner.js (modified) (1 diff)
-
tags/2.1.17/assets/js/malware-scanner.min.js (modified) (1 diff)
-
tags/2.1.17/includes/Admin/Ajax.php (modified) (16 diffs)
-
tags/2.1.17/includes/Admin/Pages/Firewall.php (modified) (1 diff)
-
tags/2.1.17/includes/Plugin.php (modified) (4 diffs)
-
tags/2.1.17/includes/Services/CaptchaService.php (modified) (4 diffs)
-
tags/2.1.17/includes/Services/CommentSpamService.php (modified) (9 diffs)
-
tags/2.1.17/includes/Services/ContactFormSpamService.php (modified) (3 diffs)
-
tags/2.1.17/includes/Services/FirewallService.php (modified) (6 diffs)
-
tags/2.1.17/includes/Services/FluentFormSpamService.php (modified) (3 diffs)
-
tags/2.1.17/includes/Services/LoginSecurityService.php (modified) (6 diffs)
-
tags/2.1.17/readme.txt (modified) (4 diffs)
-
tags/2.1.17/vulntitan.php (modified) (2 diffs)
-
trunk/CHANGELOG.md (modified) (1 diff)
-
trunk/assets/js/malware-scanner.js (modified) (1 diff)
-
trunk/assets/js/malware-scanner.min.js (modified) (1 diff)
-
trunk/includes/Admin/Ajax.php (modified) (16 diffs)
-
trunk/includes/Admin/Pages/Firewall.php (modified) (1 diff)
-
trunk/includes/Plugin.php (modified) (4 diffs)
-
trunk/includes/Services/CaptchaService.php (modified) (4 diffs)
-
trunk/includes/Services/CommentSpamService.php (modified) (9 diffs)
-
trunk/includes/Services/ContactFormSpamService.php (modified) (3 diffs)
-
trunk/includes/Services/FirewallService.php (modified) (6 diffs)
-
trunk/includes/Services/FluentFormSpamService.php (modified) (3 diffs)
-
trunk/includes/Services/LoginSecurityService.php (modified) (6 diffs)
-
trunk/readme.txt (modified) (4 diffs)
-
trunk/vulntitan.php (modified) (2 diffs)
Legend:
- Unmodified
- Added
- Removed
-
vulntitan/tags/2.1.17/CHANGELOG.md
r3490735 r3495943 5 5 The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), 6 6 and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 8 ## [2.1.17] - 2026-03-31 9 ### Added 10 - Logged comments that land in the WordPress moderation queue into the firewall activity stream and weekly digest reporting. 11 - Expanded the weekly digest protection profile and activity summaries to cover comment moderation and form-spam signals. 12 13 ### Changed 14 - Refined the weekly digest email layout so two-column sections collapse cleanly on mobile screens. 15 16 ### Security 17 - Hardened malware and integrity scan actions with stricter capability checks, safer in-root path validation, and server-side verification of auto-fix targets. 18 - Enforced signed anti-spam tokens and CAPTCHA handling on REST comment submissions when anonymous REST comments are exposed elsewhere. 19 - Tightened trusted proxy handling, bounded anti-spam token lifetimes, rate-limited 2FA challenge attempts, and moved maintenance self-heal work off the hot request path. 7 20 8 21 ## [2.1.16] - 2026-03-25 -
vulntitan/tags/2.1.17/assets/js/malware-scanner.js
r3483103 r3495943 325 325 $feedback.text('').css('color', '#94a3b8'); 326 326 327 const patterns = Array.isArray(finding.patterns) ? finding.patterns.join(', ') : (finding.type || '');328 327 $.post(ajaxurl, { 329 328 action: 'vulntitan_malware_fix_finding', 330 329 nonce: VulnTitan.nonce, 331 330 file_path: item.file || '', 332 line: lineNumber, 333 expected_code: finding.code || '', 334 patterns: patterns 331 line: lineNumber 335 332 }, function (response) { 336 333 if (response && response.success) { -
vulntitan/tags/2.1.17/assets/js/malware-scanner.min.js
r3483103 r3495943 325 325 $feedback.text('').css('color', '#94a3b8'); 326 326 327 const patterns = Array.isArray(finding.patterns) ? finding.patterns.join(', ') : (finding.type || '');328 327 $.post(ajaxurl, { 329 328 action: 'vulntitan_malware_fix_finding', 330 329 nonce: VulnTitan.nonce, 331 330 file_path: item.file || '', 332 line: lineNumber, 333 expected_code: finding.code || '', 334 patterns: patterns 331 line: lineNumber 335 332 }, function (response) { 336 333 if (response && response.success) { -
vulntitan/tags/2.1.17/includes/Admin/Ajax.php
r3485911 r3495943 13 13 class Ajax 14 14 { 15 protected const FILE_OPERATIONS_CAPABILITY_FILTER = 'vulntitan_file_operations_capability'; 16 15 17 public function register(): void 16 18 { … … 617 619 check_ajax_referer('vulntitan_ajax_scan', 'nonce'); 618 620 619 if (!current_user_can('manage_options')) { 620 wp_send_json_error(['message' => esc_html__('Insufficient permissions.', 'vulntitan')], 403); 621 } 621 $this->requireFileOperationsCapability(); 622 622 623 623 $scanner = new MalwareScanner(get_option('vulntitan_malware_scan_scope', 'all')); … … 634 634 check_ajax_referer('vulntitan_ajax_scan', 'nonce'); 635 635 636 if (!current_user_can('manage_options')) { 637 wp_send_json_error(['message' => esc_html__('Insufficient permissions.', 'vulntitan')], 403); 638 } 639 640 $file = realpath( sanitize_text_field( wp_unslash( $_POST['file_path'] ?? '' ) ) ); 641 642 if (!$file || strpos($file, ABSPATH) !== 0 || !file_exists($file)) { 636 $this->requireFileOperationsCapability(); 637 638 $resolved = $this->resolveFilePathWithinWordPress((string) wp_unslash($_POST['file_path'] ?? '')); 639 if ($resolved === null) { 643 640 wp_send_json_error(['message' => esc_html__('Invalid or missing file.', 'vulntitan')]); 644 641 } … … 646 643 try { 647 644 $scanner = new MalwareScanner(get_option('vulntitan_malware_scan_scope', 'all')); 648 $result = $scanner->scanSingleFile($ file);645 $result = $scanner->scanSingleFile($resolved['absolute']); 649 646 650 647 wp_send_json_success([ … … 665 662 check_ajax_referer('vulntitan_ajax_scan', 'nonce'); 666 663 667 if (!current_user_can('manage_options')) { 668 wp_send_json_error(['message' => esc_html__('Insufficient permissions.', 'vulntitan')], 403); 669 } 664 $this->requireFileOperationsCapability(); 670 665 671 666 $filePaths = $_POST['file_paths'] ?? []; … … 679 674 680 675 foreach ($filePaths as $inputPath) { 681 // Normalize: if path is absolute, convert it to relative 682 if (strpos($inputPath, ABSPATH) === 0) { 683 $relativePath = ltrim(str_replace(ABSPATH, '', $inputPath), '/\\'); 684 } else { 685 $relativePath = ltrim($inputPath, '/\\'); 686 } 687 688 $absolutePath = ABSPATH . $relativePath; 689 690 // Ensure the resolved path stays within WP directory (security check) 691 $realAbsolute = realpath($absolutePath); 692 $realBase = realpath(ABSPATH); 693 694 if (!$realAbsolute || strpos($realAbsolute, $realBase) !== 0) { 676 $resolved = $this->resolveFilePathWithinWordPress((string) $inputPath); 677 678 if ($resolved === null) { 695 679 $results[] = [ 696 'file' => $relativePath,680 'file' => ltrim(str_replace('\\', '/', (string) $inputPath), '/'), 697 681 'status' => 'skipped', 698 682 'skipped' => true, … … 701 685 continue; 702 686 } 703 704 if (!file_exists($realAbsolute)) { 687 688 try { 689 $findings = $scanner->scanSingleFile($resolved['absolute']); 705 690 $results[] = [ 706 'file' => $relativePath, 707 'status' => 'skipped', 708 'skipped' => true, 709 'reason' => esc_html__('File not found.', 'vulntitan'), 710 ]; 711 continue; 712 } 713 714 try { 715 $findings = $scanner->scanSingleFile($realAbsolute); 716 $results[] = [ 717 'file' => $relativePath, 691 'file' => $resolved['relative'], 718 692 'status' => !empty($findings) ? 'infected' : 'clean', 719 693 'findings' => $findings, … … 721 695 } catch (Throwable $e) { 722 696 $results[] = [ 723 'file' => $re lativePath,697 'file' => $resolved['relative'], 724 698 'status' => 'error', 725 699 'error' => $e->getMessage(), … … 739 713 check_ajax_referer('vulntitan_ajax_scan', 'nonce'); 740 714 741 if (!current_user_can('manage_options')) { 742 wp_send_json_error(['message' => esc_html__('Insufficient permissions.', 'vulntitan')], 403); 743 } 715 $this->requireFileOperationsCapability(); 744 716 745 717 $filePath = sanitize_text_field(wp_unslash($_POST['file_path'] ?? '')); 746 718 $lineNumber = isset($_POST['line']) ? (int) $_POST['line'] : 0; 747 $expectedCodeRaw = wp_unslash($_POST['expected_code'] ?? '');748 $patternsRaw = wp_unslash($_POST['patterns'] ?? '');749 750 $expectedCode = is_string($expectedCodeRaw) ? trim(substr($expectedCodeRaw, 0, 2000)) : '';751 $patterns = is_string($patternsRaw) ? trim(substr($patternsRaw, 0, 300)) : '';752 719 753 720 if ($filePath === '' || $lineNumber < 1) { … … 755 722 } 756 723 757 if (!$this->isFixablePatternList($patterns)) { 758 wp_send_json_error(['message' => esc_html__('Auto-fix is disabled for low-risk findings.', 'vulntitan')]); 759 } 760 761 $relativePath = ltrim(str_replace('\\', '/', $filePath), '/'); 762 $absolutePath = ABSPATH . $relativePath; 763 $realAbsolute = realpath($absolutePath); 764 $realBase = realpath(ABSPATH); 765 766 if (!$realAbsolute || !$realBase || strpos($realAbsolute, $realBase) !== 0 || !is_file($realAbsolute)) { 724 $resolved = $this->resolveFilePathWithinWordPress($filePath); 725 if ($resolved === null) { 767 726 wp_send_json_error(['message' => esc_html__('Invalid or unsafe file path.', 'vulntitan')]); 727 } 728 729 $relativePath = $resolved['relative']; 730 $realAbsolute = $resolved['absolute']; 731 $finding = $this->getFixableMalwareFinding($realAbsolute, $lineNumber); 732 if ($finding === null) { 733 wp_send_json_error(['message' => esc_html__('Auto-fix requires a current high-risk finding on the selected line.', 'vulntitan')]); 768 734 } 769 735 … … 796 762 $updatedLine = $originalLine; 797 763 $appliedMode = 'quarantine_line'; 764 $expectedCode = trim(substr((string) ($finding['code'] ?? ''), 0, 2000)); 765 $patterns = $this->normalizeFixablePatternList($finding['patterns'] ?? []); 798 766 $normalizedExpectedCode = str_replace(["\r", "\n"], '', $expectedCode); 799 767 … … 953 921 } 954 922 923 protected function getFixableMalwareFinding(string $absolutePath, int $lineNumber): ?array 924 { 925 try { 926 $scanner = new MalwareScanner(get_option('vulntitan_malware_scan_scope', 'all')); 927 $findings = $scanner->scanSingleFile($absolutePath); 928 } catch (Throwable $e) { 929 return null; 930 } 931 932 foreach ($findings as $finding) { 933 if (!is_array($finding) || (int) ($finding['line'] ?? 0) !== $lineNumber) { 934 continue; 935 } 936 937 $patterns = $this->normalizeFixablePatternList($finding['patterns'] ?? []); 938 if (!$this->isFixablePatternList($patterns)) { 939 continue; 940 } 941 942 return $finding; 943 } 944 945 return null; 946 } 947 948 /** 949 * @param mixed $patterns 950 */ 951 protected function normalizeFixablePatternList($patterns): string 952 { 953 if (!is_array($patterns)) { 954 return ''; 955 } 956 957 $items = []; 958 foreach ($patterns as $pattern) { 959 $value = sanitize_key((string) $pattern); 960 if ($value !== '') { 961 $items[] = $value; 962 } 963 } 964 965 return implode(', ', array_values(array_unique($items))); 966 } 967 955 968 protected function isFixablePatternList(string $patterns): bool 956 969 { … … 978 991 } 979 992 993 protected function requireFileOperationsCapability(): void 994 { 995 if ($this->currentUserCanManageFileOperations()) { 996 return; 997 } 998 999 wp_send_json_error(['message' => esc_html__('Insufficient permissions.', 'vulntitan')], 403); 1000 } 1001 1002 protected function currentUserCanManageFileOperations(): bool 1003 { 1004 $defaultCapability = is_multisite() ? 'manage_network_plugins' : 'update_plugins'; 1005 $capability = apply_filters(self::FILE_OPERATIONS_CAPABILITY_FILTER, $defaultCapability); 1006 $capability = is_string($capability) && $capability !== '' ? $capability : $defaultCapability; 1007 1008 return current_user_can($capability); 1009 } 1010 1011 protected function resolveFilePathWithinWordPress(string $inputPath): ?array 1012 { 1013 $inputPath = trim($inputPath); 1014 if ($inputPath === '') { 1015 return null; 1016 } 1017 1018 $basePath = realpath(ABSPATH); 1019 if (!$basePath) { 1020 return null; 1021 } 1022 1023 $normalizedBase = untrailingslashit(wp_normalize_path($basePath)); 1024 $normalizedInput = wp_normalize_path($inputPath); 1025 1026 if ($normalizedInput === $normalizedBase || strpos($normalizedInput, $normalizedBase . '/') === 0) { 1027 $relativePath = ltrim(substr($normalizedInput, strlen($normalizedBase)), '/'); 1028 } else { 1029 $relativePath = ltrim(str_replace('\\', '/', $inputPath), '/'); 1030 } 1031 1032 if ($relativePath === '') { 1033 return null; 1034 } 1035 1036 $absolutePath = realpath(trailingslashit($basePath) . $relativePath); 1037 if (!$absolutePath || !is_file($absolutePath) || !$this->isPathInsideBase($absolutePath, $basePath)) { 1038 return null; 1039 } 1040 1041 $normalizedAbsolute = untrailingslashit(wp_normalize_path($absolutePath)); 1042 1043 return [ 1044 'absolute' => $absolutePath, 1045 'relative' => ltrim(substr($normalizedAbsolute, strlen($normalizedBase)), '/'), 1046 ]; 1047 } 1048 1049 protected function isPathInsideBase(string $path, string $basePath): bool 1050 { 1051 $normalizedPath = untrailingslashit(wp_normalize_path($path)); 1052 $normalizedBase = untrailingslashit(wp_normalize_path($basePath)); 1053 1054 return $normalizedPath === $normalizedBase || strpos($normalizedPath, $normalizedBase . '/') === 0; 1055 } 1056 980 1057 public function integrityScanInit(): void 981 1058 { 982 1059 check_ajax_referer('vulntitan_ajax_scan', 'nonce'); 983 1060 984 if (!current_user_can('manage_options')) { 985 wp_send_json_error(['message' => esc_html__('Insufficient permissions.', 'vulntitan')], 403); 986 } 1061 $this->requireFileOperationsCapability(); 987 1062 988 1063 if (!BaselineBuilder::exists()) { … … 1024 1099 check_ajax_referer('vulntitan_ajax_scan', 'nonce'); 1025 1100 1026 if (!current_user_can('manage_options')) { 1027 wp_send_json_error(['message' => esc_html__('Insufficient permissions.', 'vulntitan')], 403); 1028 } 1029 1030 $file = sanitize_text_field(wp_unslash($_POST['file_path'] ?? '')); 1031 if ($file === '') { 1032 wp_send_json_error(['message' => esc_html__('File not found.', 'vulntitan')]); 1033 } 1034 1035 if (strpos($file, ABSPATH) === 0) { 1036 $relative = ltrim(str_replace(ABSPATH, '', $file), '/\\'); 1037 } else { 1038 $relative = ltrim($file, '/\\'); 1039 } 1040 1041 $absolutePath = ABSPATH . $relative; 1042 $realAbsolute = realpath($absolutePath); 1043 $realBase = realpath(ABSPATH); 1044 1045 if (!$realAbsolute || !$realBase || strpos($realAbsolute, $realBase) !== 0 || !file_exists($realAbsolute)) { 1101 $this->requireFileOperationsCapability(); 1102 1103 $resolved = $this->resolveFilePathWithinWordPress((string) wp_unslash($_POST['file_path'] ?? '')); 1104 if ($resolved === null) { 1046 1105 wp_send_json_error(['message' => esc_html__('File not found.', 'vulntitan')]); 1047 1106 } … … 1052 1111 $scanner->loadBaseline(); 1053 1112 1054 $relativePath = ltrim(str_replace($realBase, '', $realAbsolute), '/\\'); 1055 $result = $scanner->scanSingleFile($relativePath); 1113 $result = $scanner->scanSingleFile($resolved['relative']); 1056 1114 1057 1115 wp_send_json_success([ … … 1071 1129 $startedAt = microtime(true); 1072 1130 1073 if (!current_user_can('manage_options')) { 1074 wp_send_json_error(['message' => esc_html__('Insufficient permissions.', 'vulntitan')], 403); 1075 } 1131 $this->requireFileOperationsCapability(); 1076 1132 1077 1133 $queueId = sanitize_text_field( wp_unslash( $_POST['queue_id'] ?? '' ) ); -
vulntitan/tags/2.1.17/includes/Admin/Pages/Firewall.php
r3486040 r3495943 208 208 <span class="vulntitan-firewall-field-label"><?php esc_html_e('Trusted Proxy IPs', 'vulntitan'); ?></span> 209 209 <textarea id="vulntitan-firewall-trusted-proxies" class="vulntitan-firewall-textarea" rows="4" placeholder="203.0.113.10"></textarea> 210 <small class="vulntitan-firewall-field-help"><?php esc_html_e('Add one IP per line or comma-separated. Only these proxies will be trusted for X-Forwarded-For / X-Real-IP headers.', 'vulntitan'); ?></small> 210 <small class="vulntitan-firewall-field-help"><?php esc_html_e('Add one IP or CIDR per line or comma-separated. Only these proxies will be trusted for X-Forwarded-For / X-Real-IP headers.', 'vulntitan'); ?></small> 211 <small class="vulntitan-firewall-field-help"><?php esc_html_e('Private proxy addresses are not trusted automatically. List them here if your reverse proxy or load balancer terminates traffic on a private network.', 'vulntitan'); ?></small> 211 212 <small class="vulntitan-firewall-field-help"><?php esc_html_e('If you use Cloudflare, enable the toggle above (no manual IP list needed).', 'vulntitan'); ?></small> 212 213 </label> -
vulntitan/tags/2.1.17/includes/Plugin.php
r3485911 r3495943 23 23 protected const FIREWALL_LOG_CLEANUP_HOOK = 'vulntitan_cleanup_firewall_logs'; 24 24 protected const WEEKLY_SUMMARY_EMAIL_HOOK = 'vulntitan_send_weekly_summary_email'; 25 protected const COMPONENT_HEALTH_CHECK_TRANSIENT = 'vulntitan_component_health_check'; 26 protected const COMPONENT_HEALTH_CHECK_TTL = 12 * HOUR_IN_SECONDS; 27 protected const COMPONENT_HEALTH_CHECK_RETRY_TTL = 15 * MINUTE_IN_SECONDS; 25 28 26 29 public $admin; … … 29 32 { 30 33 $this->maybe_generate_baseline(); 31 $this-> ensure_firewall_components();34 $this->maybeEnsureFirewallComponents(); 32 35 $this->register_scheduled_events(); 33 36 $this->register_cli_commands(); … … 59 62 wp_clear_scheduled_hook(self::FIREWALL_LOG_CLEANUP_HOOK); 60 63 wp_clear_scheduled_hook(self::WEEKLY_SUMMARY_EMAIL_HOOK); 64 delete_transient(self::COMPONENT_HEALTH_CHECK_TRANSIENT); 61 65 FirewallService::removeMuLoader(); 62 66 } … … 136 140 } 137 141 138 protected function ensure_firewall_components(): void 139 { 140 FirewallService::ensureTable(); 141 FirewallService::installMuLoader(); 142 VulnerabilityRiskService::ensureTables(); 142 protected function maybeEnsureFirewallComponents(): void 143 { 144 if (!$this->shouldRunComponentHealthCheck()) { 145 return; 146 } 147 148 $success = $this->ensure_firewall_components(); 149 set_transient( 150 self::COMPONENT_HEALTH_CHECK_TRANSIENT, 151 $success ? 'ok' : 'retry', 152 $success ? self::COMPONENT_HEALTH_CHECK_TTL : self::COMPONENT_HEALTH_CHECK_RETRY_TTL 153 ); 154 } 155 156 protected function shouldRunComponentHealthCheck(): bool 157 { 158 if (defined('WP_INSTALLING') && WP_INSTALLING) { 159 return false; 160 } 161 162 if (get_transient(self::COMPONENT_HEALTH_CHECK_TRANSIENT)) { 163 return false; 164 } 165 166 if (defined('WP_CLI') && WP_CLI) { 167 return true; 168 } 169 170 if (defined('DOING_CRON') && DOING_CRON) { 171 return true; 172 } 173 174 if (function_exists('wp_doing_ajax') && wp_doing_ajax()) { 175 return false; 176 } 177 178 return is_admin(); 179 } 180 181 protected function ensure_firewall_components(): bool 182 { 183 $firewallReady = FirewallService::ensureTable(); 184 $muLoader = FirewallService::installMuLoader(); 185 $vulnerabilityReady = VulnerabilityRiskService::ensureTables(); 186 187 return $firewallReady && !empty($muLoader['success']) && $vulnerabilityReady; 143 188 } 144 189 -
vulntitan/tags/2.1.17/includes/Services/CaptchaService.php
r3483537 r3495943 13 13 protected static bool $booted = false; 14 14 protected static bool $renderedWidget = false; 15 protected static array $commentRequestFieldOverrides = []; 15 16 16 17 public static function boot(): void … … 32 33 add_filter('registration_errors', [__CLASS__, 'verifyRegistrationCaptcha'], 20, 3); 33 34 add_action('lostpassword_post', [__CLASS__, 'verifyLostPasswordCaptcha'], 10, 2); 35 add_filter('preprocess_comment', [__CLASS__, 'clearCommentRequestContext'], 1); 36 add_filter('rest_preprocess_comment', [__CLASS__, 'captureRestCommentContext'], 5, 2); 34 37 add_filter('pre_comment_approved', [__CLASS__, 'verifyCommentCaptcha'], 8, 2); 35 38 } … … 174 177 175 178 if (!self::isContextEnabled(self::CONTEXT_COMMENT) || !self::isProviderConfigured()) { 176 return $approved;177 }178 179 if (defined('REST_REQUEST') && REST_REQUEST) {180 179 return $approved; 181 180 } … … 312 311 $field = $provider === 'turnstile' ? 'cf-turnstile-response' : 'h-captcha-response'; 313 312 313 if (array_key_exists($field, self::$commentRequestFieldOverrides)) { 314 return trim((string) self::$commentRequestFieldOverrides[$field]); 315 } 316 314 317 return trim((string) wp_unslash($_POST[$field] ?? $_REQUEST[$field] ?? '')); 318 } 319 320 /** 321 * @param array<string,mixed> $commentData 322 * @return array<string,mixed> 323 */ 324 public static function clearCommentRequestContext(array $commentData): array 325 { 326 self::$commentRequestFieldOverrides = []; 327 328 return $commentData; 329 } 330 331 /** 332 * @param array<string,mixed> $preparedComment 333 * @return array<string,mixed> 334 */ 335 public static function captureRestCommentContext(array $preparedComment, $request): array 336 { 337 self::$commentRequestFieldOverrides = self::extractRestCommentRequestFields($request); 338 339 return $preparedComment; 340 } 341 342 protected static function extractRestCommentRequestFields($request): array 343 { 344 if (!is_object($request) || !method_exists($request, 'get_param')) { 345 return []; 346 } 347 348 $fields = []; 349 foreach (['cf-turnstile-response', 'h-captcha-response'] as $field) { 350 $value = $request->get_param($field); 351 if (is_scalar($value) || $value === null) { 352 $fields[$field] = trim((string) $value); 353 } 354 } 355 356 return $fields; 315 357 } 316 358 -
vulntitan/tags/2.1.17/includes/Services/CommentSpamService.php
r3490735 r3495943 12 12 protected const RATE_LIMIT_PREFIX = 'vulntitan_cs_rate_'; 13 13 protected const MIN_TOKEN_AGE = 1; 14 protected const MAX_TOKEN_AGE = 2 * HOUR_IN_SECONDS; 15 protected const MAX_TOKEN_FUTURE_SKEW = 5 * MINUTE_IN_SECONDS; 14 16 protected const SHORT_COMMENT_MAX_WORDS = 3; 15 17 protected const SHORT_COMMENT_MAX_LENGTH = 40; … … 24 26 */ 25 27 protected static ?array $pendingDecision = null; 28 protected static array $requestFieldOverrides = []; 26 29 27 30 public static function boot(): void … … 50 53 $postId = (int) get_the_ID(); 51 54 } 55 if ($postId <= 0 && function_exists('get_queried_object_id')) { 56 $postId = (int) get_queried_object_id(); 57 } 52 58 53 59 echo '<p class="comment-form-vulntitan-honeypot" style="position:absolute;left:-9999px;top:auto;width:1px;height:1px;overflow:hidden;" aria-hidden="true">'; … … 60 66 public static function captureCommentDecision(array $commentData): array 61 67 { 68 self::$requestFieldOverrides = []; 62 69 self::$pendingDecision = self::evaluateDecision($commentData, false); 63 70 … … 71 78 public static function captureRestCommentDecision(array $preparedComment, $request): array 72 79 { 80 self::$requestFieldOverrides = self::extractRestRequestFields($request); 73 81 self::$pendingDecision = self::evaluateDecision($preparedComment, true); 74 82 … … 195 203 } 196 204 197 if (!$isRest) { 198 $tokenValidation = self::validateFormToken((string) self::readRequestField(self::TOKEN_FIELD), $postId); 199 if (!empty($tokenValidation['present']) && empty($tokenValidation['valid'])) { 200 return self::buildDecisionFromSuspiciousAction( 201 (string) ($settings['comment_suspicious_action'] ?? 'hold'), 202 'invalid_form_token', 203 __('Comment form token validation failed.', 'vulntitan'), 204 [ 205 'post_id' => $postId, 206 'rate_count' => $rateCount, 207 'window_minutes' => $rateWindowMinutes, 208 ], 209 [ 210 'comment_hash' => self::buildCommentHash($normalizedContent), 211 ] 212 ); 213 } 214 215 $minSeconds = max(self::MIN_TOKEN_AGE, (int) ($settings['submission_min_submit_seconds'] ?? $settings['comment_min_submit_seconds'] ?? 3)); 216 $tokenAge = (int) ($tokenValidation['age'] ?? 0); 217 if (!empty($tokenValidation['valid']) && $tokenAge > 0 && $tokenAge < $minSeconds) { 218 return self::buildDecisionFromSuspiciousAction( 219 (string) ($settings['comment_suspicious_action'] ?? 'hold'), 220 'submitted_too_fast', 221 __('Comment was submitted faster than the configured minimum time threshold.', 'vulntitan'), 222 [ 223 'post_id' => $postId, 224 'rate_count' => $rateCount, 225 'window_minutes' => $rateWindowMinutes, 226 'submitted_after_seconds' => $tokenAge, 227 'minimum_seconds' => $minSeconds, 228 ], 229 [ 230 'comment_hash' => self::buildCommentHash($normalizedContent), 231 ] 232 ); 233 } 205 $tokenValidation = self::validateFormToken((string) self::readRequestField(self::TOKEN_FIELD), $postId); 206 if ($isRest && !self::isAuthenticatedCommenter($commentData) && empty($tokenValidation['present'])) { 207 return self::buildDecisionFromSuspiciousAction( 208 (string) ($settings['comment_suspicious_action'] ?? 'hold'), 209 'missing_form_token', 210 __('REST comment request is missing the signed anti-spam token.', 'vulntitan'), 211 [ 212 'post_id' => $postId, 213 'rate_count' => $rateCount, 214 'window_minutes' => $rateWindowMinutes, 215 ], 216 [ 217 'comment_hash' => self::buildCommentHash($normalizedContent), 218 ] 219 ); 220 } 221 222 if (!empty($tokenValidation['present']) && empty($tokenValidation['valid'])) { 223 return self::buildDecisionFromSuspiciousAction( 224 (string) ($settings['comment_suspicious_action'] ?? 'hold'), 225 'invalid_form_token', 226 __('Comment form token validation failed.', 'vulntitan'), 227 [ 228 'post_id' => $postId, 229 'rate_count' => $rateCount, 230 'window_minutes' => $rateWindowMinutes, 231 ], 232 [ 233 'comment_hash' => self::buildCommentHash($normalizedContent), 234 ] 235 ); 236 } 237 238 $minSeconds = max(self::MIN_TOKEN_AGE, (int) ($settings['submission_min_submit_seconds'] ?? $settings['comment_min_submit_seconds'] ?? 3)); 239 $tokenAge = (int) ($tokenValidation['age'] ?? 0); 240 if (!empty($tokenValidation['valid']) && $tokenAge > 0 && $tokenAge < $minSeconds) { 241 return self::buildDecisionFromSuspiciousAction( 242 (string) ($settings['comment_suspicious_action'] ?? 'hold'), 243 'submitted_too_fast', 244 __('Comment was submitted faster than the configured minimum time threshold.', 'vulntitan'), 245 [ 246 'post_id' => $postId, 247 'rate_count' => $rateCount, 248 'window_minutes' => $rateWindowMinutes, 249 'submitted_after_seconds' => $tokenAge, 250 'minimum_seconds' => $minSeconds, 251 ], 252 [ 253 'comment_hash' => self::buildCommentHash($normalizedContent), 254 ] 255 ); 234 256 } 235 257 … … 886 908 $tokenPostId = (int) $postIdRaw; 887 909 $expected = wp_hash($timestampRaw . '|' . $postIdRaw . '|' . self::TOKEN_FIELD); 910 $now = time(); 911 $age = max(0, $now - $timestamp); 888 912 889 913 if ($timestamp <= 0 || $tokenPostId < 0 || !hash_equals($expected, $signature)) { … … 903 927 } 904 928 929 if ($timestamp > ($now + self::MAX_TOKEN_FUTURE_SKEW) || $age > self::getTokenMaxAge()) { 930 return [ 931 'present' => true, 932 'valid' => false, 933 'age' => $age, 934 ]; 935 } 936 905 937 return [ 906 938 'present' => true, 907 939 'valid' => true, 908 'age' => max(0, time() - $timestamp),940 'age' => $age, 909 941 ]; 910 942 } 911 943 944 protected static function getTokenMaxAge(): int 945 { 946 $maxAge = (int) apply_filters('vulntitan_comment_token_max_age', self::MAX_TOKEN_AGE); 947 948 return max(self::MIN_TOKEN_AGE, $maxAge); 949 } 950 912 951 protected static function readRequestField(string $field): string 913 952 { 953 if (array_key_exists($field, self::$requestFieldOverrides)) { 954 return trim((string) self::$requestFieldOverrides[$field]); 955 } 956 914 957 if (!isset($_REQUEST[$field])) { 915 958 return ''; … … 923 966 return trim((string) $value); 924 967 } 968 969 protected static function extractRestRequestFields($request): array 970 { 971 if (!is_object($request) || !method_exists($request, 'get_param')) { 972 return []; 973 } 974 975 $fields = []; 976 foreach ([self::TOKEN_FIELD, self::HONEYPOT_FIELD] as $field) { 977 $value = $request->get_param($field); 978 if (is_scalar($value) || $value === null) { 979 $fields[$field] = trim((string) $value); 980 } 981 } 982 983 return $fields; 984 } 925 985 } -
vulntitan/tags/2.1.17/includes/Services/ContactFormSpamService.php
r3485911 r3495943 9 9 protected const RATE_LIMIT_PREFIX = 'vulntitan_cf7_rate_'; 10 10 protected const MIN_TOKEN_AGE = 1; 11 protected const MAX_TOKEN_AGE = 2 * HOUR_IN_SECONDS; 12 protected const MAX_TOKEN_FUTURE_SKEW = 5 * MINUTE_IN_SECONDS; 11 13 12 14 protected static bool $booted = false; … … 558 560 $tokenFormId = (int) $formIdRaw; 559 561 $expected = wp_hash($timestampRaw . '|' . $formIdRaw . '|' . self::TOKEN_FIELD); 562 $now = time(); 563 $age = max(0, $now - $timestamp); 560 564 561 565 if ($timestamp <= 0 || $tokenFormId < 0 || !hash_equals($expected, $signature)) { … … 575 579 } 576 580 581 if ($timestamp > ($now + self::MAX_TOKEN_FUTURE_SKEW) || $age > self::getTokenMaxAge($formId)) { 582 return [ 583 'present' => true, 584 'valid' => false, 585 'age' => $age, 586 ]; 587 } 588 577 589 return [ 578 590 'present' => true, 579 591 'valid' => true, 580 'age' => max(0, time() - $timestamp),592 'age' => $age, 581 593 ]; 594 } 595 596 protected static function getTokenMaxAge(int $formId): int 597 { 598 $maxAge = (int) apply_filters('vulntitan_form_token_max_age', self::MAX_TOKEN_AGE, 'contact_form_7', $formId); 599 600 return max(self::MIN_TOKEN_AGE, $maxAge); 582 601 } 583 602 -
vulntitan/tags/2.1.17/includes/Services/FirewallService.php
r3490735 r3495943 1148 1148 $remoteAddr = isset($server['REMOTE_ADDR']) ? trim((string) $server['REMOTE_ADDR']) : ''; 1149 1149 $settings = self::getSettings(); 1150 $trustedProxies = self::getTrustedProxyIps($settings); 1151 $trustCloudflare = !empty($settings['trust_cloudflare']); 1152 $trustForwarded = self::isTrustedProxy($remoteAddr, $trustedProxies) 1153 || ($trustCloudflare && self::isCloudflareIp($remoteAddr)); 1150 $trustForwarded = self::shouldTrustForwardedHeaders($remoteAddr, $settings); 1154 1151 1155 1152 if ($trustForwarded) { … … 1199 1196 $trustCloudflare = !empty($settings['trust_cloudflare']); 1200 1197 $isCloudflare = self::isCloudflareIp($remoteAddr); 1201 $trustForwarded = self::isTrustedProxy($remoteAddr, $trustedProxies) 1202 || ($trustCloudflare && $isCloudflare); 1198 $trustForwarded = self::shouldTrustForwardedHeaders($remoteAddr, $settings); 1203 1199 1204 1200 return [ … … 1243 1239 } 1244 1240 1245 $trusted = array_filter(array_map('trim', $trusted), static function ($ip) { 1246 return $ip !== ''; 1247 }); 1248 1249 return array_values(array_unique($trusted)); 1241 return self::sanitizeTrustedProxyList($trusted); 1242 } 1243 1244 protected static function shouldTrustForwardedHeaders(string $remoteAddr, ?array $settings = null): bool 1245 { 1246 if ($settings === null) { 1247 $settings = self::getSettings(); 1248 } 1249 1250 $trustedProxies = self::getTrustedProxyIps($settings); 1251 if (self::isTrustedProxy($remoteAddr, $trustedProxies)) { 1252 return true; 1253 } 1254 1255 return !empty($settings['trust_cloudflare']) && self::isCloudflareIp($remoteAddr); 1250 1256 } 1251 1257 … … 1256 1262 } 1257 1263 1258 if (in_array($remoteAddr, $trustedProxies, true)) { 1259 return true; 1260 } 1261 1262 return self::isPrivateIp($remoteAddr); 1264 foreach ($trustedProxies as $trustedProxy) { 1265 $rule = self::sanitizeTrustedProxyRule((string) $trustedProxy); 1266 if ($rule === '') { 1267 continue; 1268 } 1269 1270 if (strpos($rule, '/') !== false) { 1271 if (self::ipInCidr($remoteAddr, $rule)) { 1272 return true; 1273 } 1274 1275 continue; 1276 } 1277 1278 if ($remoteAddr === $rule) { 1279 return true; 1280 } 1281 } 1282 1283 return false; 1263 1284 } 1264 1285 … … 1534 1555 ); 1535 1556 $lockoutAllowlist = self::sanitizeIpList($settings['lockout_allowlist_ips'] ?? $defaults['lockout_allowlist_ips']); 1536 $trustedProxies = self::sanitize IpList($settings['trusted_proxies'] ?? $defaults['trusted_proxies']);1557 $trustedProxies = self::sanitizeTrustedProxyList($settings['trusted_proxies'] ?? $defaults['trusted_proxies']); 1537 1558 $weeklySummaryRecipient = sanitize_email((string) ($settings['weekly_summary_email_recipient'] ?? $defaults['weekly_summary_email_recipient'])); 1538 1559 … … 1643 1664 } 1644 1665 1666 protected static function sanitizeTrustedProxyList($value): array 1667 { 1668 if (is_array($value)) { 1669 $rawItems = $value; 1670 } else { 1671 $rawItems = preg_split('/[\r\n,]+/', (string) $value) ?: []; 1672 } 1673 1674 $rules = []; 1675 1676 foreach ($rawItems as $item) { 1677 $rule = self::sanitizeTrustedProxyRule((string) $item); 1678 if ($rule === '') { 1679 continue; 1680 } 1681 1682 $rules[$rule] = $rule; 1683 } 1684 1685 return array_values($rules); 1686 } 1687 1688 protected static function sanitizeTrustedProxyRule(string $rule): string 1689 { 1690 $rule = trim($rule); 1691 if ($rule === '') { 1692 return ''; 1693 } 1694 1695 if (strpos($rule, '/') === false) { 1696 return self::sanitizeIp($rule); 1697 } 1698 1699 [$ip, $maskRaw] = explode('/', $rule, 2); 1700 $ip = self::sanitizeIp($ip); 1701 if ($ip === '' || !preg_match('/^\d{1,3}$/', $maskRaw)) { 1702 return ''; 1703 } 1704 1705 $mask = (int) $maskRaw; 1706 1707 if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) { 1708 if ($mask < 0 || $mask > 32) { 1709 return ''; 1710 } 1711 } elseif (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) { 1712 if ($mask < 0 || $mask > 128) { 1713 return ''; 1714 } 1715 } else { 1716 return ''; 1717 } 1718 1719 return $ip . '/' . $mask; 1720 } 1721 1645 1722 protected static function sanitizeRoleList($value, array $fallback): array 1646 1723 { -
vulntitan/tags/2.1.17/includes/Services/FluentFormSpamService.php
r3485911 r3495943 9 9 protected const RATE_LIMIT_PREFIX = 'vulntitan_fluentforms_rate_'; 10 10 protected const MIN_TOKEN_AGE = 1; 11 protected const MAX_TOKEN_AGE = 2 * HOUR_IN_SECONDS; 12 protected const MAX_TOKEN_FUTURE_SKEW = 5 * MINUTE_IN_SECONDS; 11 13 protected const BLOCK_MESSAGE = 'Your submission was blocked because the system detected spam or abuse. Remove excessive links and try again.'; 12 14 … … 629 631 $tokenFormId = (int) $formIdRaw; 630 632 $expected = wp_hash($timestampRaw . '|' . $formIdRaw . '|' . self::TOKEN_FIELD); 633 $now = time(); 634 $age = max(0, $now - $timestamp); 631 635 632 636 if ($timestamp <= 0 || $tokenFormId < 0 || !hash_equals($expected, $signature)) { … … 646 650 } 647 651 652 if ($timestamp > ($now + self::MAX_TOKEN_FUTURE_SKEW) || $age > self::getTokenMaxAge($formId)) { 653 return [ 654 'present' => true, 655 'valid' => false, 656 'age' => $age, 657 ]; 658 } 659 648 660 return [ 649 661 'present' => true, 650 662 'valid' => true, 651 'age' => max(0, time() - $timestamp),663 'age' => $age, 652 664 ]; 665 } 666 667 protected static function getTokenMaxAge(int $formId): int 668 { 669 $maxAge = (int) apply_filters('vulntitan_form_token_max_age', self::MAX_TOKEN_AGE, 'fluent_forms', $formId); 670 671 return max(self::MIN_TOKEN_AGE, $maxAge); 653 672 } 654 673 -
vulntitan/tags/2.1.17/includes/Services/LoginSecurityService.php
r3483537 r3495943 22 22 protected const CHALLENGE_TTL = 10 * MINUTE_IN_SECONDS; 23 23 protected const RECOVERY_PREVIEW_TTL = 15 * MINUTE_IN_SECONDS; 24 protected const ATTEMPT_PREFIX = 'vulntitan_2fa_attempt_'; 25 protected const BLOCK_PREFIX = 'vulntitan_2fa_block_'; 26 protected const MAX_CHALLENGE_ATTEMPTS = 5; 27 protected const ATTEMPT_WINDOW = 10 * MINUTE_IN_SECONDS; 28 protected const BLOCK_TTL = 10 * MINUTE_IN_SECONDS; 24 29 protected static bool $booted = false; 25 30 … … 108 113 } 109 114 115 $rateLimit = self::getTwoFactorRateLimitState($user->ID, FirewallService::detectClientIp()); 116 if (!empty($rateLimit['blocked'])) { 117 return new WP_Error( 118 'vulntitan_2fa_rate_limited', 119 self::getTwoFactorRateLimitMessage((int) ($rateLimit['remaining_seconds'] ?? self::BLOCK_TTL)) 120 ); 121 } 122 110 123 $challengeId = self::createChallenge($user); 111 124 $redirectUrl = add_query_arg([ … … 144 157 } 145 158 159 $ipAddress = FirewallService::detectClientIp(); 160 $rateLimit = self::getTwoFactorRateLimitState($user->ID, $ipAddress); 161 if (!empty($rateLimit['blocked'])) { 162 self::deleteChallenge($challengeId); 163 self::renderChallengePage( 164 __('Two-Factor Authentication', 'vulntitan'), 165 self::getTwoFactorRateLimitMessage((int) ($rateLimit['remaining_seconds'] ?? self::BLOCK_TTL)), 166 [], 167 true 168 ); 169 } 170 146 171 if (strtoupper((string) ($_SERVER['REQUEST_METHOD'] ?? 'GET')) === 'POST') { 147 172 $nonce = isset($_POST[self::CHALLENGE_NONCE]) ? (string) wp_unslash($_POST[self::CHALLENGE_NONCE]) : ''; … … 161 186 if ($isValid) { 162 187 self::deleteChallenge($challengeId); 188 self::clearTwoFactorRateLimitState($user->ID, $ipAddress); 163 189 self::finalizeAuthenticatedLogin( 164 190 $user, … … 181 207 'reason' => 'Invalid two-factor verification code.', 182 208 ]); 209 210 $rateLimit = self::recordFailedTwoFactorAttempt($user, $ipAddress); 211 if (!empty($rateLimit['blocked'])) { 212 self::deleteChallenge($challengeId); 213 self::renderChallengePage( 214 __('Two-Factor Authentication', 'vulntitan'), 215 self::getTwoFactorRateLimitMessage((int) ($rateLimit['remaining_seconds'] ?? self::BLOCK_TTL)), 216 [], 217 true 218 ); 219 } 183 220 184 221 $errorMessage = __('The verification code was invalid. Please try again.', 'vulntitan'); … … 625 662 } 626 663 664 protected static function getTwoFactorRateLimitState(int $userId, string $ipAddress): array 665 { 666 $blockedUntil = (int) get_transient(self::getTwoFactorBlockKey($userId, $ipAddress)); 667 if ($blockedUntil > time()) { 668 return [ 669 'blocked' => true, 670 'remaining_seconds' => max(1, $blockedUntil - time()), 671 ]; 672 } 673 674 if ($blockedUntil > 0) { 675 delete_transient(self::getTwoFactorBlockKey($userId, $ipAddress)); 676 } 677 678 return [ 679 'blocked' => false, 680 'remaining_seconds' => 0, 681 ]; 682 } 683 684 protected static function recordFailedTwoFactorAttempt(WP_User $user, string $ipAddress): array 685 { 686 $attemptKey = self::getTwoFactorAttemptKey($user->ID, $ipAddress); 687 $data = get_transient($attemptKey); 688 689 if (!is_array($data)) { 690 $data = [ 691 'count' => 0, 692 'started_at' => time(), 693 ]; 694 } 695 696 $data['count'] = max(0, (int) ($data['count'] ?? 0)) + 1; 697 $data['started_at'] = (int) ($data['started_at'] ?? time()); 698 set_transient($attemptKey, $data, self::ATTEMPT_WINDOW); 699 700 if ((int) $data['count'] < self::MAX_CHALLENGE_ATTEMPTS) { 701 return [ 702 'blocked' => false, 703 'remaining_seconds' => 0, 704 ]; 705 } 706 707 $blockedUntil = time() + self::BLOCK_TTL; 708 set_transient(self::getTwoFactorBlockKey($user->ID, $ipAddress), $blockedUntil, self::BLOCK_TTL); 709 delete_transient($attemptKey); 710 711 FirewallService::logEvent('login_blocked', [ 712 'ip_address' => $ipAddress, 713 'username' => $user->user_login, 714 'user_id' => $user->ID, 715 'event_source' => 'login_security', 716 'rule_group' => 'two_factor', 717 'rule_id' => 'totp_rate_limited', 718 'reason' => 'Too many invalid two-factor verification attempts.', 719 'details' => [ 720 'attempts' => (int) $data['count'], 721 'retry_after_seconds' => self::BLOCK_TTL, 722 ], 723 ]); 724 725 return [ 726 'blocked' => true, 727 'remaining_seconds' => self::BLOCK_TTL, 728 ]; 729 } 730 731 protected static function clearTwoFactorRateLimitState(int $userId, string $ipAddress): void 732 { 733 delete_transient(self::getTwoFactorAttemptKey($userId, $ipAddress)); 734 delete_transient(self::getTwoFactorBlockKey($userId, $ipAddress)); 735 } 736 737 protected static function getTwoFactorAttemptKey(int $userId, string $ipAddress): string 738 { 739 return self::ATTEMPT_PREFIX . md5($userId . '|' . ($ipAddress !== '' ? $ipAddress : 'unknown')); 740 } 741 742 protected static function getTwoFactorBlockKey(int $userId, string $ipAddress): string 743 { 744 return self::BLOCK_PREFIX . md5($userId . '|' . ($ipAddress !== '' ? $ipAddress : 'unknown')); 745 } 746 747 protected static function getTwoFactorRateLimitMessage(int $remainingSeconds): string 748 { 749 return sprintf( 750 __('Too many two-factor verification attempts. Try again in %d minute(s).', 'vulntitan'), 751 max(1, (int) ceil($remainingSeconds / MINUTE_IN_SECONDS)) 752 ); 753 } 754 627 755 protected static function renderChallengePage(string $title, string $message = '', array $context = [], bool $isFatal = false): void 628 756 { -
vulntitan/tags/2.1.17/readme.txt
r3490735 r3495943 4 4 Tested up to: 6.9 5 5 Requires PHP: 7.4 6 Stable tag: 2.1.1 66 Stable tag: 2.1.17 7 7 License: GPLv2 8 8 License URI: https://www.gnu.org/licenses/gpl-2.0.html … … 60 60 - XML-RPC allow, disable, or rate-limit policy controls with IP allowlisting 61 61 - Weak-password blocking during profile updates, password resets, and compatible registrations 62 - Comment Shield with honeypot, s ubmit-time validation, duplicate detection, guest link limits, and IP rate limiting62 - Comment Shield with honeypot, signed tokens, submit-time validation, duplicate detection, guest link limits, IP rate limiting, and moderation-aware logging 63 63 - Form Shield for Contact Form 7 and Fluent Forms with honeypot, signed submit tokens, link heuristics, repeated-domain detection, and IP rate limiting 64 64 - Form spam blocks are logged into the WAF/live feed with provider-aware source labels for easier review 65 65 - Suspicious comments can be held for moderation or blocked immediately 66 - REST comments can enforce signed anti-spam tokens and CAPTCHA when anonymous REST commenting is enabled elsewhere 66 67 - Configurable custom login slug so administrators can use a private login URL instead of the default `wp-login.php` 67 68 - Default `wp-login.php` and guest `wp-admin` access can be hidden behind a `404` response when custom login is enabled 68 - Weekly executive security report email with 7-day firewall, login abuse, WAF, and comment spamstatistics69 - Weekly executive security report email with 7-day firewall, login abuse, WAF, form spam, and comment moderation statistics 69 70 70 71 = Security-First Architecture = … … 72 73 - Secure storage and cleanup of scan queues and logs 73 74 - Hardened backup handling outside `ABSPATH` by default 75 - Hardened malware and integrity scan actions with stricter capability checks and in-root path validation 74 76 - Adaptive performance tuning for safe large-site scanning 75 77 … … 174 176 175 177 == Changelog == 178 179 = v2.1.17 - 31 Mar, 2026 = 180 * Hardened malware and integrity scan actions with stricter capability checks, boundary-safe path validation, and server-side verification of auto-fix targets. 181 * Closed the conditional REST comment bypass by enforcing signed anti-spam tokens and comment CAPTCHA on REST comment submissions as well. 182 * Added stronger 2FA challenge throttling, tighter proxy trust handling, bounded anti-spam token lifetimes, and reduced hot-path maintenance overhead. 183 * Expanded release metadata and readme coverage for comment moderation, digest reporting, and hardening updates. 176 184 177 185 = v2.1.16 - 25 Mar, 2026 = -
vulntitan/tags/2.1.17/vulntitan.php
r3490735 r3495943 4 4 * Plugin URI: https://vulntitan.com/vulntitan/ 5 5 * Description: VulnTitan is a WordPress security plugin with vulnerability scanning, malware detection, file integrity monitoring, comment and form anti-spam protection, and a built-in firewall with WAF payload rules and login protection. 6 * Version: 2.1.1 66 * Version: 2.1.17 7 7 * Author: Jaroslav Svetlik 8 8 * Author URI: https://vulntitan.com … … 30 30 31 31 // Define plugin constants 32 define('VULNTITAN_PLUGIN_VERSION', VULNTITAN_DEVELOPMENT ? uniqid() : '2.1.1 6');32 define('VULNTITAN_PLUGIN_VERSION', VULNTITAN_DEVELOPMENT ? uniqid() : '2.1.17'); 33 33 define('VULNTITAN_PLUGIN_BASENAME', plugin_basename(__FILE__)); 34 34 define('VULNTITAN_PLUGIN_DIR', untrailingslashit(plugin_dir_path(__FILE__))); -
vulntitan/trunk/CHANGELOG.md
r3490735 r3495943 5 5 The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), 6 6 and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 8 ## [2.1.17] - 2026-03-31 9 ### Added 10 - Logged comments that land in the WordPress moderation queue into the firewall activity stream and weekly digest reporting. 11 - Expanded the weekly digest protection profile and activity summaries to cover comment moderation and form-spam signals. 12 13 ### Changed 14 - Refined the weekly digest email layout so two-column sections collapse cleanly on mobile screens. 15 16 ### Security 17 - Hardened malware and integrity scan actions with stricter capability checks, safer in-root path validation, and server-side verification of auto-fix targets. 18 - Enforced signed anti-spam tokens and CAPTCHA handling on REST comment submissions when anonymous REST comments are exposed elsewhere. 19 - Tightened trusted proxy handling, bounded anti-spam token lifetimes, rate-limited 2FA challenge attempts, and moved maintenance self-heal work off the hot request path. 7 20 8 21 ## [2.1.16] - 2026-03-25 -
vulntitan/trunk/assets/js/malware-scanner.js
r3483103 r3495943 325 325 $feedback.text('').css('color', '#94a3b8'); 326 326 327 const patterns = Array.isArray(finding.patterns) ? finding.patterns.join(', ') : (finding.type || '');328 327 $.post(ajaxurl, { 329 328 action: 'vulntitan_malware_fix_finding', 330 329 nonce: VulnTitan.nonce, 331 330 file_path: item.file || '', 332 line: lineNumber, 333 expected_code: finding.code || '', 334 patterns: patterns 331 line: lineNumber 335 332 }, function (response) { 336 333 if (response && response.success) { -
vulntitan/trunk/assets/js/malware-scanner.min.js
r3483103 r3495943 325 325 $feedback.text('').css('color', '#94a3b8'); 326 326 327 const patterns = Array.isArray(finding.patterns) ? finding.patterns.join(', ') : (finding.type || '');328 327 $.post(ajaxurl, { 329 328 action: 'vulntitan_malware_fix_finding', 330 329 nonce: VulnTitan.nonce, 331 330 file_path: item.file || '', 332 line: lineNumber, 333 expected_code: finding.code || '', 334 patterns: patterns 331 line: lineNumber 335 332 }, function (response) { 336 333 if (response && response.success) { -
vulntitan/trunk/includes/Admin/Ajax.php
r3485911 r3495943 13 13 class Ajax 14 14 { 15 protected const FILE_OPERATIONS_CAPABILITY_FILTER = 'vulntitan_file_operations_capability'; 16 15 17 public function register(): void 16 18 { … … 617 619 check_ajax_referer('vulntitan_ajax_scan', 'nonce'); 618 620 619 if (!current_user_can('manage_options')) { 620 wp_send_json_error(['message' => esc_html__('Insufficient permissions.', 'vulntitan')], 403); 621 } 621 $this->requireFileOperationsCapability(); 622 622 623 623 $scanner = new MalwareScanner(get_option('vulntitan_malware_scan_scope', 'all')); … … 634 634 check_ajax_referer('vulntitan_ajax_scan', 'nonce'); 635 635 636 if (!current_user_can('manage_options')) { 637 wp_send_json_error(['message' => esc_html__('Insufficient permissions.', 'vulntitan')], 403); 638 } 639 640 $file = realpath( sanitize_text_field( wp_unslash( $_POST['file_path'] ?? '' ) ) ); 641 642 if (!$file || strpos($file, ABSPATH) !== 0 || !file_exists($file)) { 636 $this->requireFileOperationsCapability(); 637 638 $resolved = $this->resolveFilePathWithinWordPress((string) wp_unslash($_POST['file_path'] ?? '')); 639 if ($resolved === null) { 643 640 wp_send_json_error(['message' => esc_html__('Invalid or missing file.', 'vulntitan')]); 644 641 } … … 646 643 try { 647 644 $scanner = new MalwareScanner(get_option('vulntitan_malware_scan_scope', 'all')); 648 $result = $scanner->scanSingleFile($ file);645 $result = $scanner->scanSingleFile($resolved['absolute']); 649 646 650 647 wp_send_json_success([ … … 665 662 check_ajax_referer('vulntitan_ajax_scan', 'nonce'); 666 663 667 if (!current_user_can('manage_options')) { 668 wp_send_json_error(['message' => esc_html__('Insufficient permissions.', 'vulntitan')], 403); 669 } 664 $this->requireFileOperationsCapability(); 670 665 671 666 $filePaths = $_POST['file_paths'] ?? []; … … 679 674 680 675 foreach ($filePaths as $inputPath) { 681 // Normalize: if path is absolute, convert it to relative 682 if (strpos($inputPath, ABSPATH) === 0) { 683 $relativePath = ltrim(str_replace(ABSPATH, '', $inputPath), '/\\'); 684 } else { 685 $relativePath = ltrim($inputPath, '/\\'); 686 } 687 688 $absolutePath = ABSPATH . $relativePath; 689 690 // Ensure the resolved path stays within WP directory (security check) 691 $realAbsolute = realpath($absolutePath); 692 $realBase = realpath(ABSPATH); 693 694 if (!$realAbsolute || strpos($realAbsolute, $realBase) !== 0) { 676 $resolved = $this->resolveFilePathWithinWordPress((string) $inputPath); 677 678 if ($resolved === null) { 695 679 $results[] = [ 696 'file' => $relativePath,680 'file' => ltrim(str_replace('\\', '/', (string) $inputPath), '/'), 697 681 'status' => 'skipped', 698 682 'skipped' => true, … … 701 685 continue; 702 686 } 703 704 if (!file_exists($realAbsolute)) { 687 688 try { 689 $findings = $scanner->scanSingleFile($resolved['absolute']); 705 690 $results[] = [ 706 'file' => $relativePath, 707 'status' => 'skipped', 708 'skipped' => true, 709 'reason' => esc_html__('File not found.', 'vulntitan'), 710 ]; 711 continue; 712 } 713 714 try { 715 $findings = $scanner->scanSingleFile($realAbsolute); 716 $results[] = [ 717 'file' => $relativePath, 691 'file' => $resolved['relative'], 718 692 'status' => !empty($findings) ? 'infected' : 'clean', 719 693 'findings' => $findings, … … 721 695 } catch (Throwable $e) { 722 696 $results[] = [ 723 'file' => $re lativePath,697 'file' => $resolved['relative'], 724 698 'status' => 'error', 725 699 'error' => $e->getMessage(), … … 739 713 check_ajax_referer('vulntitan_ajax_scan', 'nonce'); 740 714 741 if (!current_user_can('manage_options')) { 742 wp_send_json_error(['message' => esc_html__('Insufficient permissions.', 'vulntitan')], 403); 743 } 715 $this->requireFileOperationsCapability(); 744 716 745 717 $filePath = sanitize_text_field(wp_unslash($_POST['file_path'] ?? '')); 746 718 $lineNumber = isset($_POST['line']) ? (int) $_POST['line'] : 0; 747 $expectedCodeRaw = wp_unslash($_POST['expected_code'] ?? '');748 $patternsRaw = wp_unslash($_POST['patterns'] ?? '');749 750 $expectedCode = is_string($expectedCodeRaw) ? trim(substr($expectedCodeRaw, 0, 2000)) : '';751 $patterns = is_string($patternsRaw) ? trim(substr($patternsRaw, 0, 300)) : '';752 719 753 720 if ($filePath === '' || $lineNumber < 1) { … … 755 722 } 756 723 757 if (!$this->isFixablePatternList($patterns)) { 758 wp_send_json_error(['message' => esc_html__('Auto-fix is disabled for low-risk findings.', 'vulntitan')]); 759 } 760 761 $relativePath = ltrim(str_replace('\\', '/', $filePath), '/'); 762 $absolutePath = ABSPATH . $relativePath; 763 $realAbsolute = realpath($absolutePath); 764 $realBase = realpath(ABSPATH); 765 766 if (!$realAbsolute || !$realBase || strpos($realAbsolute, $realBase) !== 0 || !is_file($realAbsolute)) { 724 $resolved = $this->resolveFilePathWithinWordPress($filePath); 725 if ($resolved === null) { 767 726 wp_send_json_error(['message' => esc_html__('Invalid or unsafe file path.', 'vulntitan')]); 727 } 728 729 $relativePath = $resolved['relative']; 730 $realAbsolute = $resolved['absolute']; 731 $finding = $this->getFixableMalwareFinding($realAbsolute, $lineNumber); 732 if ($finding === null) { 733 wp_send_json_error(['message' => esc_html__('Auto-fix requires a current high-risk finding on the selected line.', 'vulntitan')]); 768 734 } 769 735 … … 796 762 $updatedLine = $originalLine; 797 763 $appliedMode = 'quarantine_line'; 764 $expectedCode = trim(substr((string) ($finding['code'] ?? ''), 0, 2000)); 765 $patterns = $this->normalizeFixablePatternList($finding['patterns'] ?? []); 798 766 $normalizedExpectedCode = str_replace(["\r", "\n"], '', $expectedCode); 799 767 … … 953 921 } 954 922 923 protected function getFixableMalwareFinding(string $absolutePath, int $lineNumber): ?array 924 { 925 try { 926 $scanner = new MalwareScanner(get_option('vulntitan_malware_scan_scope', 'all')); 927 $findings = $scanner->scanSingleFile($absolutePath); 928 } catch (Throwable $e) { 929 return null; 930 } 931 932 foreach ($findings as $finding) { 933 if (!is_array($finding) || (int) ($finding['line'] ?? 0) !== $lineNumber) { 934 continue; 935 } 936 937 $patterns = $this->normalizeFixablePatternList($finding['patterns'] ?? []); 938 if (!$this->isFixablePatternList($patterns)) { 939 continue; 940 } 941 942 return $finding; 943 } 944 945 return null; 946 } 947 948 /** 949 * @param mixed $patterns 950 */ 951 protected function normalizeFixablePatternList($patterns): string 952 { 953 if (!is_array($patterns)) { 954 return ''; 955 } 956 957 $items = []; 958 foreach ($patterns as $pattern) { 959 $value = sanitize_key((string) $pattern); 960 if ($value !== '') { 961 $items[] = $value; 962 } 963 } 964 965 return implode(', ', array_values(array_unique($items))); 966 } 967 955 968 protected function isFixablePatternList(string $patterns): bool 956 969 { … … 978 991 } 979 992 993 protected function requireFileOperationsCapability(): void 994 { 995 if ($this->currentUserCanManageFileOperations()) { 996 return; 997 } 998 999 wp_send_json_error(['message' => esc_html__('Insufficient permissions.', 'vulntitan')], 403); 1000 } 1001 1002 protected function currentUserCanManageFileOperations(): bool 1003 { 1004 $defaultCapability = is_multisite() ? 'manage_network_plugins' : 'update_plugins'; 1005 $capability = apply_filters(self::FILE_OPERATIONS_CAPABILITY_FILTER, $defaultCapability); 1006 $capability = is_string($capability) && $capability !== '' ? $capability : $defaultCapability; 1007 1008 return current_user_can($capability); 1009 } 1010 1011 protected function resolveFilePathWithinWordPress(string $inputPath): ?array 1012 { 1013 $inputPath = trim($inputPath); 1014 if ($inputPath === '') { 1015 return null; 1016 } 1017 1018 $basePath = realpath(ABSPATH); 1019 if (!$basePath) { 1020 return null; 1021 } 1022 1023 $normalizedBase = untrailingslashit(wp_normalize_path($basePath)); 1024 $normalizedInput = wp_normalize_path($inputPath); 1025 1026 if ($normalizedInput === $normalizedBase || strpos($normalizedInput, $normalizedBase . '/') === 0) { 1027 $relativePath = ltrim(substr($normalizedInput, strlen($normalizedBase)), '/'); 1028 } else { 1029 $relativePath = ltrim(str_replace('\\', '/', $inputPath), '/'); 1030 } 1031 1032 if ($relativePath === '') { 1033 return null; 1034 } 1035 1036 $absolutePath = realpath(trailingslashit($basePath) . $relativePath); 1037 if (!$absolutePath || !is_file($absolutePath) || !$this->isPathInsideBase($absolutePath, $basePath)) { 1038 return null; 1039 } 1040 1041 $normalizedAbsolute = untrailingslashit(wp_normalize_path($absolutePath)); 1042 1043 return [ 1044 'absolute' => $absolutePath, 1045 'relative' => ltrim(substr($normalizedAbsolute, strlen($normalizedBase)), '/'), 1046 ]; 1047 } 1048 1049 protected function isPathInsideBase(string $path, string $basePath): bool 1050 { 1051 $normalizedPath = untrailingslashit(wp_normalize_path($path)); 1052 $normalizedBase = untrailingslashit(wp_normalize_path($basePath)); 1053 1054 return $normalizedPath === $normalizedBase || strpos($normalizedPath, $normalizedBase . '/') === 0; 1055 } 1056 980 1057 public function integrityScanInit(): void 981 1058 { 982 1059 check_ajax_referer('vulntitan_ajax_scan', 'nonce'); 983 1060 984 if (!current_user_can('manage_options')) { 985 wp_send_json_error(['message' => esc_html__('Insufficient permissions.', 'vulntitan')], 403); 986 } 1061 $this->requireFileOperationsCapability(); 987 1062 988 1063 if (!BaselineBuilder::exists()) { … … 1024 1099 check_ajax_referer('vulntitan_ajax_scan', 'nonce'); 1025 1100 1026 if (!current_user_can('manage_options')) { 1027 wp_send_json_error(['message' => esc_html__('Insufficient permissions.', 'vulntitan')], 403); 1028 } 1029 1030 $file = sanitize_text_field(wp_unslash($_POST['file_path'] ?? '')); 1031 if ($file === '') { 1032 wp_send_json_error(['message' => esc_html__('File not found.', 'vulntitan')]); 1033 } 1034 1035 if (strpos($file, ABSPATH) === 0) { 1036 $relative = ltrim(str_replace(ABSPATH, '', $file), '/\\'); 1037 } else { 1038 $relative = ltrim($file, '/\\'); 1039 } 1040 1041 $absolutePath = ABSPATH . $relative; 1042 $realAbsolute = realpath($absolutePath); 1043 $realBase = realpath(ABSPATH); 1044 1045 if (!$realAbsolute || !$realBase || strpos($realAbsolute, $realBase) !== 0 || !file_exists($realAbsolute)) { 1101 $this->requireFileOperationsCapability(); 1102 1103 $resolved = $this->resolveFilePathWithinWordPress((string) wp_unslash($_POST['file_path'] ?? '')); 1104 if ($resolved === null) { 1046 1105 wp_send_json_error(['message' => esc_html__('File not found.', 'vulntitan')]); 1047 1106 } … … 1052 1111 $scanner->loadBaseline(); 1053 1112 1054 $relativePath = ltrim(str_replace($realBase, '', $realAbsolute), '/\\'); 1055 $result = $scanner->scanSingleFile($relativePath); 1113 $result = $scanner->scanSingleFile($resolved['relative']); 1056 1114 1057 1115 wp_send_json_success([ … … 1071 1129 $startedAt = microtime(true); 1072 1130 1073 if (!current_user_can('manage_options')) { 1074 wp_send_json_error(['message' => esc_html__('Insufficient permissions.', 'vulntitan')], 403); 1075 } 1131 $this->requireFileOperationsCapability(); 1076 1132 1077 1133 $queueId = sanitize_text_field( wp_unslash( $_POST['queue_id'] ?? '' ) ); -
vulntitan/trunk/includes/Admin/Pages/Firewall.php
r3486040 r3495943 208 208 <span class="vulntitan-firewall-field-label"><?php esc_html_e('Trusted Proxy IPs', 'vulntitan'); ?></span> 209 209 <textarea id="vulntitan-firewall-trusted-proxies" class="vulntitan-firewall-textarea" rows="4" placeholder="203.0.113.10"></textarea> 210 <small class="vulntitan-firewall-field-help"><?php esc_html_e('Add one IP per line or comma-separated. Only these proxies will be trusted for X-Forwarded-For / X-Real-IP headers.', 'vulntitan'); ?></small> 210 <small class="vulntitan-firewall-field-help"><?php esc_html_e('Add one IP or CIDR per line or comma-separated. Only these proxies will be trusted for X-Forwarded-For / X-Real-IP headers.', 'vulntitan'); ?></small> 211 <small class="vulntitan-firewall-field-help"><?php esc_html_e('Private proxy addresses are not trusted automatically. List them here if your reverse proxy or load balancer terminates traffic on a private network.', 'vulntitan'); ?></small> 211 212 <small class="vulntitan-firewall-field-help"><?php esc_html_e('If you use Cloudflare, enable the toggle above (no manual IP list needed).', 'vulntitan'); ?></small> 212 213 </label> -
vulntitan/trunk/includes/Plugin.php
r3485911 r3495943 23 23 protected const FIREWALL_LOG_CLEANUP_HOOK = 'vulntitan_cleanup_firewall_logs'; 24 24 protected const WEEKLY_SUMMARY_EMAIL_HOOK = 'vulntitan_send_weekly_summary_email'; 25 protected const COMPONENT_HEALTH_CHECK_TRANSIENT = 'vulntitan_component_health_check'; 26 protected const COMPONENT_HEALTH_CHECK_TTL = 12 * HOUR_IN_SECONDS; 27 protected const COMPONENT_HEALTH_CHECK_RETRY_TTL = 15 * MINUTE_IN_SECONDS; 25 28 26 29 public $admin; … … 29 32 { 30 33 $this->maybe_generate_baseline(); 31 $this-> ensure_firewall_components();34 $this->maybeEnsureFirewallComponents(); 32 35 $this->register_scheduled_events(); 33 36 $this->register_cli_commands(); … … 59 62 wp_clear_scheduled_hook(self::FIREWALL_LOG_CLEANUP_HOOK); 60 63 wp_clear_scheduled_hook(self::WEEKLY_SUMMARY_EMAIL_HOOK); 64 delete_transient(self::COMPONENT_HEALTH_CHECK_TRANSIENT); 61 65 FirewallService::removeMuLoader(); 62 66 } … … 136 140 } 137 141 138 protected function ensure_firewall_components(): void 139 { 140 FirewallService::ensureTable(); 141 FirewallService::installMuLoader(); 142 VulnerabilityRiskService::ensureTables(); 142 protected function maybeEnsureFirewallComponents(): void 143 { 144 if (!$this->shouldRunComponentHealthCheck()) { 145 return; 146 } 147 148 $success = $this->ensure_firewall_components(); 149 set_transient( 150 self::COMPONENT_HEALTH_CHECK_TRANSIENT, 151 $success ? 'ok' : 'retry', 152 $success ? self::COMPONENT_HEALTH_CHECK_TTL : self::COMPONENT_HEALTH_CHECK_RETRY_TTL 153 ); 154 } 155 156 protected function shouldRunComponentHealthCheck(): bool 157 { 158 if (defined('WP_INSTALLING') && WP_INSTALLING) { 159 return false; 160 } 161 162 if (get_transient(self::COMPONENT_HEALTH_CHECK_TRANSIENT)) { 163 return false; 164 } 165 166 if (defined('WP_CLI') && WP_CLI) { 167 return true; 168 } 169 170 if (defined('DOING_CRON') && DOING_CRON) { 171 return true; 172 } 173 174 if (function_exists('wp_doing_ajax') && wp_doing_ajax()) { 175 return false; 176 } 177 178 return is_admin(); 179 } 180 181 protected function ensure_firewall_components(): bool 182 { 183 $firewallReady = FirewallService::ensureTable(); 184 $muLoader = FirewallService::installMuLoader(); 185 $vulnerabilityReady = VulnerabilityRiskService::ensureTables(); 186 187 return $firewallReady && !empty($muLoader['success']) && $vulnerabilityReady; 143 188 } 144 189 -
vulntitan/trunk/includes/Services/CaptchaService.php
r3483537 r3495943 13 13 protected static bool $booted = false; 14 14 protected static bool $renderedWidget = false; 15 protected static array $commentRequestFieldOverrides = []; 15 16 16 17 public static function boot(): void … … 32 33 add_filter('registration_errors', [__CLASS__, 'verifyRegistrationCaptcha'], 20, 3); 33 34 add_action('lostpassword_post', [__CLASS__, 'verifyLostPasswordCaptcha'], 10, 2); 35 add_filter('preprocess_comment', [__CLASS__, 'clearCommentRequestContext'], 1); 36 add_filter('rest_preprocess_comment', [__CLASS__, 'captureRestCommentContext'], 5, 2); 34 37 add_filter('pre_comment_approved', [__CLASS__, 'verifyCommentCaptcha'], 8, 2); 35 38 } … … 174 177 175 178 if (!self::isContextEnabled(self::CONTEXT_COMMENT) || !self::isProviderConfigured()) { 176 return $approved;177 }178 179 if (defined('REST_REQUEST') && REST_REQUEST) {180 179 return $approved; 181 180 } … … 312 311 $field = $provider === 'turnstile' ? 'cf-turnstile-response' : 'h-captcha-response'; 313 312 313 if (array_key_exists($field, self::$commentRequestFieldOverrides)) { 314 return trim((string) self::$commentRequestFieldOverrides[$field]); 315 } 316 314 317 return trim((string) wp_unslash($_POST[$field] ?? $_REQUEST[$field] ?? '')); 318 } 319 320 /** 321 * @param array<string,mixed> $commentData 322 * @return array<string,mixed> 323 */ 324 public static function clearCommentRequestContext(array $commentData): array 325 { 326 self::$commentRequestFieldOverrides = []; 327 328 return $commentData; 329 } 330 331 /** 332 * @param array<string,mixed> $preparedComment 333 * @return array<string,mixed> 334 */ 335 public static function captureRestCommentContext(array $preparedComment, $request): array 336 { 337 self::$commentRequestFieldOverrides = self::extractRestCommentRequestFields($request); 338 339 return $preparedComment; 340 } 341 342 protected static function extractRestCommentRequestFields($request): array 343 { 344 if (!is_object($request) || !method_exists($request, 'get_param')) { 345 return []; 346 } 347 348 $fields = []; 349 foreach (['cf-turnstile-response', 'h-captcha-response'] as $field) { 350 $value = $request->get_param($field); 351 if (is_scalar($value) || $value === null) { 352 $fields[$field] = trim((string) $value); 353 } 354 } 355 356 return $fields; 315 357 } 316 358 -
vulntitan/trunk/includes/Services/CommentSpamService.php
r3490735 r3495943 12 12 protected const RATE_LIMIT_PREFIX = 'vulntitan_cs_rate_'; 13 13 protected const MIN_TOKEN_AGE = 1; 14 protected const MAX_TOKEN_AGE = 2 * HOUR_IN_SECONDS; 15 protected const MAX_TOKEN_FUTURE_SKEW = 5 * MINUTE_IN_SECONDS; 14 16 protected const SHORT_COMMENT_MAX_WORDS = 3; 15 17 protected const SHORT_COMMENT_MAX_LENGTH = 40; … … 24 26 */ 25 27 protected static ?array $pendingDecision = null; 28 protected static array $requestFieldOverrides = []; 26 29 27 30 public static function boot(): void … … 50 53 $postId = (int) get_the_ID(); 51 54 } 55 if ($postId <= 0 && function_exists('get_queried_object_id')) { 56 $postId = (int) get_queried_object_id(); 57 } 52 58 53 59 echo '<p class="comment-form-vulntitan-honeypot" style="position:absolute;left:-9999px;top:auto;width:1px;height:1px;overflow:hidden;" aria-hidden="true">'; … … 60 66 public static function captureCommentDecision(array $commentData): array 61 67 { 68 self::$requestFieldOverrides = []; 62 69 self::$pendingDecision = self::evaluateDecision($commentData, false); 63 70 … … 71 78 public static function captureRestCommentDecision(array $preparedComment, $request): array 72 79 { 80 self::$requestFieldOverrides = self::extractRestRequestFields($request); 73 81 self::$pendingDecision = self::evaluateDecision($preparedComment, true); 74 82 … … 195 203 } 196 204 197 if (!$isRest) { 198 $tokenValidation = self::validateFormToken((string) self::readRequestField(self::TOKEN_FIELD), $postId); 199 if (!empty($tokenValidation['present']) && empty($tokenValidation['valid'])) { 200 return self::buildDecisionFromSuspiciousAction( 201 (string) ($settings['comment_suspicious_action'] ?? 'hold'), 202 'invalid_form_token', 203 __('Comment form token validation failed.', 'vulntitan'), 204 [ 205 'post_id' => $postId, 206 'rate_count' => $rateCount, 207 'window_minutes' => $rateWindowMinutes, 208 ], 209 [ 210 'comment_hash' => self::buildCommentHash($normalizedContent), 211 ] 212 ); 213 } 214 215 $minSeconds = max(self::MIN_TOKEN_AGE, (int) ($settings['submission_min_submit_seconds'] ?? $settings['comment_min_submit_seconds'] ?? 3)); 216 $tokenAge = (int) ($tokenValidation['age'] ?? 0); 217 if (!empty($tokenValidation['valid']) && $tokenAge > 0 && $tokenAge < $minSeconds) { 218 return self::buildDecisionFromSuspiciousAction( 219 (string) ($settings['comment_suspicious_action'] ?? 'hold'), 220 'submitted_too_fast', 221 __('Comment was submitted faster than the configured minimum time threshold.', 'vulntitan'), 222 [ 223 'post_id' => $postId, 224 'rate_count' => $rateCount, 225 'window_minutes' => $rateWindowMinutes, 226 'submitted_after_seconds' => $tokenAge, 227 'minimum_seconds' => $minSeconds, 228 ], 229 [ 230 'comment_hash' => self::buildCommentHash($normalizedContent), 231 ] 232 ); 233 } 205 $tokenValidation = self::validateFormToken((string) self::readRequestField(self::TOKEN_FIELD), $postId); 206 if ($isRest && !self::isAuthenticatedCommenter($commentData) && empty($tokenValidation['present'])) { 207 return self::buildDecisionFromSuspiciousAction( 208 (string) ($settings['comment_suspicious_action'] ?? 'hold'), 209 'missing_form_token', 210 __('REST comment request is missing the signed anti-spam token.', 'vulntitan'), 211 [ 212 'post_id' => $postId, 213 'rate_count' => $rateCount, 214 'window_minutes' => $rateWindowMinutes, 215 ], 216 [ 217 'comment_hash' => self::buildCommentHash($normalizedContent), 218 ] 219 ); 220 } 221 222 if (!empty($tokenValidation['present']) && empty($tokenValidation['valid'])) { 223 return self::buildDecisionFromSuspiciousAction( 224 (string) ($settings['comment_suspicious_action'] ?? 'hold'), 225 'invalid_form_token', 226 __('Comment form token validation failed.', 'vulntitan'), 227 [ 228 'post_id' => $postId, 229 'rate_count' => $rateCount, 230 'window_minutes' => $rateWindowMinutes, 231 ], 232 [ 233 'comment_hash' => self::buildCommentHash($normalizedContent), 234 ] 235 ); 236 } 237 238 $minSeconds = max(self::MIN_TOKEN_AGE, (int) ($settings['submission_min_submit_seconds'] ?? $settings['comment_min_submit_seconds'] ?? 3)); 239 $tokenAge = (int) ($tokenValidation['age'] ?? 0); 240 if (!empty($tokenValidation['valid']) && $tokenAge > 0 && $tokenAge < $minSeconds) { 241 return self::buildDecisionFromSuspiciousAction( 242 (string) ($settings['comment_suspicious_action'] ?? 'hold'), 243 'submitted_too_fast', 244 __('Comment was submitted faster than the configured minimum time threshold.', 'vulntitan'), 245 [ 246 'post_id' => $postId, 247 'rate_count' => $rateCount, 248 'window_minutes' => $rateWindowMinutes, 249 'submitted_after_seconds' => $tokenAge, 250 'minimum_seconds' => $minSeconds, 251 ], 252 [ 253 'comment_hash' => self::buildCommentHash($normalizedContent), 254 ] 255 ); 234 256 } 235 257 … … 886 908 $tokenPostId = (int) $postIdRaw; 887 909 $expected = wp_hash($timestampRaw . '|' . $postIdRaw . '|' . self::TOKEN_FIELD); 910 $now = time(); 911 $age = max(0, $now - $timestamp); 888 912 889 913 if ($timestamp <= 0 || $tokenPostId < 0 || !hash_equals($expected, $signature)) { … … 903 927 } 904 928 929 if ($timestamp > ($now + self::MAX_TOKEN_FUTURE_SKEW) || $age > self::getTokenMaxAge()) { 930 return [ 931 'present' => true, 932 'valid' => false, 933 'age' => $age, 934 ]; 935 } 936 905 937 return [ 906 938 'present' => true, 907 939 'valid' => true, 908 'age' => max(0, time() - $timestamp),940 'age' => $age, 909 941 ]; 910 942 } 911 943 944 protected static function getTokenMaxAge(): int 945 { 946 $maxAge = (int) apply_filters('vulntitan_comment_token_max_age', self::MAX_TOKEN_AGE); 947 948 return max(self::MIN_TOKEN_AGE, $maxAge); 949 } 950 912 951 protected static function readRequestField(string $field): string 913 952 { 953 if (array_key_exists($field, self::$requestFieldOverrides)) { 954 return trim((string) self::$requestFieldOverrides[$field]); 955 } 956 914 957 if (!isset($_REQUEST[$field])) { 915 958 return ''; … … 923 966 return trim((string) $value); 924 967 } 968 969 protected static function extractRestRequestFields($request): array 970 { 971 if (!is_object($request) || !method_exists($request, 'get_param')) { 972 return []; 973 } 974 975 $fields = []; 976 foreach ([self::TOKEN_FIELD, self::HONEYPOT_FIELD] as $field) { 977 $value = $request->get_param($field); 978 if (is_scalar($value) || $value === null) { 979 $fields[$field] = trim((string) $value); 980 } 981 } 982 983 return $fields; 984 } 925 985 } -
vulntitan/trunk/includes/Services/ContactFormSpamService.php
r3485911 r3495943 9 9 protected const RATE_LIMIT_PREFIX = 'vulntitan_cf7_rate_'; 10 10 protected const MIN_TOKEN_AGE = 1; 11 protected const MAX_TOKEN_AGE = 2 * HOUR_IN_SECONDS; 12 protected const MAX_TOKEN_FUTURE_SKEW = 5 * MINUTE_IN_SECONDS; 11 13 12 14 protected static bool $booted = false; … … 558 560 $tokenFormId = (int) $formIdRaw; 559 561 $expected = wp_hash($timestampRaw . '|' . $formIdRaw . '|' . self::TOKEN_FIELD); 562 $now = time(); 563 $age = max(0, $now - $timestamp); 560 564 561 565 if ($timestamp <= 0 || $tokenFormId < 0 || !hash_equals($expected, $signature)) { … … 575 579 } 576 580 581 if ($timestamp > ($now + self::MAX_TOKEN_FUTURE_SKEW) || $age > self::getTokenMaxAge($formId)) { 582 return [ 583 'present' => true, 584 'valid' => false, 585 'age' => $age, 586 ]; 587 } 588 577 589 return [ 578 590 'present' => true, 579 591 'valid' => true, 580 'age' => max(0, time() - $timestamp),592 'age' => $age, 581 593 ]; 594 } 595 596 protected static function getTokenMaxAge(int $formId): int 597 { 598 $maxAge = (int) apply_filters('vulntitan_form_token_max_age', self::MAX_TOKEN_AGE, 'contact_form_7', $formId); 599 600 return max(self::MIN_TOKEN_AGE, $maxAge); 582 601 } 583 602 -
vulntitan/trunk/includes/Services/FirewallService.php
r3490735 r3495943 1148 1148 $remoteAddr = isset($server['REMOTE_ADDR']) ? trim((string) $server['REMOTE_ADDR']) : ''; 1149 1149 $settings = self::getSettings(); 1150 $trustedProxies = self::getTrustedProxyIps($settings); 1151 $trustCloudflare = !empty($settings['trust_cloudflare']); 1152 $trustForwarded = self::isTrustedProxy($remoteAddr, $trustedProxies) 1153 || ($trustCloudflare && self::isCloudflareIp($remoteAddr)); 1150 $trustForwarded = self::shouldTrustForwardedHeaders($remoteAddr, $settings); 1154 1151 1155 1152 if ($trustForwarded) { … … 1199 1196 $trustCloudflare = !empty($settings['trust_cloudflare']); 1200 1197 $isCloudflare = self::isCloudflareIp($remoteAddr); 1201 $trustForwarded = self::isTrustedProxy($remoteAddr, $trustedProxies) 1202 || ($trustCloudflare && $isCloudflare); 1198 $trustForwarded = self::shouldTrustForwardedHeaders($remoteAddr, $settings); 1203 1199 1204 1200 return [ … … 1243 1239 } 1244 1240 1245 $trusted = array_filter(array_map('trim', $trusted), static function ($ip) { 1246 return $ip !== ''; 1247 }); 1248 1249 return array_values(array_unique($trusted)); 1241 return self::sanitizeTrustedProxyList($trusted); 1242 } 1243 1244 protected static function shouldTrustForwardedHeaders(string $remoteAddr, ?array $settings = null): bool 1245 { 1246 if ($settings === null) { 1247 $settings = self::getSettings(); 1248 } 1249 1250 $trustedProxies = self::getTrustedProxyIps($settings); 1251 if (self::isTrustedProxy($remoteAddr, $trustedProxies)) { 1252 return true; 1253 } 1254 1255 return !empty($settings['trust_cloudflare']) && self::isCloudflareIp($remoteAddr); 1250 1256 } 1251 1257 … … 1256 1262 } 1257 1263 1258 if (in_array($remoteAddr, $trustedProxies, true)) { 1259 return true; 1260 } 1261 1262 return self::isPrivateIp($remoteAddr); 1264 foreach ($trustedProxies as $trustedProxy) { 1265 $rule = self::sanitizeTrustedProxyRule((string) $trustedProxy); 1266 if ($rule === '') { 1267 continue; 1268 } 1269 1270 if (strpos($rule, '/') !== false) { 1271 if (self::ipInCidr($remoteAddr, $rule)) { 1272 return true; 1273 } 1274 1275 continue; 1276 } 1277 1278 if ($remoteAddr === $rule) { 1279 return true; 1280 } 1281 } 1282 1283 return false; 1263 1284 } 1264 1285 … … 1534 1555 ); 1535 1556 $lockoutAllowlist = self::sanitizeIpList($settings['lockout_allowlist_ips'] ?? $defaults['lockout_allowlist_ips']); 1536 $trustedProxies = self::sanitize IpList($settings['trusted_proxies'] ?? $defaults['trusted_proxies']);1557 $trustedProxies = self::sanitizeTrustedProxyList($settings['trusted_proxies'] ?? $defaults['trusted_proxies']); 1537 1558 $weeklySummaryRecipient = sanitize_email((string) ($settings['weekly_summary_email_recipient'] ?? $defaults['weekly_summary_email_recipient'])); 1538 1559 … … 1643 1664 } 1644 1665 1666 protected static function sanitizeTrustedProxyList($value): array 1667 { 1668 if (is_array($value)) { 1669 $rawItems = $value; 1670 } else { 1671 $rawItems = preg_split('/[\r\n,]+/', (string) $value) ?: []; 1672 } 1673 1674 $rules = []; 1675 1676 foreach ($rawItems as $item) { 1677 $rule = self::sanitizeTrustedProxyRule((string) $item); 1678 if ($rule === '') { 1679 continue; 1680 } 1681 1682 $rules[$rule] = $rule; 1683 } 1684 1685 return array_values($rules); 1686 } 1687 1688 protected static function sanitizeTrustedProxyRule(string $rule): string 1689 { 1690 $rule = trim($rule); 1691 if ($rule === '') { 1692 return ''; 1693 } 1694 1695 if (strpos($rule, '/') === false) { 1696 return self::sanitizeIp($rule); 1697 } 1698 1699 [$ip, $maskRaw] = explode('/', $rule, 2); 1700 $ip = self::sanitizeIp($ip); 1701 if ($ip === '' || !preg_match('/^\d{1,3}$/', $maskRaw)) { 1702 return ''; 1703 } 1704 1705 $mask = (int) $maskRaw; 1706 1707 if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) { 1708 if ($mask < 0 || $mask > 32) { 1709 return ''; 1710 } 1711 } elseif (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) { 1712 if ($mask < 0 || $mask > 128) { 1713 return ''; 1714 } 1715 } else { 1716 return ''; 1717 } 1718 1719 return $ip . '/' . $mask; 1720 } 1721 1645 1722 protected static function sanitizeRoleList($value, array $fallback): array 1646 1723 { -
vulntitan/trunk/includes/Services/FluentFormSpamService.php
r3485911 r3495943 9 9 protected const RATE_LIMIT_PREFIX = 'vulntitan_fluentforms_rate_'; 10 10 protected const MIN_TOKEN_AGE = 1; 11 protected const MAX_TOKEN_AGE = 2 * HOUR_IN_SECONDS; 12 protected const MAX_TOKEN_FUTURE_SKEW = 5 * MINUTE_IN_SECONDS; 11 13 protected const BLOCK_MESSAGE = 'Your submission was blocked because the system detected spam or abuse. Remove excessive links and try again.'; 12 14 … … 629 631 $tokenFormId = (int) $formIdRaw; 630 632 $expected = wp_hash($timestampRaw . '|' . $formIdRaw . '|' . self::TOKEN_FIELD); 633 $now = time(); 634 $age = max(0, $now - $timestamp); 631 635 632 636 if ($timestamp <= 0 || $tokenFormId < 0 || !hash_equals($expected, $signature)) { … … 646 650 } 647 651 652 if ($timestamp > ($now + self::MAX_TOKEN_FUTURE_SKEW) || $age > self::getTokenMaxAge($formId)) { 653 return [ 654 'present' => true, 655 'valid' => false, 656 'age' => $age, 657 ]; 658 } 659 648 660 return [ 649 661 'present' => true, 650 662 'valid' => true, 651 'age' => max(0, time() - $timestamp),663 'age' => $age, 652 664 ]; 665 } 666 667 protected static function getTokenMaxAge(int $formId): int 668 { 669 $maxAge = (int) apply_filters('vulntitan_form_token_max_age', self::MAX_TOKEN_AGE, 'fluent_forms', $formId); 670 671 return max(self::MIN_TOKEN_AGE, $maxAge); 653 672 } 654 673 -
vulntitan/trunk/includes/Services/LoginSecurityService.php
r3483537 r3495943 22 22 protected const CHALLENGE_TTL = 10 * MINUTE_IN_SECONDS; 23 23 protected const RECOVERY_PREVIEW_TTL = 15 * MINUTE_IN_SECONDS; 24 protected const ATTEMPT_PREFIX = 'vulntitan_2fa_attempt_'; 25 protected const BLOCK_PREFIX = 'vulntitan_2fa_block_'; 26 protected const MAX_CHALLENGE_ATTEMPTS = 5; 27 protected const ATTEMPT_WINDOW = 10 * MINUTE_IN_SECONDS; 28 protected const BLOCK_TTL = 10 * MINUTE_IN_SECONDS; 24 29 protected static bool $booted = false; 25 30 … … 108 113 } 109 114 115 $rateLimit = self::getTwoFactorRateLimitState($user->ID, FirewallService::detectClientIp()); 116 if (!empty($rateLimit['blocked'])) { 117 return new WP_Error( 118 'vulntitan_2fa_rate_limited', 119 self::getTwoFactorRateLimitMessage((int) ($rateLimit['remaining_seconds'] ?? self::BLOCK_TTL)) 120 ); 121 } 122 110 123 $challengeId = self::createChallenge($user); 111 124 $redirectUrl = add_query_arg([ … … 144 157 } 145 158 159 $ipAddress = FirewallService::detectClientIp(); 160 $rateLimit = self::getTwoFactorRateLimitState($user->ID, $ipAddress); 161 if (!empty($rateLimit['blocked'])) { 162 self::deleteChallenge($challengeId); 163 self::renderChallengePage( 164 __('Two-Factor Authentication', 'vulntitan'), 165 self::getTwoFactorRateLimitMessage((int) ($rateLimit['remaining_seconds'] ?? self::BLOCK_TTL)), 166 [], 167 true 168 ); 169 } 170 146 171 if (strtoupper((string) ($_SERVER['REQUEST_METHOD'] ?? 'GET')) === 'POST') { 147 172 $nonce = isset($_POST[self::CHALLENGE_NONCE]) ? (string) wp_unslash($_POST[self::CHALLENGE_NONCE]) : ''; … … 161 186 if ($isValid) { 162 187 self::deleteChallenge($challengeId); 188 self::clearTwoFactorRateLimitState($user->ID, $ipAddress); 163 189 self::finalizeAuthenticatedLogin( 164 190 $user, … … 181 207 'reason' => 'Invalid two-factor verification code.', 182 208 ]); 209 210 $rateLimit = self::recordFailedTwoFactorAttempt($user, $ipAddress); 211 if (!empty($rateLimit['blocked'])) { 212 self::deleteChallenge($challengeId); 213 self::renderChallengePage( 214 __('Two-Factor Authentication', 'vulntitan'), 215 self::getTwoFactorRateLimitMessage((int) ($rateLimit['remaining_seconds'] ?? self::BLOCK_TTL)), 216 [], 217 true 218 ); 219 } 183 220 184 221 $errorMessage = __('The verification code was invalid. Please try again.', 'vulntitan'); … … 625 662 } 626 663 664 protected static function getTwoFactorRateLimitState(int $userId, string $ipAddress): array 665 { 666 $blockedUntil = (int) get_transient(self::getTwoFactorBlockKey($userId, $ipAddress)); 667 if ($blockedUntil > time()) { 668 return [ 669 'blocked' => true, 670 'remaining_seconds' => max(1, $blockedUntil - time()), 671 ]; 672 } 673 674 if ($blockedUntil > 0) { 675 delete_transient(self::getTwoFactorBlockKey($userId, $ipAddress)); 676 } 677 678 return [ 679 'blocked' => false, 680 'remaining_seconds' => 0, 681 ]; 682 } 683 684 protected static function recordFailedTwoFactorAttempt(WP_User $user, string $ipAddress): array 685 { 686 $attemptKey = self::getTwoFactorAttemptKey($user->ID, $ipAddress); 687 $data = get_transient($attemptKey); 688 689 if (!is_array($data)) { 690 $data = [ 691 'count' => 0, 692 'started_at' => time(), 693 ]; 694 } 695 696 $data['count'] = max(0, (int) ($data['count'] ?? 0)) + 1; 697 $data['started_at'] = (int) ($data['started_at'] ?? time()); 698 set_transient($attemptKey, $data, self::ATTEMPT_WINDOW); 699 700 if ((int) $data['count'] < self::MAX_CHALLENGE_ATTEMPTS) { 701 return [ 702 'blocked' => false, 703 'remaining_seconds' => 0, 704 ]; 705 } 706 707 $blockedUntil = time() + self::BLOCK_TTL; 708 set_transient(self::getTwoFactorBlockKey($user->ID, $ipAddress), $blockedUntil, self::BLOCK_TTL); 709 delete_transient($attemptKey); 710 711 FirewallService::logEvent('login_blocked', [ 712 'ip_address' => $ipAddress, 713 'username' => $user->user_login, 714 'user_id' => $user->ID, 715 'event_source' => 'login_security', 716 'rule_group' => 'two_factor', 717 'rule_id' => 'totp_rate_limited', 718 'reason' => 'Too many invalid two-factor verification attempts.', 719 'details' => [ 720 'attempts' => (int) $data['count'], 721 'retry_after_seconds' => self::BLOCK_TTL, 722 ], 723 ]); 724 725 return [ 726 'blocked' => true, 727 'remaining_seconds' => self::BLOCK_TTL, 728 ]; 729 } 730 731 protected static function clearTwoFactorRateLimitState(int $userId, string $ipAddress): void 732 { 733 delete_transient(self::getTwoFactorAttemptKey($userId, $ipAddress)); 734 delete_transient(self::getTwoFactorBlockKey($userId, $ipAddress)); 735 } 736 737 protected static function getTwoFactorAttemptKey(int $userId, string $ipAddress): string 738 { 739 return self::ATTEMPT_PREFIX . md5($userId . '|' . ($ipAddress !== '' ? $ipAddress : 'unknown')); 740 } 741 742 protected static function getTwoFactorBlockKey(int $userId, string $ipAddress): string 743 { 744 return self::BLOCK_PREFIX . md5($userId . '|' . ($ipAddress !== '' ? $ipAddress : 'unknown')); 745 } 746 747 protected static function getTwoFactorRateLimitMessage(int $remainingSeconds): string 748 { 749 return sprintf( 750 __('Too many two-factor verification attempts. Try again in %d minute(s).', 'vulntitan'), 751 max(1, (int) ceil($remainingSeconds / MINUTE_IN_SECONDS)) 752 ); 753 } 754 627 755 protected static function renderChallengePage(string $title, string $message = '', array $context = [], bool $isFatal = false): void 628 756 { -
vulntitan/trunk/readme.txt
r3490735 r3495943 4 4 Tested up to: 6.9 5 5 Requires PHP: 7.4 6 Stable tag: 2.1.1 66 Stable tag: 2.1.17 7 7 License: GPLv2 8 8 License URI: https://www.gnu.org/licenses/gpl-2.0.html … … 60 60 - XML-RPC allow, disable, or rate-limit policy controls with IP allowlisting 61 61 - Weak-password blocking during profile updates, password resets, and compatible registrations 62 - Comment Shield with honeypot, s ubmit-time validation, duplicate detection, guest link limits, and IP rate limiting62 - Comment Shield with honeypot, signed tokens, submit-time validation, duplicate detection, guest link limits, IP rate limiting, and moderation-aware logging 63 63 - Form Shield for Contact Form 7 and Fluent Forms with honeypot, signed submit tokens, link heuristics, repeated-domain detection, and IP rate limiting 64 64 - Form spam blocks are logged into the WAF/live feed with provider-aware source labels for easier review 65 65 - Suspicious comments can be held for moderation or blocked immediately 66 - REST comments can enforce signed anti-spam tokens and CAPTCHA when anonymous REST commenting is enabled elsewhere 66 67 - Configurable custom login slug so administrators can use a private login URL instead of the default `wp-login.php` 67 68 - Default `wp-login.php` and guest `wp-admin` access can be hidden behind a `404` response when custom login is enabled 68 - Weekly executive security report email with 7-day firewall, login abuse, WAF, and comment spamstatistics69 - Weekly executive security report email with 7-day firewall, login abuse, WAF, form spam, and comment moderation statistics 69 70 70 71 = Security-First Architecture = … … 72 73 - Secure storage and cleanup of scan queues and logs 73 74 - Hardened backup handling outside `ABSPATH` by default 75 - Hardened malware and integrity scan actions with stricter capability checks and in-root path validation 74 76 - Adaptive performance tuning for safe large-site scanning 75 77 … … 174 176 175 177 == Changelog == 178 179 = v2.1.17 - 31 Mar, 2026 = 180 * Hardened malware and integrity scan actions with stricter capability checks, boundary-safe path validation, and server-side verification of auto-fix targets. 181 * Closed the conditional REST comment bypass by enforcing signed anti-spam tokens and comment CAPTCHA on REST comment submissions as well. 182 * Added stronger 2FA challenge throttling, tighter proxy trust handling, bounded anti-spam token lifetimes, and reduced hot-path maintenance overhead. 183 * Expanded release metadata and readme coverage for comment moderation, digest reporting, and hardening updates. 176 184 177 185 = v2.1.16 - 25 Mar, 2026 = -
vulntitan/trunk/vulntitan.php
r3490735 r3495943 4 4 * Plugin URI: https://vulntitan.com/vulntitan/ 5 5 * Description: VulnTitan is a WordPress security plugin with vulnerability scanning, malware detection, file integrity monitoring, comment and form anti-spam protection, and a built-in firewall with WAF payload rules and login protection. 6 * Version: 2.1.1 66 * Version: 2.1.17 7 7 * Author: Jaroslav Svetlik 8 8 * Author URI: https://vulntitan.com … … 30 30 31 31 // Define plugin constants 32 define('VULNTITAN_PLUGIN_VERSION', VULNTITAN_DEVELOPMENT ? uniqid() : '2.1.1 6');32 define('VULNTITAN_PLUGIN_VERSION', VULNTITAN_DEVELOPMENT ? uniqid() : '2.1.17'); 33 33 define('VULNTITAN_PLUGIN_BASENAME', plugin_basename(__FILE__)); 34 34 define('VULNTITAN_PLUGIN_DIR', untrailingslashit(plugin_dir_path(__FILE__)));
Note: See TracChangeset
for help on using the changeset viewer.