Plugin Directory

Changeset 3492916


Ignore:
Timestamp:
03/27/2026 06:40:02 PM (14 hours ago)
Author:
supersoju
Message:

version 1.1 changes

Location:
block-logins-cf/trunk
Files:
2 edited

Legend:

Unmodified
Added
Removed
  • block-logins-cf/trunk/block-logins-cf.php

    r3489027 r3492916  
    44 * Plugin URI: https://github.com/supersoju/block-logins-cf
    55 * Description: Blocks failed login attempts directly through Cloudflare.
    6  * Version: 1.0
     6 * Version: 1.1
    77 * Author: supersoju
    88 * Author URI: https://supersoju.com
     
    142142    // Check if IP should be blocked
    143143    if ($failed_attempts >= $max_attempts) {
     144        $auto_unblock_expiry = intval($settings['auto_unblock_hours'] ?? 24) * HOUR_IN_SECONDS;
     145        set_transient("cfblocklogins_block_source_$ip", 'login', $auto_unblock_expiry);
    144146        cfblocklogins_block_ip($ip);
    145147        cfblocklogins_log_error("IP blocked after reaching attempt threshold", [
     
    167169function cfblocklogins_increment_failed_attempts_atomic($target, $settings, $type = 'ip') {
    168170    $lock_key = "cfblocklogins_lock_{$type}_{$target}";
    169     $attempt_key = "cfblocklogins_failed_login" . ($type === 'subnet' ? '_subnet' : '') . "_{$target}";
     171    if ($type === 'subnet') {
     172        $type_suffix = '_subnet';
     173    } elseif ($type === 'xmlrpc') {
     174        $type_suffix = '_xmlrpc';
     175    } elseif ($type === 'notfound') {
     176        $type_suffix = '_notfound';
     177    } else {
     178        $type_suffix = '';
     179    }
     180    $attempt_key = "cfblocklogins_failed_login{$type_suffix}_{$target}";
    170181    $block_duration = intval($settings['block_duration'] ?? 60);
    171182    $lock_timeout = 5; // 5 second lock timeout
     
    359370
    360371    cfblocklogins_log_error("Successfully blocked subnet via Cloudflare", ['subnet' => $subnet, 'rule_id' => $api_result['result']['id'] ?? 'unknown']);
     372    return true;
     373}
     374
     375/**
     376 * Fetch blocked IPs from Cloudflare that were created by this plugin.
     377 *
     378 * @return array{results: array, capped: bool}|WP_Error
     379 */
     380function cfblocklogins_fetch_cf_blocked_ips() {
     381    $credentials = cfblocklogins_get_api_credentials();
     382    $email   = $credentials['email']   ?? '';
     383    $api_key = $credentials['api_key'] ?? '';
     384    $zone_id = $credentials['zone_id'] ?? '';
     385
     386    if (!$email || !$api_key || !$zone_id) {
     387        return new WP_Error(
     388            'missing_credentials',
     389            __('Cloudflare credentials are not configured. Please check your settings.', 'block-logins-cf')
     390        );
     391    }
     392
     393    $results   = [];
     394    $page      = 1;
     395    $max_pages = 5;
     396    $capped    = false;
     397
     398    while ($page <= $max_pages) {
     399        $url      = "https://api.cloudflare.com/client/v4/zones/{$zone_id}/firewall/access_rules/rules?mode=block&per_page=100&page={$page}";
     400        $response = wp_remote_get($url, [
     401            'headers' => [
     402                'X-Auth-Email' => $email,
     403                'X-Auth-Key'   => $api_key,
     404                'Content-Type' => 'application/json',
     405            ],
     406            'timeout' => 15,
     407        ]);
     408
     409        $data = cfblocklogins_validate_api_response($response, "fetch_cf_blocked_ips:page_{$page}");
     410        if ($data === false) {
     411            return new WP_Error(
     412                'api_error',
     413                __('Failed to retrieve blocked IPs from Cloudflare. Please check your credentials and try again.', 'block-logins-cf')
     414            );
     415        }
     416
     417        $rules = $data['result'] ?? [];
     418        if (empty($rules)) {
     419            break;
     420        }
     421
     422        foreach ($rules as $rule) {
     423            $notes  = sanitize_text_field($rule['notes'] ?? '');
     424            if (strpos($notes, 'Auto-generated by Block Logins CF plugin') === false) {
     425                continue;
     426            }
     427            $config   = $rule['configuration'] ?? [];
     428            $ip_value = $config['value']        ?? '';
     429            $target   = $config['target']       ?? 'ip';
     430            if (!$ip_value) {
     431                continue;
     432            }
     433            $results[] = [
     434                'value'   => $ip_value,
     435                'target'  => $target,
     436                'rule_id' => $rule['id'] ?? '',
     437                'notes'   => $notes,
     438            ];
     439        }
     440
     441        $total_pages = intval($data['result_info']['total_pages'] ?? 1);
     442        if ($page >= $total_pages) {
     443            break;
     444        }
     445
     446        $page++;
     447    }
     448
     449    if ($page > $max_pages) {
     450        $capped = true;
     451    }
     452
     453    return ['results' => $results, 'capped' => $capped];
     454}
     455
     456/**
     457 * Create local transients for a blocked IP/subnet without calling the Cloudflare API.
     458 * Used when importing rules that already exist in Cloudflare.
     459 *
     460 * @param string $ip IP address or CIDR subnet.
     461 * @return bool True on success, false if $ip fails validation.
     462 */
     463function cfblocklogins_create_local_block($ip) {
     464    if (!cfblocklogins_validate_ip_or_cidr($ip)) {
     465        return false;
     466    }
     467
     468    $settings             = get_option('cfblocklogins_settings', []);
     469    $auto_unblock_hours   = intval($settings['auto_unblock_hours'] ?? 24);
     470    $auto_unblock_seconds = $auto_unblock_hours * 3600;
     471
     472    set_transient("cfblocklogins_block_login_$ip", '1', $auto_unblock_seconds);
     473    set_transient("cfblocklogins_block_login_time_$ip", time(), $auto_unblock_seconds);
     474
     475    wp_cache_delete('cfblocklogins_blocked_ip_transients', 'block-logins-cf');
     476    wp_cache_delete('cfblocklogins_blocked_ip_transients_cron', 'block-logins-cf');
     477
    361478    return true;
    362479}
     
    476593        ]);
    477594
     595        // Clear cached token validation so the next page load re-checks with the new token
     596        $cache_key = 'cfblocklogins_token_valid_' . substr(md5($input['api_key']), 0, 8);
     597        delete_transient($cache_key);
     598
    478599        // Encrypt sensitive credentials before saving
    479600        return cfblocklogins_encrypt_api_credentials($new_settings);
     
    490611        'xmlrpc_max_attempts' => intval($input['xmlrpc_max_attempts'] ?? ($current['xmlrpc_max_attempts'] ?? 3)),
    491612        'xmlrpc_block_duration' => intval($input['xmlrpc_block_duration'] ?? ($current['xmlrpc_block_duration'] ?? 300)),
     613        'enable_404_blocking'   => !empty($input['enable_404_blocking']) ? 1 : 0,
     614        '404_max_attempts'      => max(1, intval($input['404_max_attempts'] ?? ($current['404_max_attempts'] ?? 20))),
     615        '404_time_window'       => max(60, intval($input['404_time_window'] ?? ($current['404_time_window'] ?? 300))),
    492616        'whitelist' => $input['whitelist'], // preserve whitelist
    493617    ]);
     
    519643            xmlrpcCheckbox.addEventListener('change', function() {
    520644                xmlrpcRow.style.display = this.checked ? '' : 'none';
     645            });
     646        }
     647
     648        var notfoundCheckbox = document.getElementById('enable_404_blocking');
     649        var notfoundRow = document.getElementById('notfound-settings-row');
     650        if (notfoundCheckbox && notfoundRow) {
     651            notfoundCheckbox.addEventListener('change', function() {
     652                notfoundRow.style.display = this.checked ? '' : 'none';
    521653            });
    522654        }
     
    648780                    </td>
    649781                </tr>
     782                <tr valign="top">
     783                    <th scope="row" colspan="2"><strong><?php esc_html_e('404 Scan Protection', 'block-logins-cf'); ?></strong></th>
     784                </tr>
     785                <tr valign="top">
     786                    <th scope="row"><?php esc_html_e('Enable 404 Blocking', 'block-logins-cf'); ?></th>
     787                    <td>
     788                        <label>
     789                            <input type="checkbox" id="enable_404_blocking" name="cfblocklogins_settings[enable_404_blocking]" value="1" <?php checked(!empty($options['enable_404_blocking'])); ?> />
     790                            <?php esc_html_e('Block IPs that generate too many 404 responses.', 'block-logins-cf'); ?>
     791                        </label>
     792                        <p class="description">
     793                            <?php esc_html_e('Note: Automattic services (WordPress.com, Jetpack, VaultPress) and logged-in users are automatically excluded.', 'block-logins-cf'); ?>
     794                        </p>
     795                    </td>
     796                </tr>
     797                <tr valign="top" id="notfound-settings-row" <?php if (empty($options['enable_404_blocking'])) echo 'style="display:none;"'; ?>>
     798                    <th scope="row"><?php esc_html_e('404 Block Settings', 'block-logins-cf'); ?></th>
     799                    <td>
     800                        <p>
     801                            <?php esc_html_e('Block after', 'block-logins-cf'); ?>
     802                            <input type="number" min="1" style="width:70px;" name="cfblocklogins_settings[404_max_attempts]" value="<?php echo esc_attr($options['404_max_attempts'] ?? 20); ?>" />
     803                            <?php esc_html_e('404 responses within', 'block-logins-cf'); ?>
     804                            <input type="number" min="60" style="width:90px;" name="cfblocklogins_settings[404_time_window]" value="<?php echo esc_attr($options['404_time_window'] ?? 300); ?>" />
     805                            <?php esc_html_e('seconds', 'block-logins-cf'); ?>
     806                        </p>
     807                        <p class="description">
     808                            <?php esc_html_e('404 blocking uses separate thresholds from regular login blocking.', 'block-logins-cf'); ?>
     809                        </p>
     810                    </td>
     811                </tr>
    650812            </table>
    651813            <?php submit_button(esc_html__('Save Settings', 'block-logins-cf')); ?>
     
    655817        <h2><?php esc_html_e('Cloudflare API Credentials', 'block-logins-cf'); ?></h2>
    656818        <?php
    657         // Validate credentials again for display
     819        // Validate credentials again for display (cached for 5 minutes to avoid throttling)
    658820        $valid = false;
    659821        $debug = '';
    660         if (!empty($options['api_key'])) {
    661             $url = "https://api.cloudflare.com/client/v4/user/tokens/verify";
    662             $response = wp_remote_get($url, [
    663                 'headers' => [
    664                     'Authorization' => 'Bearer ' . $options['api_key'],
    665                     'Content-Type' => 'application/json',
    666                 ],
    667                 'timeout' => 10,
    668             ]);
    669 
    670             $api_result = cfblocklogins_validate_api_response($response, 'token_verify_display');
    671             $valid = ($api_result !== false);
    672             if (!$valid) {
    673                 $body = wp_remote_retrieve_body($response);
    674                 $debug_msg = is_wp_error($response) ? $response->get_error_message() : substr($body, 0, 200);
    675                 $debug = '<pre>' . esc_html($debug_msg) . '</pre>';
     822        if (!empty($decrypted_options['api_key'])) {
     823            $cache_key = 'cfblocklogins_token_valid_' . substr(md5($decrypted_options['api_key']), 0, 8);
     824            $cached = get_transient($cache_key);
     825            if ($cached !== false) {
     826                $valid = ( 'valid' === $cached );
     827            } else {
     828                $url = "https://api.cloudflare.com/client/v4/user/tokens/verify";
     829                $response = wp_remote_get($url, [
     830                    'headers' => [
     831                        'Authorization' => 'Bearer ' . $decrypted_options['api_key'],
     832                        'Content-Type' => 'application/json',
     833                    ],
     834                    'timeout' => 10,
     835                ]);
     836
     837                $api_result = cfblocklogins_validate_api_response($response, 'token_verify_display');
     838                $valid = ($api_result !== false);
     839                set_transient($cache_key, $valid ? 'valid' : 'invalid', 5 * MINUTE_IN_SECONDS);
     840                if (!$valid) {
     841                    $body = wp_remote_retrieve_body($response);
     842                    $debug_msg = is_wp_error($response) ? $response->get_error_message() : substr($body, 0, 200);
     843                    $debug = '<pre>' . esc_html($debug_msg) . '</pre>';
     844                }
    676845            }
    677846        }
     
    708877        $settings['last_whitelist_update'] = time(); // Optional: track last update time
    709878        $settings['whitelist'][] = $ip;
    710         $result = update_option('cfblocklogins_settings', $settings);
     879        update_option('cfblocklogins_settings', $settings);
    711880        wp_cache_delete('cfblocklogins_settings', 'options');
    712881    }
     
    717886    if (isset($settings['whitelist']) && is_array($settings['whitelist'])) {
    718887        $settings['whitelist'] = array_diff($settings['whitelist'], [$ip]);
    719         $result = update_option('cfblocklogins_settings', $settings);
     888        update_option('cfblocklogins_settings', $settings);
    720889        wp_cache_delete('cfblocklogins_settings', 'options');
    721890    }
     
    727896        wp_die(esc_html__('You do not have sufficient permissions to access this page.', 'block-logins-cf'));
    728897    }
     898
     899    $cf_only_ips    = null;
     900    $cf_sync_error  = null;
     901    $cf_sync_capped = false;
    729902
    730903    // Handle unblock
     
    746919        }
    747920        $ip = sanitize_text_field(wp_unslash($_POST['cfblocklogins_whitelist_ip']));
    748         if (filter_var($ip, FILTER_VALIDATE_IP)) {
     921        if (cfblocklogins_validate_ip_or_cidr($ip)) {
    749922            cfblocklogins_add_to_whitelist($ip);
    750             echo '<div class="updated"><p>Whitelisted IP: ' . esc_html($ip) . '</p></div>';
     923            echo '<div class="updated"><p>Whitelisted: ' . esc_html($ip) . '</p></div>';
    751924        } else {
    752             echo '<div class="error"><p>Invalid IP address.</p></div>';
     925            echo '<div class="error"><p>Invalid IP address or CIDR notation.</p></div>';
    753926        }
    754927    }
     
    777950    }
    778951
     952    // Handle Cloudflare sync fetch
     953    if (isset($_POST['cfblocklogins_fetch_cf_ips']) && check_admin_referer('cfblocklogins_fetch_cf_ips_action')) {
     954        if (!current_user_can('manage_options')) {
     955            wp_die(esc_html__('You do not have sufficient permissions to perform this action.', 'block-logins-cf'));
     956        }
     957        $fetch_result = cfblocklogins_fetch_cf_blocked_ips();
     958        if (is_wp_error($fetch_result)) {
     959            $cf_sync_error = $fetch_result->get_error_message();
     960        } else {
     961            $cf_sync_capped = $fetch_result['capped'];
     962            $cf_only_ips    = [];
     963            foreach ($fetch_result['results'] as $rule) {
     964                $ip = sanitize_text_field($rule['value']);
     965                if (!cfblocklogins_validate_ip_or_cidr($ip)) {
     966                    continue; // Skip malformed rules
     967                }
     968                if (!get_transient("cfblocklogins_block_login_$ip")) {
     969                    $cf_only_ips[] = $rule;
     970                }
     971            }
     972        }
     973    }
     974
     975    // Handle single IP import from Cloudflare
     976    if (isset($_POST['cfblocklogins_import_cf_ip']) && check_admin_referer('cfblocklogins_import_cf_ip_action')) {
     977        if (!current_user_can('manage_options')) {
     978            wp_die(esc_html__('You do not have sufficient permissions to perform this action.', 'block-logins-cf'));
     979        }
     980        $ip = sanitize_text_field(wp_unslash($_POST['cfblocklogins_import_ip'] ?? ''));
     981        if (cfblocklogins_create_local_block($ip)) {
     982            wp_safe_redirect(admin_url('admin.php?page=block-logins-cf-blocked'));
     983        } else {
     984            wp_safe_redirect(admin_url('admin.php?page=block-logins-cf-blocked&cf_import_error=1'));
     985        }
     986        exit;
     987    }
     988
     989    // Handle import all from Cloudflare
     990    if (isset($_POST['cfblocklogins_import_all_cf_ips']) && check_admin_referer('cfblocklogins_import_all_cf_ips_action')) {
     991        if (!current_user_can('manage_options')) {
     992            wp_die(esc_html__('You do not have sufficient permissions to perform this action.', 'block-logins-cf'));
     993        }
     994        // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- sanitized in loop below
     995        $raw_ips = isset($_POST['cfblocklogins_import_ips']) ? (array) wp_unslash($_POST['cfblocklogins_import_ips']) : [];
     996        foreach ($raw_ips as $ip) {
     997            cfblocklogins_create_local_block(sanitize_text_field($ip));
     998        }
     999        wp_safe_redirect(admin_url('admin.php?page=block-logins-cf-blocked'));
     1000        exit;
     1001    }
     1002
    7791003    // Find blocked IPs (transients)
    7801004    global $wpdb;
     
    7881012            $wpdb->prepare(
    7891013                "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE %s",
    790                 '_transient_cf_block_login_%'
     1014                '_transient_cfblocklogins_block_login_%'
    7911015            )
    7921016        );
     
    7961020    foreach ($transients as $transient) {
    7971021        $name = $transient->option_name;
    798         if (strpos($name, '_transient_cf_block_login_time_') === 0) {
     1022        if (strpos($name, '_transient_cfblocklogins_block_login_time_') === 0) {
    7991023            continue; // skip time transients
    8001024        }
    801         $ip = str_replace('_transient_cf_block_login_', '', $name);
     1025        $ip = str_replace('_transient_cfblocklogins_block_login_', '', $name);
    8021026        $blocked_ips[] = $ip;
    8031027    }
     
    8081032        <h1><?php esc_html_e('Blocked and Whitelisted IPs', 'block-logins-cf'); ?></h1>
    8091033        <h2><?php esc_html_e('Currently Blocked', 'block-logins-cf'); ?></h2>
     1034        <form method="post" style="margin-bottom:1em;">
     1035            <?php wp_nonce_field('cfblocklogins_fetch_cf_ips_action'); ?>
     1036            <input type="submit" name="cfblocklogins_fetch_cf_ips" class="button" value="<?php esc_attr_e('Sync from Cloudflare', 'block-logins-cf'); ?>">
     1037        </form>
     1038        <?php if (isset($_GET['cf_import_error']) && sanitize_text_field(wp_unslash($_GET['cf_import_error'])) === '1'): ?>
     1039            <div class="notice notice-error"><p><?php esc_html_e('Failed to import IP: invalid IP address or CIDR notation.', 'block-logins-cf'); ?></p></div>
     1040        <?php elseif ($cf_sync_error !== null): ?>
     1041            <div class="notice notice-error"><p><?php echo esc_html($cf_sync_error); ?></p></div>
     1042        <?php elseif ($cf_only_ips !== null && empty($cf_only_ips)): ?>
     1043            <div class="notice notice-success"><p><?php esc_html_e('No untracked IPs found in Cloudflare.', 'block-logins-cf'); ?></p></div>
     1044        <?php endif; ?>
    8101045        <table class="widefat">
    8111046            <thead>
    8121047                <tr>
    8131048                    <th><?php esc_html_e('IP Address', 'block-logins-cf'); ?></th>
     1049                    <th><?php esc_html_e('Source', 'block-logins-cf'); ?></th>
    8141050                    <th><?php esc_html_e('Time Until Unblock', 'block-logins-cf'); ?></th>
    8151051                    <th><?php esc_html_e('Action', 'block-logins-cf'); ?></th>
     
    8181054            <tbody>
    8191055                <?php if (empty($blocked_ips)): ?>
    820                     <tr><td colspan="3"><?php esc_html_e('No blocked IPs.', 'block-logins-cf'); ?></td></tr>
     1056                    <tr><td colspan="4"><?php esc_html_e('No blocked IPs.', 'block-logins-cf'); ?></td></tr>
    8211057                <?php else: foreach ($blocked_ips as $ip): ?>
    8221058                    <tr>
    8231059                        <td><?php echo esc_html($ip); ?></td>
     1060                        <td>
     1061                            <?php
     1062                            $source = get_transient("cfblocklogins_block_source_$ip");
     1063                            $source_labels = [
     1064                                'notfound' => '<span style="background:#d63638;color:#fff;padding:2px 6px;border-radius:3px;font-size:11px;">404 Scan</span>',
     1065                                'xmlrpc'   => '<span style="background:#2271b1;color:#fff;padding:2px 6px;border-radius:3px;font-size:11px;">XML-RPC</span>',
     1066                                'login'    => '<span style="background:#1d2327;color:#fff;padding:2px 6px;border-radius:3px;font-size:11px;">Login</span>',
     1067                            ];
     1068                            echo wp_kses($source_labels[$source] ?? $source_labels['login'], ['span' => ['style' => []]]);
     1069                            ?>
     1070                        </td>
    8241071                        <td>
    8251072                            <?php
     
    8541101            </tbody>
    8551102        </table>
    856        
     1103
     1104        <?php if ($cf_only_ips !== null && !empty($cf_only_ips)): ?>
     1105        <h2><?php esc_html_e('Cloudflare-only IPs', 'block-logins-cf'); ?></h2>
     1106        <p><?php esc_html_e('These IPs are blocked in Cloudflare by this plugin but not tracked locally. Import them to sync.', 'block-logins-cf'); ?></p>
     1107        <?php if ($cf_sync_capped): ?>
     1108            <div class="notice notice-warning inline"><p><?php esc_html_e('Showing first 500 rules. Additional rules may exist in Cloudflare.', 'block-logins-cf'); ?></p></div>
     1109        <?php endif; ?>
     1110        <form method="post" style="margin-bottom:1em;">
     1111            <?php wp_nonce_field('cfblocklogins_import_all_cf_ips_action'); ?>
     1112            <?php foreach ($cf_only_ips as $rule): ?>
     1113                <input type="hidden" name="cfblocklogins_import_ips[]" value="<?php echo esc_attr($rule['value']); ?>">
     1114            <?php endforeach; ?>
     1115            <input type="submit" name="cfblocklogins_import_all_cf_ips" class="button button-primary" value="<?php esc_attr_e('Import All', 'block-logins-cf'); ?>">
     1116        </form>
     1117        <table class="widefat">
     1118            <thead>
     1119                <tr>
     1120                    <th><?php esc_html_e('IP Address', 'block-logins-cf'); ?></th>
     1121                    <th><?php esc_html_e('Notes', 'block-logins-cf'); ?></th>
     1122                    <th><?php esc_html_e('Action', 'block-logins-cf'); ?></th>
     1123                </tr>
     1124            </thead>
     1125            <tbody>
     1126                <?php foreach ($cf_only_ips as $rule): ?>
     1127                <tr>
     1128                    <td><?php echo esc_html($rule['value']); ?></td>
     1129                    <td><?php echo esc_html($rule['notes']); ?></td>
     1130                    <td>
     1131                        <form method="post" style="display:inline;">
     1132                            <?php wp_nonce_field('cfblocklogins_import_cf_ip_action'); ?>
     1133                            <input type="hidden" name="cfblocklogins_import_ip" value="<?php echo esc_attr($rule['value']); ?>">
     1134                            <input type="submit" name="cfblocklogins_import_cf_ip" class="button" value="<?php esc_attr_e('Import', 'block-logins-cf'); ?>">
     1135                        </form>
     1136                    </td>
     1137                </tr>
     1138                <?php endforeach; ?>
     1139            </tbody>
     1140        </table>
     1141        <?php endif; ?>
     1142
    8571143        <h3><?php esc_html_e('Manually Block an IP', 'block-logins-cf'); ?></h3>
    8581144        <form method="post">
     
    8891175        <form method="post" style="margin-bottom:1em;">
    8901176            <?php wp_nonce_field('cfblocklogins_whitelist_ip_action'); ?>
    891             <input type="text" name="cfblocklogins_whitelist_ip" placeholder="<?php esc_attr_e('Enter IP address', 'block-logins-cf'); ?>" required>
     1177            <input type="text" name="cfblocklogins_whitelist_ip" placeholder="<?php esc_attr_e('IP or CIDR (e.g., 192.168.1.0/24)', 'block-logins-cf'); ?>" required>
    8921178            <input type="submit" class="button" value="<?php esc_attr_e('Add to Whitelist', 'block-logins-cf'); ?>">
    8931179        </form>
     1180
     1181        <?php
     1182        $settings = get_option('cfblocklogins_settings', []);
     1183        if (!empty($settings['enable_404_blocking'])):
     1184            $today   = gmdate('Y-m-d');
     1185            $log_404 = get_transient("cfblocklogins_404_access_$today");
     1186            ?>
     1187        <hr>
     1188        <h2><?php esc_html_e('404 Activity Today', 'block-logins-cf'); ?></h2>
     1189        <?php if (empty($log_404)): ?>
     1190            <p><?php esc_html_e('No 404 activity recorded today.', 'block-logins-cf'); ?></p>
     1191        <?php else: ?>
     1192            <table class="widefat" style="margin-bottom:1em;max-width:500px;">
     1193                <tbody>
     1194                    <tr>
     1195                        <th><?php esc_html_e('Total requests today', 'block-logins-cf'); ?></th>
     1196                        <td><?php echo esc_html($log_404['total_requests']); ?></td>
     1197                    </tr>
     1198                    <tr>
     1199                        <th><?php esc_html_e('Unique IPs', 'block-logins-cf'); ?></th>
     1200                        <td><?php echo esc_html(count($log_404['unique_ips'])); ?></td>
     1201                    </tr>
     1202                    <tr>
     1203                        <th><?php esc_html_e('Top missing paths', 'block-logins-cf'); ?></th>
     1204                        <td>
     1205                            <?php foreach ($log_404['top_paths'] as $path => $count): ?>
     1206                                <code><?php echo esc_html($path); ?></code> (<?php echo esc_html($count); ?>)<br>
     1207                            <?php endforeach; ?>
     1208                        </td>
     1209                    </tr>
     1210                </tbody>
     1211            </table>
     1212
     1213            <?php if (!empty($log_404['per_ip'])): ?>
     1214            <h3><?php esc_html_e('Per-IP Breakdown', 'block-logins-cf'); ?></h3>
     1215            <table class="widefat">
     1216                <thead>
     1217                    <tr>
     1218                        <th><?php esc_html_e('IP Address', 'block-logins-cf'); ?></th>
     1219                        <th><?php esc_html_e('Requests today', 'block-logins-cf'); ?></th>
     1220                        <th><?php esc_html_e('Threshold', 'block-logins-cf'); ?></th>
     1221                        <th><?php esc_html_e('Status', 'block-logins-cf'); ?></th>
     1222                    </tr>
     1223                </thead>
     1224                <tbody>
     1225                    <?php
     1226                    $max_attempts = intval($settings['404_max_attempts'] ?? 20);
     1227                    uasort($log_404['per_ip'], fn($a, $b) => $b['count'] <=> $a['count']);
     1228                    foreach ($log_404['per_ip'] as $per_ip_addr => $per_ip_data):
     1229                        $count      = $per_ip_data['count'];
     1230                        $is_blocked = (bool) get_transient("cfblocklogins_block_login_$per_ip_addr");
     1231                        if ($is_blocked) {
     1232                            $status = '<span style="color:#d63638;font-weight:bold;">Blocked</span>';
     1233                        } elseif ($count >= $max_attempts * 0.75) {
     1234                            $status = '<span style="color:#d63638;">Approaching</span>';
     1235                        } else {
     1236                            $status = '<span style="color:#00a32a;">Normal</span>';
     1237                        }
     1238                    ?>
     1239                    <tr>
     1240                        <td><?php echo esc_html($per_ip_addr); ?></td>
     1241                        <td><?php echo esc_html($count); ?></td>
     1242                        <td><?php echo esc_html($max_attempts); ?></td>
     1243                        <td><?php echo wp_kses($status, ['span' => ['style' => []]]); ?></td>
     1244                    </tr>
     1245                    <?php endforeach; ?>
     1246                </tbody>
     1247            </table>
     1248            <?php endif; ?>
     1249        <?php endif; ?>
     1250        <?php endif; ?>
    8941251
    8951252    </div>
     
    9041261    }
    9051262    // For IPv6 or invalid, return false or handle as needed
     1263    return false;
     1264}
     1265
     1266// Validate IP address or CIDR notation
     1267function cfblocklogins_validate_ip_or_cidr($input) {
     1268    // Check if it's a plain IP address
     1269    if (filter_var($input, FILTER_VALIDATE_IP)) {
     1270        return true;
     1271    }
     1272
     1273    // Check if it's CIDR notation
     1274    if (strpos($input, '/') !== false) {
     1275        list($ip, $bits) = explode('/', $input, 2);
     1276
     1277        // Validate the IP part
     1278        if (!filter_var($ip, FILTER_VALIDATE_IP)) {
     1279            return false;
     1280        }
     1281
     1282        // Validate the prefix length
     1283        $bits = intval($bits);
     1284        if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
     1285            // IPv4: prefix must be 0-32
     1286            return $bits >= 0 && $bits <= 32;
     1287        } elseif (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) {
     1288            // IPv6: prefix must be 0-128
     1289            return $bits >= 0 && $bits <= 128;
     1290        }
     1291    }
     1292
    9061293    return false;
    9071294}
     
    10291416
    10301417    list($subnet, $bits) = explode('/', $range);
     1418    $bits = intval($bits);
    10311419
    10321420    if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) &&
     
    10401428    }
    10411429
    1042     // IPv6 support could be added here in the future
     1430    // IPv6 CIDR check
     1431    if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) &&
     1432        filter_var($subnet, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) {
     1433        // Convert IPv6 addresses to binary strings
     1434        $ip_bin = inet_pton($ip);
     1435        $subnet_bin = inet_pton($subnet);
     1436
     1437        if ($ip_bin === false || $subnet_bin === false) {
     1438            return false;
     1439        }
     1440
     1441        // Build the netmask binary string
     1442        $mask = str_repeat('f', intval($bits / 4));
     1443        $remainder = $bits % 4;
     1444        if ($remainder > 0) {
     1445            $mask .= dechex(0xf << (4 - $remainder) & 0xf);
     1446        }
     1447        $mask = str_pad($mask, 32, '0');
     1448        $mask_bin = pack('H*', $mask);
     1449
     1450        // Compare masked addresses
     1451        return ($ip_bin & $mask_bin) === ($subnet_bin & $mask_bin);
     1452    }
     1453
    10431454    return false;
    10441455}
     
    12321643            $wpdb->prepare(
    12331644                "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE %s",
    1234                 '_transient_cf_block_login_%'
     1645                '_transient_cfblocklogins_block_login_%'
    12351646            )
    12361647        );
     
    12401651    foreach ($transients as $transient) {
    12411652        $name = $transient->option_name;
    1242         if (strpos($name, '_transient_cf_block_login_time_') === 0) {
     1653        if (strpos($name, '_transient_cfblocklogins_block_login_time_') === 0) {
    12431654            continue; // skip time transients
    12441655        }
    1245         $ip_or_subnet = str_replace('_transient_cf_block_login_', '', $name);
     1656        $ip_or_subnet = str_replace('_transient_cfblocklogins_block_login_', '', $name);
    12461657
    12471658        // Check if this transient should be expired
     
    13691780    ]);
    13701781}
     1782
     1783function cfblocklogins_log_404_access($ip) {
     1784    $today      = gmdate('Y-m-d');
     1785    $cache_key  = "cfblocklogins_404_access_$today";
     1786    $expiry     = 48 * HOUR_IN_SECONDS;
     1787
     1788    $log = get_transient($cache_key);
     1789    if (!is_array($log)) {
     1790        $log = [
     1791            'total_requests' => 0,
     1792            'unique_ips'     => [],
     1793            'top_paths'      => [],
     1794            'per_ip'         => [],
     1795            'first_seen'     => time(),
     1796            'last_seen'      => time(),
     1797        ];
     1798    }
     1799
     1800    // Get request path (strip query string)
     1801    $raw_uri = isset($_SERVER['REQUEST_URI']) ? sanitize_text_field(wp_unslash($_SERVER['REQUEST_URI'])) : '/';
     1802    $path    = explode('?', $raw_uri, 2)[0];
     1803
     1804    $log['total_requests']++;
     1805    $log['last_seen'] = time();
     1806
     1807    // Track unique IPs (cap at 100)
     1808    if (!in_array($ip, $log['unique_ips'], true) && count($log['unique_ips']) < 100) {
     1809        $log['unique_ips'][] = $ip;
     1810    }
     1811
     1812    // Track path counts (sort descending, keep top 10)
     1813    $log['top_paths'][$path] = ($log['top_paths'][$path] ?? 0) + 1;
     1814    arsort($log['top_paths']);
     1815    $log['top_paths'] = array_slice($log['top_paths'], 0, 10, true);
     1816
     1817    // Track per-IP counts (cap at 100 entries)
     1818    if (isset($log['per_ip'][$ip])) {
     1819        $log['per_ip'][$ip]['count']++;
     1820        $log['per_ip'][$ip]['last_seen'] = time();
     1821    } elseif (count($log['per_ip']) < 100) {
     1822        $log['per_ip'][$ip] = ['count' => 1, 'last_seen' => time()];
     1823    }
     1824
     1825    set_transient($cache_key, $log, $expiry);
     1826}
     1827
     1828function cfblocklogins_track_404() {
     1829    if (!is_404()) {
     1830        return;
     1831    }
     1832
     1833    if (is_user_logged_in()) {
     1834        return;
     1835    }
     1836
     1837    if (wp_doing_ajax() || (defined('REST_REQUEST') && REST_REQUEST)) {
     1838        return;
     1839    }
     1840
     1841    $settings = get_option('cfblocklogins_settings', []);
     1842
     1843    if (empty($settings['enable_404_blocking'])) {
     1844        return;
     1845    }
     1846
     1847    $ip = cfblocklogins_get_user_ip();
     1848
     1849    if (cfblocklogins_is_automattic_ip($ip)) {
     1850        return;
     1851    }
     1852
     1853    $whitelist = cfblocklogins_get_whitelist();
     1854    if (in_array($ip, $whitelist, true)) {
     1855        return;
     1856    }
     1857
     1858    if (cfblocklogins_check_if_blocked($ip)) {
     1859        return;
     1860    }
     1861
     1862    cfblocklogins_log_404_access($ip);
     1863
     1864    $max_attempts  = max(1, intval($settings['404_max_attempts'] ?? 20));
     1865    $time_window   = max(60, intval($settings['404_time_window'] ?? 300));
     1866
     1867    $result = cfblocklogins_increment_failed_attempts_atomic($ip, [
     1868        'block_duration' => $time_window,
     1869        'max_attempts'   => $max_attempts,
     1870    ], 'notfound');
     1871
     1872    if ($result && $result['attempts'] >= $max_attempts) {
     1873        $auto_unblock_expiry = intval($settings['auto_unblock_hours'] ?? 24) * HOUR_IN_SECONDS;
     1874        set_transient("cfblocklogins_block_source_$ip", 'notfound', $auto_unblock_expiry);
     1875        cfblocklogins_block_ip($ip);
     1876        cfblocklogins_log_error('IP blocked for 404 scanning', [
     1877            'ip'       => $ip,
     1878            'attempts' => $result['attempts'],
     1879            'threshold'=> $max_attempts,
     1880        ]);
     1881    }
     1882}
     1883add_action('template_redirect', 'cfblocklogins_track_404', 1);
    13711884
    13721885// Check if XML-RPC traffic suggests an attack
     
    14902003            delete_transient("cfblocklogins_xmlrpc_notification_shown_$today");
    14912004
    1492             wp_redirect(admin_url('admin.php?page=block-logins-cf&xmlrpc_enabled=1'));
     2005            wp_safe_redirect(admin_url('admin.php?page=block-logins-cf&xmlrpc_enabled=1'));
    14932006            exit;
    14942007        }
     
    15042017            delete_transient("cfblocklogins_xmlrpc_notification_shown_$today");
    15052018
    1506             wp_redirect(admin_url('admin.php?page=block-logins-cf&xmlrpc_dismissed=1'));
     2019            wp_safe_redirect(admin_url('admin.php?page=block-logins-cf&xmlrpc_dismissed=1'));
    15072020            exit;
    15082021        }
     
    15512064        // Check if this attempt should trigger blocking
    15522065        if ($attempts >= $max_attempts) {
     2066            $auto_unblock_expiry = intval($settings['auto_unblock_hours'] ?? 24) * HOUR_IN_SECONDS;
     2067            set_transient("cfblocklogins_block_source_$ip", 'xmlrpc', $auto_unblock_expiry);
    15532068            // Block the IP using existing function
    15542069            cfblocklogins_block_ip($ip);
  • block-logins-cf/trunk/readme.txt

    r3489027 r3492916  
    55Tested up to: 7.0
    66Requires PHP: 7.4
    7 Stable tag: 1.0
     7Stable tag: 1.1
    88License: GPL-2.0-or-later
    99
     
    1515
    1616- Block IPs via Cloudflare after X failed login attempts
     17- Block IPs that generate excessive 404 responses (bots and scanners)
     18- Block IPs attacking via XML-RPC with intelligent detection
    1719- Automatic unblocking after a configurable duration
    18 - Whitelist IPs to never block or track them
     20- Whitelist IPs to never block or track them (supports IPv6 CIDR ranges)
    1921- View and manually unblock blocked IPs from the admin
     22- Block source tracking — see whether each IP was blocked via login, XML-RPC, or 404
     23- Sync existing Cloudflare blocks into the local blocked IPs list
    2024- Secure settings page with Cloudflare API token validation
    2125- Hourly cron job for automatic maintenance
     
    2630
    2731**What is the Cloudflare API and what is it used for?**
    28 The Cloudflare API is a RESTful service provided by Cloudflare, Inc. that allows programmatic management of Cloudflare firewall rules. This plugin uses it to automatically block and unblock IP addresses based on failed login attempts.
     32The Cloudflare API is a RESTful service provided by Cloudflare, Inc. that allows programmatic management of Cloudflare firewall rules. This plugin uses it to automatically block and unblock IP addresses based on failed login attempts, XML-RPC attacks, and 404 scanning activity.
    2933
    3034**What data is sent and when?**
     
    3539   - Endpoint: `https://api.cloudflare.com/client/v4/user/tokens/verify`
    3640
    37 2. **When blocking an IP** (after failed login threshold is reached):
     412. **When blocking an IP** (after a threshold is reached):
    3842   - The IP address to be blocked
    3943   - Your Cloudflare email address and API key/token
     
    4246   - Endpoint: `https://api.cloudflare.com/client/v4/zones/{zone_id}/firewall/access_rules/rules`
    4347
    44 No personally identifiable information about your WordPress users is transmitted. Only IP addresses of failed login attempts are sent to Cloudflare.
     483. **When syncing from Cloudflare** (on demand):
     49   - Fetches existing firewall rules from your Cloudflare zone
     50   - Endpoint: `https://api.cloudflare.com/client/v4/zones/{zone_id}/firewall/access_rules/rules`
     51
     52No personally identifiable information about your WordPress users is transmitted. Only IP addresses are sent to Cloudflare.
    4553
    4654**Service provider information:**
     
    7179This plugin blocks IPs at the Cloudflare firewall, stopping attacks before they reach your server.
    7280
     81= What does 404 blocking protect against? =
     82It detects bots and vulnerability scanners that probe your site by requesting many non-existent URLs. When an IP exceeds the configurable 404 threshold, it is blocked via Cloudflare just like a brute-force login attacker.
     83
     84= Can I sync blocks I already have in Cloudflare? =
     85Yes. Use the "Sync from Cloudflare" button on the Blocked IPs page to import existing Cloudflare firewall rules into the plugin's local list.
     86
     87== Screenshots ==
     88
     891. **Settings Page:** Configure your Cloudflare credentials, blocking thresholds, and auto-unblock duration.
     902. **Blocked IPs Management:** View currently blocked IPs with source tracking, unblock them, and manage your whitelist.
     913. **Whitelist Management:** Add or remove IP addresses (including IPv6 CIDR ranges) from the whitelist.
     92
    7393== Changelog ==
     94
     95= 1.1 =
     96* Added 404-based IP blocking to detect and block bots and vulnerability scanners
     97* Added XML-RPC protection with intelligent attack detection
     98* Added block source tracking — blocked IPs now show whether they were blocked via login, XML-RPC, or 404
     99* Added 404 activity log in the Blocked IPs admin page
     100* Added "Sync from Cloudflare" to import existing Cloudflare firewall rules into the local list
     101* Added IPv6 CIDR range support in the IP whitelist
     102* Added caching for Cloudflare API token validation to prevent throttling
    74103
    75104= 1.0 =
     
    77106
    78107== Upgrade Notice ==
     108
     109= 1.1 =
     110Adds 404 blocking, XML-RPC protection, block source tracking, Cloudflare sync, and IPv6 CIDR whitelist support.
    79111
    80112= 1.0 =
Note: See TracChangeset for help on using the changeset viewer.