Plugin Directory

Changeset 3495943


Ignore:
Timestamp:
03/31/2026 07:06:53 PM (2 days ago)
Author:
jerryscg
Message:

Release 2.1.17 with hardened comment/login protection and digest updates

Location:
vulntitan
Files:
28 edited
1 copied

Legend:

Unmodified
Added
Removed
  • vulntitan/tags/2.1.17/CHANGELOG.md

    r3490735 r3495943  
    55The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
    66and 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.
    720
    821## [2.1.16] - 2026-03-25
  • vulntitan/tags/2.1.17/assets/js/malware-scanner.js

    r3483103 r3495943  
    325325                $feedback.text('').css('color', '#94a3b8');
    326326
    327                 const patterns = Array.isArray(finding.patterns) ? finding.patterns.join(', ') : (finding.type || '');
    328327                $.post(ajaxurl, {
    329328                    action: 'vulntitan_malware_fix_finding',
    330329                    nonce: VulnTitan.nonce,
    331330                    file_path: item.file || '',
    332                     line: lineNumber,
    333                     expected_code: finding.code || '',
    334                     patterns: patterns
     331                    line: lineNumber
    335332                }, function (response) {
    336333                    if (response && response.success) {
  • vulntitan/tags/2.1.17/assets/js/malware-scanner.min.js

    r3483103 r3495943  
    325325                $feedback.text('').css('color', '#94a3b8');
    326326
    327                 const patterns = Array.isArray(finding.patterns) ? finding.patterns.join(', ') : (finding.type || '');
    328327                $.post(ajaxurl, {
    329328                    action: 'vulntitan_malware_fix_finding',
    330329                    nonce: VulnTitan.nonce,
    331330                    file_path: item.file || '',
    332                     line: lineNumber,
    333                     expected_code: finding.code || '',
    334                     patterns: patterns
     331                    line: lineNumber
    335332                }, function (response) {
    336333                    if (response && response.success) {
  • vulntitan/tags/2.1.17/includes/Admin/Ajax.php

    r3485911 r3495943  
    1313class Ajax
    1414{
     15    protected const FILE_OPERATIONS_CAPABILITY_FILTER = 'vulntitan_file_operations_capability';
     16
    1517    public function register(): void
    1618    {
     
    617619        check_ajax_referer('vulntitan_ajax_scan', 'nonce');
    618620
    619         if (!current_user_can('manage_options')) {
    620             wp_send_json_error(['message' => esc_html__('Insufficient permissions.', 'vulntitan')], 403);
    621         }
     621        $this->requireFileOperationsCapability();
    622622
    623623        $scanner = new MalwareScanner(get_option('vulntitan_malware_scan_scope', 'all'));
     
    634634        check_ajax_referer('vulntitan_ajax_scan', 'nonce');
    635635
    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) {
    643640            wp_send_json_error(['message' => esc_html__('Invalid or missing file.', 'vulntitan')]);
    644641        }
     
    646643        try {
    647644            $scanner = new MalwareScanner(get_option('vulntitan_malware_scan_scope', 'all'));
    648             $result = $scanner->scanSingleFile($file);
     645            $result = $scanner->scanSingleFile($resolved['absolute']);
    649646
    650647            wp_send_json_success([
     
    665662        check_ajax_referer('vulntitan_ajax_scan', 'nonce');
    666663
    667         if (!current_user_can('manage_options')) {
    668             wp_send_json_error(['message' => esc_html__('Insufficient permissions.', 'vulntitan')], 403);
    669         }
     664        $this->requireFileOperationsCapability();
    670665
    671666        $filePaths = $_POST['file_paths'] ?? [];
     
    679674
    680675        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) {
    695679                $results[] = [
    696                     'file' => $relativePath,
     680                    'file' => ltrim(str_replace('\\', '/', (string) $inputPath), '/'),
    697681                    'status' => 'skipped',
    698682                    'skipped' => true,
     
    701685                continue;
    702686            }
    703        
    704             if (!file_exists($realAbsolute)) {
     687
     688            try {
     689                $findings = $scanner->scanSingleFile($resolved['absolute']);
    705690                $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'],
    718692                    'status'    => !empty($findings) ? 'infected' : 'clean',
    719693                    'findings'  => $findings,
     
    721695            } catch (Throwable $e) {
    722696                $results[] = [
    723                     'file'  => $relativePath,
     697                    'file'  => $resolved['relative'],
    724698                    'status' => 'error',
    725699                    'error' => $e->getMessage(),
     
    739713        check_ajax_referer('vulntitan_ajax_scan', 'nonce');
    740714
    741         if (!current_user_can('manage_options')) {
    742             wp_send_json_error(['message' => esc_html__('Insufficient permissions.', 'vulntitan')], 403);
    743         }
     715        $this->requireFileOperationsCapability();
    744716
    745717        $filePath = sanitize_text_field(wp_unslash($_POST['file_path'] ?? ''));
    746718        $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)) : '';
    752719
    753720        if ($filePath === '' || $lineNumber < 1) {
     
    755722        }
    756723
    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) {
    767726            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')]);
    768734        }
    769735
     
    796762        $updatedLine = $originalLine;
    797763        $appliedMode = 'quarantine_line';
     764        $expectedCode = trim(substr((string) ($finding['code'] ?? ''), 0, 2000));
     765        $patterns = $this->normalizeFixablePatternList($finding['patterns'] ?? []);
    798766        $normalizedExpectedCode = str_replace(["\r", "\n"], '', $expectedCode);
    799767
     
    953921    }
    954922
     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
    955968    protected function isFixablePatternList(string $patterns): bool
    956969    {
     
    978991    }
    979992
     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
    9801057    public function integrityScanInit(): void
    9811058    {
    9821059        check_ajax_referer('vulntitan_ajax_scan', 'nonce');
    9831060
    984         if (!current_user_can('manage_options')) {
    985             wp_send_json_error(['message' => esc_html__('Insufficient permissions.', 'vulntitan')], 403);
    986         }
     1061        $this->requireFileOperationsCapability();
    9871062
    9881063        if (!BaselineBuilder::exists()) {
     
    10241099        check_ajax_referer('vulntitan_ajax_scan', 'nonce');
    10251100
    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) {
    10461105            wp_send_json_error(['message' => esc_html__('File not found.', 'vulntitan')]);
    10471106        }
     
    10521111            $scanner->loadBaseline();
    10531112
    1054             $relativePath = ltrim(str_replace($realBase, '', $realAbsolute), '/\\');
    1055             $result = $scanner->scanSingleFile($relativePath);
     1113            $result = $scanner->scanSingleFile($resolved['relative']);
    10561114
    10571115            wp_send_json_success([
     
    10711129        $startedAt = microtime(true);
    10721130
    1073         if (!current_user_can('manage_options')) {
    1074             wp_send_json_error(['message' => esc_html__('Insufficient permissions.', 'vulntitan')], 403);
    1075         }
     1131        $this->requireFileOperationsCapability();
    10761132   
    10771133        $queueId = sanitize_text_field( wp_unslash( $_POST['queue_id'] ?? '' ) );
  • vulntitan/tags/2.1.17/includes/Admin/Pages/Firewall.php

    r3486040 r3495943  
    208208                                                        <span class="vulntitan-firewall-field-label"><?php esc_html_e('Trusted Proxy IPs', 'vulntitan'); ?></span>
    209209                                                        <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>
    211212                                                        <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>
    212213                                                    </label>
  • vulntitan/tags/2.1.17/includes/Plugin.php

    r3485911 r3495943  
    2323    protected const FIREWALL_LOG_CLEANUP_HOOK = 'vulntitan_cleanup_firewall_logs';
    2424    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;
    2528
    2629    public $admin;
     
    2932    {
    3033        $this->maybe_generate_baseline();
    31         $this->ensure_firewall_components();
     34        $this->maybeEnsureFirewallComponents();
    3235        $this->register_scheduled_events();
    3336        $this->register_cli_commands();
     
    5962        wp_clear_scheduled_hook(self::FIREWALL_LOG_CLEANUP_HOOK);
    6063        wp_clear_scheduled_hook(self::WEEKLY_SUMMARY_EMAIL_HOOK);
     64        delete_transient(self::COMPONENT_HEALTH_CHECK_TRANSIENT);
    6165        FirewallService::removeMuLoader();
    6266    }
     
    136140    }
    137141
    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;
    143188    }
    144189
  • vulntitan/tags/2.1.17/includes/Services/CaptchaService.php

    r3483537 r3495943  
    1313    protected static bool $booted = false;
    1414    protected static bool $renderedWidget = false;
     15    protected static array $commentRequestFieldOverrides = [];
    1516
    1617    public static function boot(): void
     
    3233        add_filter('registration_errors', [__CLASS__, 'verifyRegistrationCaptcha'], 20, 3);
    3334        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);
    3437        add_filter('pre_comment_approved', [__CLASS__, 'verifyCommentCaptcha'], 8, 2);
    3538    }
     
    174177
    175178        if (!self::isContextEnabled(self::CONTEXT_COMMENT) || !self::isProviderConfigured()) {
    176             return $approved;
    177         }
    178 
    179         if (defined('REST_REQUEST') && REST_REQUEST) {
    180179            return $approved;
    181180        }
     
    312311        $field = $provider === 'turnstile' ? 'cf-turnstile-response' : 'h-captcha-response';
    313312
     313        if (array_key_exists($field, self::$commentRequestFieldOverrides)) {
     314            return trim((string) self::$commentRequestFieldOverrides[$field]);
     315        }
     316
    314317        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;
    315357    }
    316358
  • vulntitan/tags/2.1.17/includes/Services/CommentSpamService.php

    r3490735 r3495943  
    1212    protected const RATE_LIMIT_PREFIX = 'vulntitan_cs_rate_';
    1313    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;
    1416    protected const SHORT_COMMENT_MAX_WORDS = 3;
    1517    protected const SHORT_COMMENT_MAX_LENGTH = 40;
     
    2426     */
    2527    protected static ?array $pendingDecision = null;
     28    protected static array $requestFieldOverrides = [];
    2629
    2730    public static function boot(): void
     
    5053            $postId = (int) get_the_ID();
    5154        }
     55        if ($postId <= 0 && function_exists('get_queried_object_id')) {
     56            $postId = (int) get_queried_object_id();
     57        }
    5258
    5359        echo '<p class="comment-form-vulntitan-honeypot" style="position:absolute;left:-9999px;top:auto;width:1px;height:1px;overflow:hidden;" aria-hidden="true">';
     
    6066    public static function captureCommentDecision(array $commentData): array
    6167    {
     68        self::$requestFieldOverrides = [];
    6269        self::$pendingDecision = self::evaluateDecision($commentData, false);
    6370
     
    7178    public static function captureRestCommentDecision(array $preparedComment, $request): array
    7279    {
     80        self::$requestFieldOverrides = self::extractRestRequestFields($request);
    7381        self::$pendingDecision = self::evaluateDecision($preparedComment, true);
    7482
     
    195203        }
    196204
    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            );
    234256        }
    235257
     
    886908        $tokenPostId = (int) $postIdRaw;
    887909        $expected = wp_hash($timestampRaw . '|' . $postIdRaw . '|' . self::TOKEN_FIELD);
     910        $now = time();
     911        $age = max(0, $now - $timestamp);
    888912
    889913        if ($timestamp <= 0 || $tokenPostId < 0 || !hash_equals($expected, $signature)) {
     
    903927        }
    904928
     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
    905937        return [
    906938            'present' => true,
    907939            'valid' => true,
    908             'age' => max(0, time() - $timestamp),
     940            'age' => $age,
    909941        ];
    910942    }
    911943
     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
    912951    protected static function readRequestField(string $field): string
    913952    {
     953        if (array_key_exists($field, self::$requestFieldOverrides)) {
     954            return trim((string) self::$requestFieldOverrides[$field]);
     955        }
     956
    914957        if (!isset($_REQUEST[$field])) {
    915958            return '';
     
    923966        return trim((string) $value);
    924967    }
     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    }
    925985}
  • vulntitan/tags/2.1.17/includes/Services/ContactFormSpamService.php

    r3485911 r3495943  
    99    protected const RATE_LIMIT_PREFIX = 'vulntitan_cf7_rate_';
    1010    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;
    1113
    1214    protected static bool $booted = false;
     
    558560        $tokenFormId = (int) $formIdRaw;
    559561        $expected = wp_hash($timestampRaw . '|' . $formIdRaw . '|' . self::TOKEN_FIELD);
     562        $now = time();
     563        $age = max(0, $now - $timestamp);
    560564
    561565        if ($timestamp <= 0 || $tokenFormId < 0 || !hash_equals($expected, $signature)) {
     
    575579        }
    576580
     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
    577589        return [
    578590            'present' => true,
    579591            'valid' => true,
    580             'age' => max(0, time() - $timestamp),
     592            'age' => $age,
    581593        ];
     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);
    582601    }
    583602
  • vulntitan/tags/2.1.17/includes/Services/FirewallService.php

    r3490735 r3495943  
    11481148        $remoteAddr = isset($server['REMOTE_ADDR']) ? trim((string) $server['REMOTE_ADDR']) : '';
    11491149        $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);
    11541151
    11551152        if ($trustForwarded) {
     
    11991196        $trustCloudflare = !empty($settings['trust_cloudflare']);
    12001197        $isCloudflare = self::isCloudflareIp($remoteAddr);
    1201         $trustForwarded = self::isTrustedProxy($remoteAddr, $trustedProxies)
    1202             || ($trustCloudflare && $isCloudflare);
     1198        $trustForwarded = self::shouldTrustForwardedHeaders($remoteAddr, $settings);
    12031199
    12041200        return [
     
    12431239        }
    12441240
    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);
    12501256    }
    12511257
     
    12561262        }
    12571263
    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;
    12631284    }
    12641285
     
    15341555        );
    15351556        $lockoutAllowlist = self::sanitizeIpList($settings['lockout_allowlist_ips'] ?? $defaults['lockout_allowlist_ips']);
    1536         $trustedProxies = self::sanitizeIpList($settings['trusted_proxies'] ?? $defaults['trusted_proxies']);
     1557        $trustedProxies = self::sanitizeTrustedProxyList($settings['trusted_proxies'] ?? $defaults['trusted_proxies']);
    15371558        $weeklySummaryRecipient = sanitize_email((string) ($settings['weekly_summary_email_recipient'] ?? $defaults['weekly_summary_email_recipient']));
    15381559
     
    16431664    }
    16441665
     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
    16451722    protected static function sanitizeRoleList($value, array $fallback): array
    16461723    {
  • vulntitan/tags/2.1.17/includes/Services/FluentFormSpamService.php

    r3485911 r3495943  
    99    protected const RATE_LIMIT_PREFIX = 'vulntitan_fluentforms_rate_';
    1010    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;
    1113    protected const BLOCK_MESSAGE = 'Your submission was blocked because the system detected spam or abuse. Remove excessive links and try again.';
    1214
     
    629631        $tokenFormId = (int) $formIdRaw;
    630632        $expected = wp_hash($timestampRaw . '|' . $formIdRaw . '|' . self::TOKEN_FIELD);
     633        $now = time();
     634        $age = max(0, $now - $timestamp);
    631635
    632636        if ($timestamp <= 0 || $tokenFormId < 0 || !hash_equals($expected, $signature)) {
     
    646650        }
    647651
     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
    648660        return [
    649661            'present' => true,
    650662            'valid' => true,
    651             'age' => max(0, time() - $timestamp),
     663            'age' => $age,
    652664        ];
     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);
    653672    }
    654673
  • vulntitan/tags/2.1.17/includes/Services/LoginSecurityService.php

    r3483537 r3495943  
    2222    protected const CHALLENGE_TTL = 10 * MINUTE_IN_SECONDS;
    2323    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;
    2429    protected static bool $booted = false;
    2530
     
    108113        }
    109114
     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
    110123        $challengeId = self::createChallenge($user);
    111124        $redirectUrl = add_query_arg([
     
    144157        }
    145158
     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
    146171        if (strtoupper((string) ($_SERVER['REQUEST_METHOD'] ?? 'GET')) === 'POST') {
    147172            $nonce = isset($_POST[self::CHALLENGE_NONCE]) ? (string) wp_unslash($_POST[self::CHALLENGE_NONCE]) : '';
     
    161186                if ($isValid) {
    162187                    self::deleteChallenge($challengeId);
     188                    self::clearTwoFactorRateLimitState($user->ID, $ipAddress);
    163189                    self::finalizeAuthenticatedLogin(
    164190                        $user,
     
    181207                    'reason' => 'Invalid two-factor verification code.',
    182208                ]);
     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                }
    183220
    184221                $errorMessage = __('The verification code was invalid. Please try again.', 'vulntitan');
     
    625662    }
    626663
     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
    627755    protected static function renderChallengePage(string $title, string $message = '', array $context = [], bool $isFatal = false): void
    628756    {
  • vulntitan/tags/2.1.17/readme.txt

    r3490735 r3495943  
    44Tested up to: 6.9
    55Requires PHP: 7.4
    6 Stable tag: 2.1.16
     6Stable tag: 2.1.17
    77License: GPLv2
    88License URI: https://www.gnu.org/licenses/gpl-2.0.html
     
    6060- XML-RPC allow, disable, or rate-limit policy controls with IP allowlisting
    6161- Weak-password blocking during profile updates, password resets, and compatible registrations
    62 - Comment Shield with honeypot, submit-time validation, duplicate detection, guest link limits, and IP rate limiting
     62- Comment Shield with honeypot, signed tokens, submit-time validation, duplicate detection, guest link limits, IP rate limiting, and moderation-aware logging
    6363- Form Shield for Contact Form 7 and Fluent Forms with honeypot, signed submit tokens, link heuristics, repeated-domain detection, and IP rate limiting
    6464- Form spam blocks are logged into the WAF/live feed with provider-aware source labels for easier review
    6565- 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
    6667- Configurable custom login slug so administrators can use a private login URL instead of the default `wp-login.php`
    6768- 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 spam statistics
     69- Weekly executive security report email with 7-day firewall, login abuse, WAF, form spam, and comment moderation statistics
    6970
    7071= Security-First Architecture =
     
    7273- Secure storage and cleanup of scan queues and logs
    7374- Hardened backup handling outside `ABSPATH` by default
     75- Hardened malware and integrity scan actions with stricter capability checks and in-root path validation
    7476- Adaptive performance tuning for safe large-site scanning
    7577
     
    174176
    175177== 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.
    176184
    177185= v2.1.16 - 25 Mar, 2026 =
  • vulntitan/tags/2.1.17/vulntitan.php

    r3490735 r3495943  
    44 * Plugin URI: https://vulntitan.com/vulntitan/
    55 * 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.16
     6 * Version: 2.1.17
    77 * Author: Jaroslav Svetlik
    88 * Author URI: https://vulntitan.com
     
    3030
    3131// Define plugin constants
    32 define('VULNTITAN_PLUGIN_VERSION', VULNTITAN_DEVELOPMENT ? uniqid() : '2.1.16');
     32define('VULNTITAN_PLUGIN_VERSION', VULNTITAN_DEVELOPMENT ? uniqid() : '2.1.17');
    3333define('VULNTITAN_PLUGIN_BASENAME', plugin_basename(__FILE__));
    3434define('VULNTITAN_PLUGIN_DIR', untrailingslashit(plugin_dir_path(__FILE__)));
  • vulntitan/trunk/CHANGELOG.md

    r3490735 r3495943  
    55The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
    66and 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.
    720
    821## [2.1.16] - 2026-03-25
  • vulntitan/trunk/assets/js/malware-scanner.js

    r3483103 r3495943  
    325325                $feedback.text('').css('color', '#94a3b8');
    326326
    327                 const patterns = Array.isArray(finding.patterns) ? finding.patterns.join(', ') : (finding.type || '');
    328327                $.post(ajaxurl, {
    329328                    action: 'vulntitan_malware_fix_finding',
    330329                    nonce: VulnTitan.nonce,
    331330                    file_path: item.file || '',
    332                     line: lineNumber,
    333                     expected_code: finding.code || '',
    334                     patterns: patterns
     331                    line: lineNumber
    335332                }, function (response) {
    336333                    if (response && response.success) {
  • vulntitan/trunk/assets/js/malware-scanner.min.js

    r3483103 r3495943  
    325325                $feedback.text('').css('color', '#94a3b8');
    326326
    327                 const patterns = Array.isArray(finding.patterns) ? finding.patterns.join(', ') : (finding.type || '');
    328327                $.post(ajaxurl, {
    329328                    action: 'vulntitan_malware_fix_finding',
    330329                    nonce: VulnTitan.nonce,
    331330                    file_path: item.file || '',
    332                     line: lineNumber,
    333                     expected_code: finding.code || '',
    334                     patterns: patterns
     331                    line: lineNumber
    335332                }, function (response) {
    336333                    if (response && response.success) {
  • vulntitan/trunk/includes/Admin/Ajax.php

    r3485911 r3495943  
    1313class Ajax
    1414{
     15    protected const FILE_OPERATIONS_CAPABILITY_FILTER = 'vulntitan_file_operations_capability';
     16
    1517    public function register(): void
    1618    {
     
    617619        check_ajax_referer('vulntitan_ajax_scan', 'nonce');
    618620
    619         if (!current_user_can('manage_options')) {
    620             wp_send_json_error(['message' => esc_html__('Insufficient permissions.', 'vulntitan')], 403);
    621         }
     621        $this->requireFileOperationsCapability();
    622622
    623623        $scanner = new MalwareScanner(get_option('vulntitan_malware_scan_scope', 'all'));
     
    634634        check_ajax_referer('vulntitan_ajax_scan', 'nonce');
    635635
    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) {
    643640            wp_send_json_error(['message' => esc_html__('Invalid or missing file.', 'vulntitan')]);
    644641        }
     
    646643        try {
    647644            $scanner = new MalwareScanner(get_option('vulntitan_malware_scan_scope', 'all'));
    648             $result = $scanner->scanSingleFile($file);
     645            $result = $scanner->scanSingleFile($resolved['absolute']);
    649646
    650647            wp_send_json_success([
     
    665662        check_ajax_referer('vulntitan_ajax_scan', 'nonce');
    666663
    667         if (!current_user_can('manage_options')) {
    668             wp_send_json_error(['message' => esc_html__('Insufficient permissions.', 'vulntitan')], 403);
    669         }
     664        $this->requireFileOperationsCapability();
    670665
    671666        $filePaths = $_POST['file_paths'] ?? [];
     
    679674
    680675        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) {
    695679                $results[] = [
    696                     'file' => $relativePath,
     680                    'file' => ltrim(str_replace('\\', '/', (string) $inputPath), '/'),
    697681                    'status' => 'skipped',
    698682                    'skipped' => true,
     
    701685                continue;
    702686            }
    703        
    704             if (!file_exists($realAbsolute)) {
     687
     688            try {
     689                $findings = $scanner->scanSingleFile($resolved['absolute']);
    705690                $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'],
    718692                    'status'    => !empty($findings) ? 'infected' : 'clean',
    719693                    'findings'  => $findings,
     
    721695            } catch (Throwable $e) {
    722696                $results[] = [
    723                     'file'  => $relativePath,
     697                    'file'  => $resolved['relative'],
    724698                    'status' => 'error',
    725699                    'error' => $e->getMessage(),
     
    739713        check_ajax_referer('vulntitan_ajax_scan', 'nonce');
    740714
    741         if (!current_user_can('manage_options')) {
    742             wp_send_json_error(['message' => esc_html__('Insufficient permissions.', 'vulntitan')], 403);
    743         }
     715        $this->requireFileOperationsCapability();
    744716
    745717        $filePath = sanitize_text_field(wp_unslash($_POST['file_path'] ?? ''));
    746718        $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)) : '';
    752719
    753720        if ($filePath === '' || $lineNumber < 1) {
     
    755722        }
    756723
    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) {
    767726            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')]);
    768734        }
    769735
     
    796762        $updatedLine = $originalLine;
    797763        $appliedMode = 'quarantine_line';
     764        $expectedCode = trim(substr((string) ($finding['code'] ?? ''), 0, 2000));
     765        $patterns = $this->normalizeFixablePatternList($finding['patterns'] ?? []);
    798766        $normalizedExpectedCode = str_replace(["\r", "\n"], '', $expectedCode);
    799767
     
    953921    }
    954922
     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
    955968    protected function isFixablePatternList(string $patterns): bool
    956969    {
     
    978991    }
    979992
     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
    9801057    public function integrityScanInit(): void
    9811058    {
    9821059        check_ajax_referer('vulntitan_ajax_scan', 'nonce');
    9831060
    984         if (!current_user_can('manage_options')) {
    985             wp_send_json_error(['message' => esc_html__('Insufficient permissions.', 'vulntitan')], 403);
    986         }
     1061        $this->requireFileOperationsCapability();
    9871062
    9881063        if (!BaselineBuilder::exists()) {
     
    10241099        check_ajax_referer('vulntitan_ajax_scan', 'nonce');
    10251100
    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) {
    10461105            wp_send_json_error(['message' => esc_html__('File not found.', 'vulntitan')]);
    10471106        }
     
    10521111            $scanner->loadBaseline();
    10531112
    1054             $relativePath = ltrim(str_replace($realBase, '', $realAbsolute), '/\\');
    1055             $result = $scanner->scanSingleFile($relativePath);
     1113            $result = $scanner->scanSingleFile($resolved['relative']);
    10561114
    10571115            wp_send_json_success([
     
    10711129        $startedAt = microtime(true);
    10721130
    1073         if (!current_user_can('manage_options')) {
    1074             wp_send_json_error(['message' => esc_html__('Insufficient permissions.', 'vulntitan')], 403);
    1075         }
     1131        $this->requireFileOperationsCapability();
    10761132   
    10771133        $queueId = sanitize_text_field( wp_unslash( $_POST['queue_id'] ?? '' ) );
  • vulntitan/trunk/includes/Admin/Pages/Firewall.php

    r3486040 r3495943  
    208208                                                        <span class="vulntitan-firewall-field-label"><?php esc_html_e('Trusted Proxy IPs', 'vulntitan'); ?></span>
    209209                                                        <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>
    211212                                                        <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>
    212213                                                    </label>
  • vulntitan/trunk/includes/Plugin.php

    r3485911 r3495943  
    2323    protected const FIREWALL_LOG_CLEANUP_HOOK = 'vulntitan_cleanup_firewall_logs';
    2424    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;
    2528
    2629    public $admin;
     
    2932    {
    3033        $this->maybe_generate_baseline();
    31         $this->ensure_firewall_components();
     34        $this->maybeEnsureFirewallComponents();
    3235        $this->register_scheduled_events();
    3336        $this->register_cli_commands();
     
    5962        wp_clear_scheduled_hook(self::FIREWALL_LOG_CLEANUP_HOOK);
    6063        wp_clear_scheduled_hook(self::WEEKLY_SUMMARY_EMAIL_HOOK);
     64        delete_transient(self::COMPONENT_HEALTH_CHECK_TRANSIENT);
    6165        FirewallService::removeMuLoader();
    6266    }
     
    136140    }
    137141
    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;
    143188    }
    144189
  • vulntitan/trunk/includes/Services/CaptchaService.php

    r3483537 r3495943  
    1313    protected static bool $booted = false;
    1414    protected static bool $renderedWidget = false;
     15    protected static array $commentRequestFieldOverrides = [];
    1516
    1617    public static function boot(): void
     
    3233        add_filter('registration_errors', [__CLASS__, 'verifyRegistrationCaptcha'], 20, 3);
    3334        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);
    3437        add_filter('pre_comment_approved', [__CLASS__, 'verifyCommentCaptcha'], 8, 2);
    3538    }
     
    174177
    175178        if (!self::isContextEnabled(self::CONTEXT_COMMENT) || !self::isProviderConfigured()) {
    176             return $approved;
    177         }
    178 
    179         if (defined('REST_REQUEST') && REST_REQUEST) {
    180179            return $approved;
    181180        }
     
    312311        $field = $provider === 'turnstile' ? 'cf-turnstile-response' : 'h-captcha-response';
    313312
     313        if (array_key_exists($field, self::$commentRequestFieldOverrides)) {
     314            return trim((string) self::$commentRequestFieldOverrides[$field]);
     315        }
     316
    314317        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;
    315357    }
    316358
  • vulntitan/trunk/includes/Services/CommentSpamService.php

    r3490735 r3495943  
    1212    protected const RATE_LIMIT_PREFIX = 'vulntitan_cs_rate_';
    1313    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;
    1416    protected const SHORT_COMMENT_MAX_WORDS = 3;
    1517    protected const SHORT_COMMENT_MAX_LENGTH = 40;
     
    2426     */
    2527    protected static ?array $pendingDecision = null;
     28    protected static array $requestFieldOverrides = [];
    2629
    2730    public static function boot(): void
     
    5053            $postId = (int) get_the_ID();
    5154        }
     55        if ($postId <= 0 && function_exists('get_queried_object_id')) {
     56            $postId = (int) get_queried_object_id();
     57        }
    5258
    5359        echo '<p class="comment-form-vulntitan-honeypot" style="position:absolute;left:-9999px;top:auto;width:1px;height:1px;overflow:hidden;" aria-hidden="true">';
     
    6066    public static function captureCommentDecision(array $commentData): array
    6167    {
     68        self::$requestFieldOverrides = [];
    6269        self::$pendingDecision = self::evaluateDecision($commentData, false);
    6370
     
    7178    public static function captureRestCommentDecision(array $preparedComment, $request): array
    7279    {
     80        self::$requestFieldOverrides = self::extractRestRequestFields($request);
    7381        self::$pendingDecision = self::evaluateDecision($preparedComment, true);
    7482
     
    195203        }
    196204
    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            );
    234256        }
    235257
     
    886908        $tokenPostId = (int) $postIdRaw;
    887909        $expected = wp_hash($timestampRaw . '|' . $postIdRaw . '|' . self::TOKEN_FIELD);
     910        $now = time();
     911        $age = max(0, $now - $timestamp);
    888912
    889913        if ($timestamp <= 0 || $tokenPostId < 0 || !hash_equals($expected, $signature)) {
     
    903927        }
    904928
     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
    905937        return [
    906938            'present' => true,
    907939            'valid' => true,
    908             'age' => max(0, time() - $timestamp),
     940            'age' => $age,
    909941        ];
    910942    }
    911943
     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
    912951    protected static function readRequestField(string $field): string
    913952    {
     953        if (array_key_exists($field, self::$requestFieldOverrides)) {
     954            return trim((string) self::$requestFieldOverrides[$field]);
     955        }
     956
    914957        if (!isset($_REQUEST[$field])) {
    915958            return '';
     
    923966        return trim((string) $value);
    924967    }
     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    }
    925985}
  • vulntitan/trunk/includes/Services/ContactFormSpamService.php

    r3485911 r3495943  
    99    protected const RATE_LIMIT_PREFIX = 'vulntitan_cf7_rate_';
    1010    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;
    1113
    1214    protected static bool $booted = false;
     
    558560        $tokenFormId = (int) $formIdRaw;
    559561        $expected = wp_hash($timestampRaw . '|' . $formIdRaw . '|' . self::TOKEN_FIELD);
     562        $now = time();
     563        $age = max(0, $now - $timestamp);
    560564
    561565        if ($timestamp <= 0 || $tokenFormId < 0 || !hash_equals($expected, $signature)) {
     
    575579        }
    576580
     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
    577589        return [
    578590            'present' => true,
    579591            'valid' => true,
    580             'age' => max(0, time() - $timestamp),
     592            'age' => $age,
    581593        ];
     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);
    582601    }
    583602
  • vulntitan/trunk/includes/Services/FirewallService.php

    r3490735 r3495943  
    11481148        $remoteAddr = isset($server['REMOTE_ADDR']) ? trim((string) $server['REMOTE_ADDR']) : '';
    11491149        $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);
    11541151
    11551152        if ($trustForwarded) {
     
    11991196        $trustCloudflare = !empty($settings['trust_cloudflare']);
    12001197        $isCloudflare = self::isCloudflareIp($remoteAddr);
    1201         $trustForwarded = self::isTrustedProxy($remoteAddr, $trustedProxies)
    1202             || ($trustCloudflare && $isCloudflare);
     1198        $trustForwarded = self::shouldTrustForwardedHeaders($remoteAddr, $settings);
    12031199
    12041200        return [
     
    12431239        }
    12441240
    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);
    12501256    }
    12511257
     
    12561262        }
    12571263
    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;
    12631284    }
    12641285
     
    15341555        );
    15351556        $lockoutAllowlist = self::sanitizeIpList($settings['lockout_allowlist_ips'] ?? $defaults['lockout_allowlist_ips']);
    1536         $trustedProxies = self::sanitizeIpList($settings['trusted_proxies'] ?? $defaults['trusted_proxies']);
     1557        $trustedProxies = self::sanitizeTrustedProxyList($settings['trusted_proxies'] ?? $defaults['trusted_proxies']);
    15371558        $weeklySummaryRecipient = sanitize_email((string) ($settings['weekly_summary_email_recipient'] ?? $defaults['weekly_summary_email_recipient']));
    15381559
     
    16431664    }
    16441665
     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
    16451722    protected static function sanitizeRoleList($value, array $fallback): array
    16461723    {
  • vulntitan/trunk/includes/Services/FluentFormSpamService.php

    r3485911 r3495943  
    99    protected const RATE_LIMIT_PREFIX = 'vulntitan_fluentforms_rate_';
    1010    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;
    1113    protected const BLOCK_MESSAGE = 'Your submission was blocked because the system detected spam or abuse. Remove excessive links and try again.';
    1214
     
    629631        $tokenFormId = (int) $formIdRaw;
    630632        $expected = wp_hash($timestampRaw . '|' . $formIdRaw . '|' . self::TOKEN_FIELD);
     633        $now = time();
     634        $age = max(0, $now - $timestamp);
    631635
    632636        if ($timestamp <= 0 || $tokenFormId < 0 || !hash_equals($expected, $signature)) {
     
    646650        }
    647651
     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
    648660        return [
    649661            'present' => true,
    650662            'valid' => true,
    651             'age' => max(0, time() - $timestamp),
     663            'age' => $age,
    652664        ];
     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);
    653672    }
    654673
  • vulntitan/trunk/includes/Services/LoginSecurityService.php

    r3483537 r3495943  
    2222    protected const CHALLENGE_TTL = 10 * MINUTE_IN_SECONDS;
    2323    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;
    2429    protected static bool $booted = false;
    2530
     
    108113        }
    109114
     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
    110123        $challengeId = self::createChallenge($user);
    111124        $redirectUrl = add_query_arg([
     
    144157        }
    145158
     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
    146171        if (strtoupper((string) ($_SERVER['REQUEST_METHOD'] ?? 'GET')) === 'POST') {
    147172            $nonce = isset($_POST[self::CHALLENGE_NONCE]) ? (string) wp_unslash($_POST[self::CHALLENGE_NONCE]) : '';
     
    161186                if ($isValid) {
    162187                    self::deleteChallenge($challengeId);
     188                    self::clearTwoFactorRateLimitState($user->ID, $ipAddress);
    163189                    self::finalizeAuthenticatedLogin(
    164190                        $user,
     
    181207                    'reason' => 'Invalid two-factor verification code.',
    182208                ]);
     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                }
    183220
    184221                $errorMessage = __('The verification code was invalid. Please try again.', 'vulntitan');
     
    625662    }
    626663
     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
    627755    protected static function renderChallengePage(string $title, string $message = '', array $context = [], bool $isFatal = false): void
    628756    {
  • vulntitan/trunk/readme.txt

    r3490735 r3495943  
    44Tested up to: 6.9
    55Requires PHP: 7.4
    6 Stable tag: 2.1.16
     6Stable tag: 2.1.17
    77License: GPLv2
    88License URI: https://www.gnu.org/licenses/gpl-2.0.html
     
    6060- XML-RPC allow, disable, or rate-limit policy controls with IP allowlisting
    6161- Weak-password blocking during profile updates, password resets, and compatible registrations
    62 - Comment Shield with honeypot, submit-time validation, duplicate detection, guest link limits, and IP rate limiting
     62- Comment Shield with honeypot, signed tokens, submit-time validation, duplicate detection, guest link limits, IP rate limiting, and moderation-aware logging
    6363- Form Shield for Contact Form 7 and Fluent Forms with honeypot, signed submit tokens, link heuristics, repeated-domain detection, and IP rate limiting
    6464- Form spam blocks are logged into the WAF/live feed with provider-aware source labels for easier review
    6565- 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
    6667- Configurable custom login slug so administrators can use a private login URL instead of the default `wp-login.php`
    6768- 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 spam statistics
     69- Weekly executive security report email with 7-day firewall, login abuse, WAF, form spam, and comment moderation statistics
    6970
    7071= Security-First Architecture =
     
    7273- Secure storage and cleanup of scan queues and logs
    7374- Hardened backup handling outside `ABSPATH` by default
     75- Hardened malware and integrity scan actions with stricter capability checks and in-root path validation
    7476- Adaptive performance tuning for safe large-site scanning
    7577
     
    174176
    175177== 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.
    176184
    177185= v2.1.16 - 25 Mar, 2026 =
  • vulntitan/trunk/vulntitan.php

    r3490735 r3495943  
    44 * Plugin URI: https://vulntitan.com/vulntitan/
    55 * 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.16
     6 * Version: 2.1.17
    77 * Author: Jaroslav Svetlik
    88 * Author URI: https://vulntitan.com
     
    3030
    3131// Define plugin constants
    32 define('VULNTITAN_PLUGIN_VERSION', VULNTITAN_DEVELOPMENT ? uniqid() : '2.1.16');
     32define('VULNTITAN_PLUGIN_VERSION', VULNTITAN_DEVELOPMENT ? uniqid() : '2.1.17');
    3333define('VULNTITAN_PLUGIN_BASENAME', plugin_basename(__FILE__));
    3434define('VULNTITAN_PLUGIN_DIR', untrailingslashit(plugin_dir_path(__FILE__)));
Note: See TracChangeset for help on using the changeset viewer.