| 1 |
<?php |
| 2 |
/** |
| 3 |
* RELEASE GUARD — per-visitor rate limiting (plan 284318). |
| 4 |
* |
| 5 |
* Three things must hold, or a logged-out cap does not hold either: |
| 6 |
* (a) the counter is keyed on an address the visitor cannot choose — |
| 7 |
* REMOTE_ADDR, or CF-Connecting-IP only when the request really came |
| 8 |
* from a Cloudflare edge; Client-IP / X-Forwarded-For are never read; |
| 9 |
* (b) the hourly reset parses counter names correctly and deletes a |
| 10 |
* counter only once its window has elapsed — the old split-on-'_' |
| 11 |
* parser mis-read every name and deleted every counter every hour; |
| 12 |
* (c) counters are written without autoload and the number of visitor |
| 13 |
* counters per bot is bounded. |
| 14 |
* |
| 15 |
* Run on a test site (it writes and removes its own rate-limit options and |
| 16 |
* briefly rewrites mxchat_options.rate_limits, restoring the original): |
| 17 |
* |
| 18 |
* wp eval-file wp-content/plugins/mxchat-basic/tests/verify-rate-limit-284318.php |
| 19 |
* |
| 20 |
* A build that prints FAIL is NOT releasable. |
| 21 |
*/ |
| 22 |
if (!defined('ABSPATH')) { |
| 23 |
exit; |
| 24 |
} |
| 25 |
|
| 26 |
global $mxchat_integrator, $wpdb; |
| 27 |
$integrator = ($mxchat_integrator instanceof MxChat_Integrator) ? $mxchat_integrator : new MxChat_Integrator(); |
| 28 |
|
| 29 |
$results = array(); |
| 30 |
$check = function ($name, $pass, $detail = '') use (&$results) { |
| 31 |
$results[] = array($name, (bool) $pass, (string) $detail); |
| 32 |
}; |
| 33 |
$call_private = function ($obj, $method, ...$args) { |
| 34 |
$m = new ReflectionMethod($obj, $method); |
| 35 |
$m->setAccessible(true); |
| 36 |
return $m->invoke($obj, ...$args); |
| 37 |
}; |
| 38 |
|
| 39 |
// ---------------------------------------------------------------- (a) client IP |
| 40 |
$saved_server = $_SERVER; |
| 41 |
$set = function ($remote, $extra = array()) { |
| 42 |
foreach (array('HTTP_CLIENT_IP', 'HTTP_X_FORWARDED_FOR', 'HTTP_CF_CONNECTING_IP', 'REMOTE_ADDR') as $k) { |
| 43 |
unset($_SERVER[$k]); |
| 44 |
} |
| 45 |
if ($remote !== null) { |
| 46 |
$_SERVER['REMOTE_ADDR'] = $remote; |
| 47 |
} |
| 48 |
foreach ($extra as $k => $v) { |
| 49 |
$_SERVER[$k] = $v; |
| 50 |
} |
| 51 |
}; |
| 52 |
|
| 53 |
$set('198.51.100.7', array('HTTP_X_FORWARDED_FOR' => '203.0.113.9, 198.51.100.7', 'HTTP_CLIENT_IP' => '203.0.113.8')); |
| 54 |
$check('a1 forged X-Forwarded-For / Client-IP ignored', MxChat_User::mxchat_get_client_ip() === '198.51.100.7', MxChat_User::mxchat_get_client_ip()); |
| 55 |
$check('a1b transcript identifier agrees', MxChat_User::mxchat_get_user_identifier() === '198.51.100.7', MxChat_User::mxchat_get_user_identifier()); |
| 56 |
$check('a1c integrator get_client_ip agrees', $call_private($integrator, 'get_client_ip') === '198.51.100.7'); |
| 57 |
|
| 58 |
$set('173.245.48.10', array('HTTP_CF_CONNECTING_IP' => '203.0.113.9')); |
| 59 |
$check('a2 CF-Connecting-IP honoured from a Cloudflare IPv4 edge', MxChat_User::mxchat_get_client_ip() === '203.0.113.9', MxChat_User::mxchat_get_client_ip()); |
| 60 |
|
| 61 |
$set('2606:4700::1234', array('HTTP_CF_CONNECTING_IP' => '2001:db8::7')); |
| 62 |
$check('a3 CF-Connecting-IP honoured from a Cloudflare IPv6 edge', MxChat_User::mxchat_get_client_ip() === '2001:db8::7', MxChat_User::mxchat_get_client_ip()); |
| 63 |
|
| 64 |
$set('198.51.100.7', array('HTTP_CF_CONNECTING_IP' => '203.0.113.9')); |
| 65 |
$check('a4 CF-Connecting-IP ignored when not from Cloudflare', MxChat_User::mxchat_get_client_ip() === '198.51.100.7', MxChat_User::mxchat_get_client_ip()); |
| 66 |
|
| 67 |
$set('173.245.48.10', array('HTTP_CF_CONNECTING_IP' => 'not-an-ip"onclick')); |
| 68 |
$check('a5 invalid CF-Connecting-IP falls back to REMOTE_ADDR', MxChat_User::mxchat_get_client_ip() === '173.245.48.10', MxChat_User::mxchat_get_client_ip()); |
| 69 |
|
| 70 |
$set('198.51.100.7'); |
| 71 |
$junk = function () { return 'garbage'; }; |
| 72 |
add_filter('mxchat_client_ip', $junk); |
| 73 |
$check('a6 filter returning junk is ignored', MxChat_User::mxchat_get_client_ip() === '198.51.100.7', MxChat_User::mxchat_get_client_ip()); |
| 74 |
remove_filter('mxchat_client_ip', $junk); |
| 75 |
$own = function () { return '203.0.113.77'; }; |
| 76 |
add_filter('mxchat_client_ip', $own); |
| 77 |
$check('a7 filter returning a valid address is used', MxChat_User::mxchat_get_client_ip() === '203.0.113.77', MxChat_User::mxchat_get_client_ip()); |
| 78 |
remove_filter('mxchat_client_ip', $own); |
| 79 |
|
| 80 |
$set(null); |
| 81 |
$check('a8 no REMOTE_ADDR at all -> unknown', MxChat_User::mxchat_get_client_ip() === 'unknown', MxChat_User::mxchat_get_client_ip()); |
| 82 |
|
| 83 |
$check('a9 cidr: 104.23.255.255 in 104.16.0.0/13', MxChat_User::mxchat_ip_in_cidr('104.23.255.255', '104.16.0.0/13')); |
| 84 |
$check('a10 cidr: 104.28.0.0 not in 104.16.0.0/13', !MxChat_User::mxchat_ip_in_cidr('104.28.0.0', '104.16.0.0/13')); |
| 85 |
$check('a11 cidr: 172.71.1.1 is Cloudflare, 172.72.0.0 is not', MxChat_User::mxchat_ip_is_cloudflare('172.71.1.1') && !MxChat_User::mxchat_ip_is_cloudflare('172.72.0.0')); |
| 86 |
$check('a12 cidr: 2a06:98c0::/29 covers 2a06:98c7::1, not 2a06:98c8::1', MxChat_User::mxchat_ip_in_cidr('2a06:98c7::1', '2a06:98c0::/29') && !MxChat_User::mxchat_ip_in_cidr('2a06:98c8::1', '2a06:98c0::/29')); |
| 87 |
$check('a13 cidr: family mismatch is false', !MxChat_User::mxchat_ip_in_cidr('2606:4700::1', '104.16.0.0/13')); |
| 88 |
|
| 89 |
$_SERVER = $saved_server; |
| 90 |
|
| 91 |
// ---------------------------------------------------------------- name parser |
| 92 |
$p = $integrator->mxchat_parse_rate_counter_name('mxchat_chat_limit_default_logged_out_203_0_113_1'); |
| 93 |
$check('p1 parse logged_out visitor', $p && $p['bot'] === 'default' && $p['role'] === 'logged_out' && $p['identifier'] === '203_0_113_1', json_encode($p)); |
| 94 |
$p = $integrator->mxchat_parse_rate_counter_name('mxchat_chat_limit_default_author_42'); |
| 95 |
$check('p2 parse author 42', $p && $p['bot'] === 'default' && $p['role'] === 'author' && $p['identifier'] === '42', json_encode($p)); |
| 96 |
$p = $integrator->mxchat_parse_rate_counter_name('mxchat_chat_limit_default_global'); |
| 97 |
$check('p3 parse global pool', $p && $p['bot'] === 'default' && $p['role'] === 'global', json_encode($p)); |
| 98 |
$p = $integrator->mxchat_parse_rate_counter_name('mxchat_chat_limit_sales_bot_2_logged_out_2001_db8__1'); |
| 99 |
$check('p4 parse bot id with underscores + IPv6 identifier', $p && $p['bot'] === 'sales_bot_2' && $p['role'] === 'logged_out' && $p['identifier'] === '2001_db8__1', json_encode($p)); |
| 100 |
$p = $integrator->mxchat_parse_rate_counter_name('mxchat_chat_limit_default_subscriber_7'); |
| 101 |
$check('p5 parse subscriber 7', $p && $p['role'] === 'subscriber' && $p['identifier'] === '7', json_encode($p)); |
| 102 |
$p = $integrator->mxchat_parse_rate_counter_name('mxchat_chat_limit_junk'); |
| 103 |
$check('p6 unparseable name -> null', $p === null, json_encode($p)); |
| 104 |
|
| 105 |
// ---------------------------------------------------------------- (b) the reset |
| 106 |
$opts_before = get_option('mxchat_options', array()); |
| 107 |
$opts = $opts_before; |
| 108 |
$opts['rate_limits'] = array( |
| 109 |
'logged_out' => array('limit' => '50', 'timeframe' => 'daily'), |
| 110 |
'author' => array('limit' => '100', 'timeframe' => 'daily'), |
| 111 |
); |
| 112 |
$opts['rate_limits_global'] = array('limit' => '1000', 'timeframe' => 'daily'); |
| 113 |
update_option('mxchat_options', $opts); |
| 114 |
wp_cache_flush(); |
| 115 |
|
| 116 |
$now = time(); |
| 117 |
$rows = array( |
| 118 |
'mxchat_chat_limit_default_logged_out_203_0_113_1' => array('count' => 3, 'timestamp' => $now - 60), |
| 119 |
'mxchat_chat_limit_default_logged_out_203_0_113_2' => array('count' => 3, 'timestamp' => $now - 25 * 3600), |
| 120 |
'mxchat_chat_limit_default_author_42' => array('count' => 1, 'timestamp' => $now - 60), |
| 121 |
'mxchat_chat_limit_default_global' => array('count' => 9, 'timestamp' => $now - 60), |
| 122 |
'mxchat_chat_limit_default_editor_5' => array('count' => 1, 'timestamp' => $now - 60), // role not configured -> orphan |
| 123 |
); |
| 124 |
foreach ($rows as $name => $data) { |
| 125 |
delete_option($name); |
| 126 |
$call_private($integrator, 'mxchat_save_rate_counter', $name, $data); |
| 127 |
} |
| 128 |
$integrator->mxchat_reset_rate_limits(); |
| 129 |
wp_cache_flush(); |
| 130 |
|
| 131 |
$check('b1 fresh logged_out counter survives the reset', get_option('mxchat_chat_limit_default_logged_out_203_0_113_1') !== false); |
| 132 |
$check('b2 25h-old logged_out counter is removed', get_option('mxchat_chat_limit_default_logged_out_203_0_113_2') === false); |
| 133 |
$check('b3 fresh author counter survives', get_option('mxchat_chat_limit_default_author_42') !== false); |
| 134 |
$check('b4 fresh global pool survives', get_option('mxchat_chat_limit_default_global') !== false); |
| 135 |
$check('b5 counter for an unconfigured role is the only orphan removed', get_option('mxchat_chat_limit_default_editor_5') === false); |
| 136 |
|
| 137 |
// ---------------------------------------------------------------- (c) autoload + cap |
| 138 |
$autoload = $wpdb->get_var($wpdb->prepare( |
| 139 |
"SELECT autoload FROM {$wpdb->options} WHERE option_name = %s", |
| 140 |
'mxchat_chat_limit_default_logged_out_203_0_113_1' |
| 141 |
)); |
| 142 |
$check('c1 counters are stored with autoload off', in_array($autoload, array('no', 'off'), true), var_export($autoload, true)); |
| 143 |
|
| 144 |
foreach (array_keys($rows) as $name) { |
| 145 |
delete_option($name); |
| 146 |
} |
| 147 |
$cap = function () { return 5; }; |
| 148 |
add_filter('mxchat_rate_limit_max_visitor_counters', $cap); |
| 149 |
for ($i = 1; $i <= 5; $i++) { |
| 150 |
$call_private($integrator, 'mxchat_save_rate_counter', 'mxchat_chat_limit_default_logged_out_10_0_0_' . $i, array('count' => 1, 'timestamp' => $now)); |
| 151 |
} |
| 152 |
$call_private($integrator, 'mxchat_cap_visitor_counters', 'default', 'logged_out'); |
| 153 |
remove_filter('mxchat_rate_limit_max_visitor_counters', $cap); |
| 154 |
$left = (int) $wpdb->get_var("SELECT COUNT(*) FROM {$wpdb->options} WHERE option_name LIKE 'mxchat\\_chat\\_limit\\_default\\_logged\\_out\\_10\\_0\\_0\\_%'"); |
| 155 |
$oldest_gone = get_option('mxchat_chat_limit_default_logged_out_10_0_0_1') === false; |
| 156 |
$newest_kept = get_option('mxchat_chat_limit_default_logged_out_10_0_0_5') !== false; |
| 157 |
$check('c2 at the cap the oldest visitor counter is evicted, the newest kept', $left === 4 && $oldest_gone && $newest_kept, "left=$left"); |
| 158 |
for ($i = 1; $i <= 5; $i++) { |
| 159 |
delete_option('mxchat_chat_limit_default_logged_out_10_0_0_' . $i); |
| 160 |
} |
| 161 |
|
| 162 |
// ---------------------------------------------------------------- restore |
| 163 |
update_option('mxchat_options', $opts_before); |
| 164 |
wp_cache_flush(); |
| 165 |
$check('z restore: mxchat_options back to the original', get_option('mxchat_options') === $opts_before); |
| 166 |
|
| 167 |
// ---------------------------------------------------------------- report |
| 168 |
$fail = 0; |
| 169 |
foreach ($results as $r) { |
| 170 |
if (!$r[1]) { |
| 171 |
$fail++; |
| 172 |
} |
| 173 |
echo ($r[1] ? 'PASS ' : 'FAIL ') . $r[0] . ($r[1] || $r[2] === '' ? '' : " -> " . $r[2]) . "\n"; |
| 174 |
} |
| 175 |
echo "\n" . (count($results) - $fail) . '/' . count($results) . ' passed' . ($fail ? ' — NOT releasable' : ' — releasable') . "\n"; |
| 176 |
|