| @@ -2,9 +2,11 @@ | ||
| 2 | 2 | /** |
| 3 | 3 | * IP matching utilities |
| 4 | 4 | * |
| 5 | 5 | * Shared IP/pattern matching for the firewall and login modules. Supports |
| 6 | - * exact addresses, CIDR ranges and wildcards, for both IPv4 and IPv6. | |
| 6 | + * exact addresses, CIDR ranges and wildcards, for both IPv4 and IPv6. It is | |
| 7 | + * also where the plugin reads what the client sends about the request: the | |
| 8 | + * client address (get_client_ip()) and the request target (request_uri()). | |
| 7 | 9 | * |
| 8 | 10 | * @package Vigilante |
| 9 | 11 | */ |
| 10 | 12 | |
| @@ -39,10 +41,11 @@ | ||
| 39 | 41 | if ( '' === $ip || '' === $pattern ) { |
| 40 | 42 | return false; |
| 41 | 43 | } |
| 42 | 44 | |
| 43 | - // Exact match (also covers fully-written IPv6). | |
| 44 | - if ( $ip === $pattern ) { | |
| 45 | + // Exact match, comparing what the addresses ARE and not how they are | |
| 46 | + // written (see same_address()). | |
| 47 | + if ( self::same_address( $ip, $pattern ) ) { | |
| 45 | 48 | return true; |
| 46 | 49 | } |
| 47 | 50 | |
| 48 | 51 | // CIDR notation. |
| @@ -113,14 +116,24 @@ | ||
| 113 | 116 | if ( '' === $pattern || false !== strpos( $pattern, '*' ) ) { |
| 114 | 117 | continue; |
| 115 | 118 | } |
| 116 | 119 | |
| 117 | - if ( $ip === $pattern ) { | |
| 120 | + if ( self::same_address( $ip, $pattern ) ) { | |
| 118 | 121 | return true; |
| 119 | 122 | } |
| 120 | 123 | |
| 121 | - if ( false !== strpos( $pattern, '/' ) && self::cidr_match( $ip, $pattern ) ) { | |
| 122 | - return true; | |
| 124 | + if ( false !== strpos( $pattern, '/' ) ) { | |
| 125 | + // A range too wide to name anything is ignored here as well as | |
| 126 | + // rejected on the way in: an entry can arrive by import or from | |
| 127 | + // an older version, and it must not turn every peer into a | |
| 128 | + // trusted proxy. See proxy_prefix_is_sane(). | |
| 129 | + if ( ! self::proxy_prefix_is_sane( $pattern ) ) { | |
| 130 | + continue; | |
| 131 | + } | |
| 132 | + | |
| 133 | + if ( self::cidr_match( $ip, $pattern ) ) { | |
| 134 | + return true; | |
| 135 | + } | |
| 123 | 136 | } |
| 124 | 137 | } |
| 125 | 138 | |
| 126 | 139 | return false; |
| @@ -238,12 +251,117 @@ | ||
| 238 | 251 | */ |
| 239 | 252 | public static function is_valid_proxy( $pattern ) { |
| 240 | 253 | $pattern = trim( (string) $pattern ); |
| 241 | 254 | |
| 242 | - return false === strpos( $pattern, '*' ) && self::is_valid_pattern( $pattern ); | |
| 255 | + if ( false !== strpos( $pattern, '*' ) || ! self::is_valid_pattern( $pattern ) ) { | |
| 256 | + return false; | |
| 257 | + } | |
| 258 | + | |
| 259 | + return self::proxy_prefix_is_sane( $pattern ); | |
| 243 | 260 | } |
| 244 | 261 | |
| 245 | 262 | /** |
| 263 | + * Whether a CIDR entry is narrow enough to name a proxy | |
| 264 | + * | |
| 265 | + * A prefix length of zero matches every address, so 0.0.0.0/0 and ::/0 say | |
| 266 | + * exactly what the rejected '*' says, written as a CIDR. Rejecting the | |
| 267 | + * wildcard and accepting those was the same footgun with another spelling: | |
| 268 | + * with either one in the list every peer counts as a trusted proxy and any | |
| 269 | + * visitor picks the address the firewall sees, which is the forwarded-header | |
| 270 | + * spoofing (6.1) this list exists to prevent. Found by the file-by-file | |
| 271 | + * review of 2.11.10. | |
| 272 | + * | |
| 273 | + * Stopping at zero was not enough, and that is the second cross review of | |
| 274 | + * 2.11.10: 0.0.0.0/1 and 128.0.0.0/1 are two accepted entries that between | |
| 275 | + * them cover the whole internet, with the same effect and no warning. So the | |
| 276 | + * question is not "is it zero" but "can this range name a proxy". The floors | |
| 277 | + * are 8 for IPv4, the widest range that still names something real (the | |
| 278 | + * classic private network is 10.0.0.0/8), and 7 for IPv6, because fc00::/7 is | |
| 279 | + * how the whole IPv6 private space is written and this very class treats it | |
| 280 | + * as the own network in is_own_network(). A first version put the IPv6 floor | |
| 281 | + * at 16 and refused fc00::/7, fd00::/8 (what Docker hands out) and fe80::/10, | |
| 282 | + * so a list that already held one of them stopped honouring the header | |
| 283 | + * altogether and every visitor came out with the proxy's address: the tool | |
| 284 | + * contradicting itself about what a private network is. Found by the third | |
| 285 | + * cross review of 2.11.10. With 7, ::/0 and 2000::/3, which is all of the | |
| 286 | + * routable internet, are still refused. | |
| 287 | + * | |
| 288 | + * Measured against what the CDNs publish, and all of them pass: Cloudflare | |
| 289 | + * (/13, /15, /29), Fastly (/16, /32), Akamai (/10, /11, /13, /24), Sucuri | |
| 290 | + * (/22, /23), Bunny (/32), CloudFront (/15) and Google (/16, /22). | |
| 291 | + * | |
| 292 | + * Kept deliberately as a floor and not as a warning: an entry this wide is | |
| 293 | + * indistinguishable from the wildcard that is already refused, and the cost | |
| 294 | + * of being wrong is that any visitor chooses their own address. | |
| 295 | + * | |
| 296 | + * @since 2.11.10 | |
| 297 | + * | |
| 298 | + * @param string $pattern Address or CIDR range. | |
| 299 | + * @return bool True when it is an exact address or a narrow enough range. | |
| 300 | + */ | |
| 301 | + public static function proxy_prefix_is_sane( $pattern ) { | |
| 302 | + $pattern = trim( (string) $pattern ); | |
| 303 | + | |
| 304 | + if ( false === strpos( $pattern, '/' ) ) { | |
| 305 | + return true; | |
| 306 | + } | |
| 307 | + | |
| 308 | + $parts = explode( '/', $pattern, 2 ); | |
| 309 | + | |
| 310 | + if ( 2 !== count( $parts ) ) { | |
| 311 | + return false; | |
| 312 | + } | |
| 313 | + | |
| 314 | + $subnet = trim( $parts[0] ); | |
| 315 | + $bits = (int) trim( $parts[1] ); | |
| 316 | + $packed = inet_pton( $subnet ); | |
| 317 | + | |
| 318 | + if ( false === $packed ) { | |
| 319 | + return false; | |
| 320 | + } | |
| 321 | + | |
| 322 | + $minimo = ( 4 === strlen( $packed ) ) ? 8 : 7; | |
| 323 | + | |
| 324 | + return ( $bits >= $minimo ); | |
| 325 | + } | |
| 326 | + | |
| 327 | + /** | |
| 328 | + * Whether two written addresses are the same address | |
| 329 | + * | |
| 330 | + * Comparing the strings was enough for IPv4 and wrong for IPv6, where the | |
| 331 | + * same address has many spellings: 2001:DB8::1, 2001:db8::1 and | |
| 332 | + * 2001:0db8:0000:0000:0000:0000:0000:0001 are one address written three | |
| 333 | + * ways, and only the last two compared equal to each other. It mattered | |
| 334 | + * because the .htaccess side normalises with inet_pton()/inet_ntop() before | |
| 335 | + * writing its rule, so Apache exempted a peer that PHP did not recognise, | |
| 336 | + * which is the direction that opens something: measured against a real | |
| 337 | + * Apache by the second cross review of 2.11.10. | |
| 338 | + * | |
| 339 | + * Falls back to the string comparison when either side is not an address, so | |
| 340 | + * nothing that used to match stops matching. | |
| 341 | + * | |
| 342 | + * @since 2.11.10 | |
| 343 | + * | |
| 344 | + * @param string $a First address. | |
| 345 | + * @param string $b Second address. | |
| 346 | + * @return bool | |
| 347 | + */ | |
| 348 | + public static function same_address( $a, $b ) { | |
| 349 | + if ( $a === $b ) { | |
| 350 | + return true; | |
| 351 | + } | |
| 352 | + | |
| 353 | + $pa = inet_pton( $a ); | |
| 354 | + $pb = inet_pton( $b ); | |
| 355 | + | |
| 356 | + if ( false === $pa || false === $pb ) { | |
| 357 | + return false; | |
| 358 | + } | |
| 359 | + | |
| 360 | + return ( $pa === $pb ); | |
| 361 | + } | |
| 362 | + | |
| 363 | + /** | |
| 246 | 364 | * Split a list into the proxy addresses that are valid and the ones that are not. |
| 247 | 365 | * |
| 248 | 366 | * Like split_list(), but rejecting wildcards: the trusted proxies list feeds |
| 249 | 367 | * an identity decision, and only exact addresses and CIDR ranges belong there. |
| @@ -750,6 +868,42 @@ | ||
| 750 | 868 | } |
| 751 | 869 | } |
| 752 | 870 | |
| 753 | 871 | return self::resolve_client_ip( $server, $trusted, self::trusted_proxies() ); |
| 872 | + } | |
| 873 | + | |
| 874 | + /** | |
| 875 | + * The request target as the client wrote it: path and query, with the | |
| 876 | + * percent-encoding and the UTF-8 bytes intact. | |
| 877 | + * | |
| 878 | + * The one reader of REQUEST_URI for everything that is not the firewall's | |
| 879 | + * pattern haystack. sanitize_text_field() is the wrong tool for it: it | |
| 880 | + * deletes every %XX octet instead of decoding it (_sanitize_text_fields(), | |
| 881 | + * wp-includes/formatting.php), so /categor%C3%ADa/ came out as /categora/ | |
| 882 | + * and a path in Cyrillic as //. The redirects built from that value landed | |
| 883 | + * on a 404, the hidden login address written in a non-Latin script never | |
| 884 | + * matched, and the activity log kept the evidence with the evidence removed | |
| 885 | + * (measured on 1 oct 2026 against 3.0.1). | |
| 886 | + * | |
| 887 | + * esc_url_raw() keeps the %XX octets and the raw UTF-8, writes a space as | |
| 888 | + * %20, drops what a URL cannot carry (< > " { } ^ ` and control characters) | |
| 889 | + * and leaves a target that starts with a slash alone. Its liberties, none of | |
| 890 | + * which matters to the callers: [ ] become %5B %5D, a ;// becomes ://, and a | |
| 891 | + * target that does not start with a slash (OPTIONS *, an absolute-form | |
| 892 | + * request line) comes back with a scheme added or empty, which is why a | |
| 893 | + * caller that needs a path checks for the leading slash itself. | |
| 894 | + * | |
| 895 | + * Not for the firewall's pattern checks, which need the bytes untouched and | |
| 896 | + * read their own raw copy (Vigilante_Firewall::gather_request_data()). | |
| 897 | + * | |
| 898 | + * @since 3.0.2 | |
| 899 | + * | |
| 900 | + * @return string Path and query as sent, or '' when the server gives none. | |
| 901 | + */ | |
| 902 | + public static function request_uri() { | |
| 903 | + if ( ! isset( $_SERVER['REQUEST_URI'] ) || ! is_string( $_SERVER['REQUEST_URI'] ) ) { | |
| 904 | + return ''; | |
| 905 | + } | |
| 906 | + | |
| 907 | + return esc_url_raw( wp_unslash( $_SERVER['REQUEST_URI'] ) ); | |
| 754 | 908 | } |
| 755 | 909 | } |