| @@ -56,8 +56,17 @@ | ||
| 56 | 56 | */ |
| 57 | 57 | private $request_data = array(); |
| 58 | 58 | |
| 59 | 59 | /** |
| 60 | + * Memoized haystack the pattern checks run against | |
| 61 | + * | |
| 62 | + * @since 2.9.9 | |
| 63 | + * | |
| 64 | + * @var string|null | |
| 65 | + */ | |
| 66 | + private $haystack = null; | |
| 67 | + | |
| 68 | + /** | |
| 60 | 69 | * Constructor |
| 61 | 70 | * |
| 62 | 71 | * @param Vigilante_Settings $settings Settings instance. |
| 63 | 72 | * @param Vigilante_Activity_Log $activity_log Activity log instance. |
| @@ -84,23 +93,42 @@ | ||
| 84 | 93 | if ( $this->is_ip_whitelisted() ) { |
| 85 | 94 | return; |
| 86 | 95 | } |
| 87 | 96 | |
| 88 | - // Skip for whitelisted User-Agents (ManageWP, MainWP, etc.) | |
| 89 | - if ( $this->is_ua_whitelisted() ) { | |
| 90 | - return; | |
| 91 | - } | |
| 97 | + /* | |
| 98 | + * The User-Agent whitelist no longer skips the firewall. | |
| 99 | + * | |
| 100 | + * Until 2.11.1 a matching User-Agent returned here, before the IP | |
| 101 | + * blacklist and every request check, so anyone who guessed a | |
| 102 | + * configured substring ("ManageWP", "MainWP") walked past the SQL | |
| 103 | + * injection, script injection, file inclusion, traversal, bot and | |
| 104 | + * HTTP method rules by setting a header they control. A header a | |
| 105 | + * client chooses cannot stand in for an identity. Reported by the | |
| 106 | + * automated security review of wp.org on 9 sep 2026 and fixed in | |
| 107 | + * 2.11.2. | |
| 108 | + * | |
| 109 | + * What the option is actually for is keeping a remote manager from | |
| 110 | + * being turned away as a bot, so that is all it does now: it exempts | |
| 111 | + * the User-Agent rules, resolved further down, and nothing else. The | |
| 112 | + * list is empty by default, so only sites that had configured one were | |
| 113 | + * ever exposed. | |
| 114 | + */ | |
| 115 | + $ua_whitelisted = $this->is_ua_whitelisted(); | |
| 92 | 116 | |
| 117 | + // Gather request data first: a block is logged with the address it | |
| 118 | + // turned away, and until 2.11.1 the blacklist ran before this, so the | |
| 119 | + // entry for a blacklisted IP recorded no address at all. | |
| 120 | + $this->gather_request_data(); | |
| 121 | + | |
| 93 | 122 | // Check if IP is blacklisted |
| 94 | 123 | if ( $this->is_ip_blacklisted() ) { |
| 95 | 124 | $this->block_request( 'ip_blacklisted', __( 'IP address is blacklisted', 'vigilante' ) ); |
| 96 | 125 | } |
| 97 | 126 | |
| 98 | - // Gather request data | |
| 99 | - $this->gather_request_data(); | |
| 100 | - | |
| 101 | - // Check if User-Agent is blacklisted (after gathering request data) | |
| 102 | - if ( $this->is_ua_blacklisted() ) { | |
| 127 | + // Check if User-Agent is blacklisted (after gathering request data). | |
| 128 | + // An explicitly whitelisted agent still wins over the blacklist, which | |
| 129 | + // is what an administrator who wrote it there expects. | |
| 130 | + if ( ! $ua_whitelisted && $this->is_ua_blacklisted() ) { | |
| 103 | 131 | $this->block_request( 'ua_blacklisted', __( 'User-Agent is blacklisted', 'vigilante' ) ); |
| 104 | 132 | } |
| 105 | 133 | |
| 106 | 134 | // Run security checks |
| @@ -117,9 +145,16 @@ | ||
| 117 | 145 | 'block_bad_bots' => 'check_bad_bots', |
| 118 | 146 | 'block_empty_user_agent' => 'check_empty_user_agent', |
| 119 | 147 | ); |
| 120 | 148 | |
| 149 | + // The rules a whitelisted User-Agent is exempt from, and only these. | |
| 150 | + $ua_rules = array( 'block_bad_bots', 'block_empty_user_agent' ); | |
| 151 | + | |
| 121 | 152 | foreach ( $checks as $option => $method ) { |
| 153 | + if ( $ua_whitelisted && in_array( $option, $ua_rules, true ) ) { | |
| 154 | + continue; | |
| 155 | + } | |
| 156 | + | |
| 122 | 157 | if ( ! empty( $this->options[ $option ] ) && method_exists( $this, $method ) ) { |
| 123 | 158 | $result = $this->$method(); |
| 124 | 159 | if ( is_string( $result ) ) { |
| 125 | 160 | $this->block_request( $option, $result ); |
| @@ -136,11 +171,38 @@ | ||
| 136 | 171 | /** |
| 137 | 172 | * Gather current request data |
| 138 | 173 | */ |
| 139 | 174 | private function gather_request_data() { |
| 175 | + $this->haystack = null; | |
| 176 | + | |
| 140 | 177 | $this->request_data = array( |
| 141 | 178 | 'uri' => isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '', |
| 142 | 179 | 'query_string'=> isset( $_SERVER['QUERY_STRING'] ) ? sanitize_text_field( wp_unslash( $_SERVER['QUERY_STRING'] ) ) : '', |
| 180 | + /* | |
| 181 | + * Copies that keep the percent encoding, used only as the haystack | |
| 182 | + * of the pattern checks and never logged, printed or stored. | |
| 183 | + * | |
| 184 | + * They exist because sanitize_text_field() deletes every %XX | |
| 185 | + * sequence instead of decoding it: the copies above are the payload | |
| 186 | + * with the evidence removed, so an encoded attack was invisible to | |
| 187 | + * every rule that reads them. Measured on 22 aug 2026 against 2.9.8, | |
| 188 | + * ?x=%3Cscript%3E, javascript%3A, php%3A%2F%2F and GLOBALS%5B all | |
| 189 | + * reached the checks as harmless text and went straight through. | |
| 190 | + * | |
| 191 | + * No sanitizer is applied, and that is the point: every one of them | |
| 192 | + * destroys exactly what has to be matched. sanitize_text_field() | |
| 193 | + * deletes the %XX sequences and strips tags. esc_url_raw() is worse | |
| 194 | + * here: measured on 22 aug 2026, it returns an empty string for a | |
| 195 | + * query that carries an unencoded :// , which is precisely the | |
| 196 | + * remote inclusion shape, so it would blind the firewall instead of | |
| 197 | + * arming it. These two values are never echoed, never stored and | |
| 198 | + * never reach a query; they are the haystack of preg_match() and | |
| 199 | + * nothing else. | |
| 200 | + */ | |
| 201 | + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- inspection buffer for the pattern checks, see the note above. Sanitizing it is what hid the attacks. Never output, stored nor queried. | |
| 202 | + 'uri_raw' => isset( $_SERVER['REQUEST_URI'] ) ? wp_unslash( $_SERVER['REQUEST_URI'] ) : '', | |
| 203 | + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- same as uri_raw. | |
| 204 | + 'query_raw' => isset( $_SERVER['QUERY_STRING'] ) ? wp_unslash( $_SERVER['QUERY_STRING'] ) : '', | |
| 143 | 205 | 'user_agent' => isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : '', |
| 144 | 206 | 'referer' => isset( $_SERVER['HTTP_REFERER'] ) ? esc_url_raw( wp_unslash( $_SERVER['HTTP_REFERER'] ) ) : '', |
| 145 | 207 | 'method' => isset( $_SERVER['REQUEST_METHOD'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) : 'GET', |
| 146 | 208 | 'ip' => $this->get_client_ip(), |
| @@ -147,19 +209,51 @@ | ||
| 147 | 209 | ); |
| 148 | 210 | } |
| 149 | 211 | |
| 150 | 212 | /** |
| 213 | + * What the pattern checks run against: the request as it arrived, plus its decoded form | |
| 214 | + * | |
| 215 | + * Both forms on purpose. Some patterns look for the encoded shape, such as | |
| 216 | + * the null byte %00 or the %5b of GLOBALS[, and others for the decoded one, | |
| 217 | + * such as <script or ../. Feeding only one of the two leaves half the rules | |
| 218 | + * looking at something that cannot match. | |
| 219 | + * | |
| 220 | + * Decoded once, not twice: a second pass catches a bit more evasion and | |
| 221 | + * brings in false positives that are not worth it. | |
| 222 | + * | |
| 223 | + * @since 2.9.9 | |
| 224 | + * | |
| 225 | + * @return string | |
| 226 | + */ | |
| 227 | + private function inspection_haystack() { | |
| 228 | + if ( null !== $this->haystack ) { | |
| 229 | + return $this->haystack; | |
| 230 | + } | |
| 231 | + | |
| 232 | + $raw = trim( (string) $this->request_data['uri_raw'] . ' ' . (string) $this->request_data['query_raw'] ); | |
| 233 | + $decoded = rawurldecode( $raw ); | |
| 234 | + | |
| 235 | + $this->haystack = ( $raw === $decoded ) ? $raw : $raw . ' ' . $decoded; | |
| 236 | + | |
| 237 | + return $this->haystack; | |
| 238 | + } | |
| 239 | + | |
| 240 | + /** | |
| 151 | 241 | * Check for malicious query strings |
| 152 | 242 | * |
| 153 | 243 | * @return string|false Error message or false if safe. |
| 154 | 244 | */ |
| 155 | 245 | private function check_query_strings() { |
| 156 | - $query = $this->request_data['query_string']; | |
| 246 | + $query = $this->request_data['query_raw']; | |
| 157 | 247 | |
| 158 | 248 | if ( empty( $query ) ) { |
| 159 | 249 | return false; |
| 160 | 250 | } |
| 161 | 251 | |
| 252 | + // Length is measured on the query alone, the rest of the patterns run | |
| 253 | + // against the whole request in both its raw and decoded forms. | |
| 254 | + $haystack = $this->inspection_haystack(); | |
| 255 | + | |
| 162 | 256 | // Dangerous patterns |
| 163 | 257 | $patterns = array( |
| 164 | 258 | // Too long query strings |
| 165 | 259 | '/^.{4000,}$/s' => __( 'Query string too long', 'vigilante' ), |
| @@ -183,9 +277,13 @@ | ||
| 183 | 277 | '/document\.(cookie|location|write)/i' => __( 'DOM manipulation attempt', 'vigilante' ), |
| 184 | 278 | ); |
| 185 | 279 | |
| 186 | 280 | foreach ( $patterns as $pattern => $message ) { |
| 187 | - if ( preg_match( $pattern, $query ) ) { | |
| 281 | + // The length rule is anchored, so it has to see the query on its | |
| 282 | + // own; every other pattern gets the whole request. | |
| 283 | + $subject = ( '/^.{4000,}$/s' === $pattern ) ? $query : $haystack; | |
| 284 | + | |
| 285 | + if ( preg_match( $pattern, $subject ) ) { | |
| 188 | 286 | return $message; |
| 189 | 287 | } |
| 190 | 288 | } |
| 191 | 289 | |
| @@ -204,10 +302,9 @@ | ||
| 204 | 302 | return false; |
| 205 | 303 | } |
| 206 | 304 | |
| 207 | 305 | $to_check = array( |
| 208 | - $this->request_data['query_string'], | |
| 209 | - $this->request_data['uri'], | |
| 306 | + $this->inspection_haystack(), | |
| 210 | 307 | ); |
| 211 | 308 | |
| 212 | 309 | // Check POST data, but exclude content fields that may contain legitimate code/text |
| 213 | 310 | // phpcs:ignore WordPress.Security.NonceVerification.Missing |
| @@ -290,21 +387,16 @@ | ||
| 290 | 387 | * |
| 291 | 388 | * @return string|false Error message or false if safe. |
| 292 | 389 | */ |
| 293 | 390 | private function check_xss_attacks() { |
| 294 | - $to_check = array( | |
| 295 | - $this->request_data['query_string'], | |
| 296 | - $this->request_data['uri'], | |
| 297 | - ); | |
| 391 | + $combined = $this->inspection_haystack(); | |
| 298 | 392 | |
| 299 | - $combined = implode( ' ', array_filter( $to_check ) ); | |
| 300 | - | |
| 301 | 393 | if ( empty( $combined ) ) { |
| 302 | 394 | return false; |
| 303 | 395 | } |
| 304 | 396 | |
| 305 | - // URL decode for checking | |
| 306 | - $decoded = urldecode( $combined ); | |
| 397 | + // Already carries the decoded form, see inspection_haystack(). | |
| 398 | + $decoded = $combined; | |
| 307 | 399 | |
| 308 | 400 | // XSS patterns |
| 309 | 401 | $patterns = array( |
| 310 | 402 | // Script tags |
| @@ -309,10 +401,17 @@ | ||
| 309 | 401 | $patterns = array( |
| 310 | 402 | // Script tags |
| 311 | 403 | '/<script[^>]*>/i' => __( 'Script tag detected', 'vigilante' ), |
| 312 | 404 | |
| 313 | - // Event handlers | |
| 314 | - '/\bon\w+\s*=/i' => __( 'Event handler detected', 'vigilante' ), | |
| 405 | + /* | |
| 406 | + * Event handlers. Two shapes, because the rule used to be a bare | |
| 407 | + * \bon\w+\s*= and that matches any parameter whose name starts | |
| 408 | + * with "on": only=, once=, online= and onboarding= were all | |
| 409 | + * answered with a 403 on every site with the firewall on, and the | |
| 410 | + * owner never saw it because it only hits visitors. | |
| 411 | + */ | |
| 412 | + '/<[^>]*\bon\w+\s*=/i' => __( 'Event handler detected', 'vigilante' ), | |
| 413 | + '/\bon(abort|blur|change|click|contextmenu|copy|cut|dblclick|drag\w*|drop|error|focus\w*|input|invalid|key\w+|load\w*|mouse\w+|paste|pointer\w+|reset|resize|scroll|select|submit|toggle|touch\w+|transitionend|animation\w+|wheel)\s*=\s*["\']?\s*[\w.$]+\s*\(/i' => __( 'Event handler detected', 'vigilante' ), | |
| 315 | 414 | |
| 316 | 415 | // JavaScript protocol |
| 317 | 416 | '/javascript\s*:/i' => __( 'JavaScript protocol detected', 'vigilante' ), |
| 318 | 417 | |
| @@ -346,21 +445,31 @@ | ||
| 346 | 445 | * |
| 347 | 446 | * @return string|false Error message or false if safe. |
| 348 | 447 | */ |
| 349 | 448 | private function check_file_inclusion() { |
| 350 | - $uri = $this->request_data['uri']; | |
| 351 | - $query = $this->request_data['query_string']; | |
| 352 | - $combined = $uri . ' ' . $query; | |
| 449 | + $combined = $this->inspection_haystack(); | |
| 353 | 450 | |
| 354 | 451 | if ( empty( $combined ) ) { |
| 355 | 452 | return false; |
| 356 | 453 | } |
| 357 | 454 | |
| 455 | + // Remote inclusion is decided on the parsed values, not on the raw | |
| 456 | + // string. Until 2.9.9 any '=' followed by an absolute URL tripped this | |
| 457 | + // rule, and legitimate links carry those all the time: a redirect_to | |
| 458 | + // back to the site itself, a return_url, a payment gateway callback. | |
| 459 | + // What makes it an inclusion attempt is the target being somewhere | |
| 460 | + // else, so a URL pointing at this very site is left alone. | |
| 461 | + // | |
| 462 | + // The core endpoint that resolves an embed takes an external URL as | |
| 463 | + // its whole job, so it is exempted rather than made to look innocent. | |
| 464 | + // Only this check is skipped: the PHP wrappers and the system paths | |
| 465 | + // below still run on that route, for everyone. | |
| 466 | + if ( ! $this->is_core_embed_proxy_request() && $this->has_remote_inclusion() ) { | |
| 467 | + return __( 'Remote file inclusion attempt', 'vigilante' ); | |
| 468 | + } | |
| 469 | + | |
| 358 | 470 | // File inclusion patterns |
| 359 | 471 | $patterns = array( |
| 360 | - // Remote file inclusion | |
| 361 | - '/=\s*(https?|ftp):\/\//i' => __( 'Remote file inclusion attempt', 'vigilante' ), | |
| 362 | - | |
| 363 | 472 | // PHP wrappers |
| 364 | 473 | '/(php|zip|glob|phar|ssh2|rar|ogg|expect):\/\//i' => __( 'PHP wrapper detected', 'vigilante' ), |
| 365 | 474 | |
| 366 | 475 | // System files |
| @@ -380,16 +489,284 @@ | ||
| 380 | 489 | return false; |
| 381 | 490 | } |
| 382 | 491 | |
| 383 | 492 | /** |
| 493 | + * Parameter names a remote inclusion payload travels in | |
| 494 | + * | |
| 495 | + * An inclusion needs its value to reach an include() or a require(), so it | |
| 496 | + * arrives in the parameter a vulnerable script treats as a path. These are | |
| 497 | + * the names those scripts use, and the ones every RFI scanner probes. | |
| 498 | + * | |
| 499 | + * Deliberately absent: url, redirect, redirect_to, return, return_url, | |
| 500 | + * callback and the rest of the link-carrying names. Carrying a URL is what | |
| 501 | + * those are for. Matching them is what turned WordPress core's own oembed | |
| 502 | + * proxy into a 403 for anyone using the block editor, reported on 9 sep | |
| 503 | + * 2026 and fixed in 2.11.1. | |
| 504 | + * | |
| 505 | + * @since 2.11.1 | |
| 506 | + * | |
| 507 | + * @var string[] | |
| 508 | + */ | |
| 509 | + private static $inclusion_param_names = array( | |
| 510 | + // The value is read as a file | |
| 511 | + 'file', 'files', 'filename', 'file_name', 'filepath', 'file_path', | |
| 512 | + 'archivo', 'arquivo', 'fichier', 'datei', | |
| 513 | + // ...or as the place to read it from | |
| 514 | + 'path', 'paths', 'dir', 'directory', 'folder', 'root', 'base', | |
| 515 | + 'basepath', 'base_path', 'abs_path', 'absolute_path', | |
| 516 | + 'mosconfig_absolute_path', | |
| 517 | + // ...or as the page a front controller includes | |
| 518 | + 'page', 'pag', 'pagina', 'pageweb', 'pg', 'seite', | |
| 519 | + 'include', 'includes', 'inc', 'incl', 'require', 'load', 'loadfile', | |
| 520 | + 'open', 'read', 'readfile', 'show', 'display', 'view', 'content', | |
| 521 | + // ...or as a template, which is a file by another name | |
| 522 | + 'template', 'templates', 'tpl', 'tmpl', 'theme', 'skin', 'style', | |
| 523 | + 'layout', 'doc', 'document', 'module', 'mod', 'plugin', 'controller', | |
| 524 | + 'class', 'func', 'function', 'lang', 'language', | |
| 525 | + // ...or as configuration, or straight into a shell | |
| 526 | + 'conf', 'config', 'cfg', 'src', 'source', 'download', | |
| 527 | + 'cmd', 'exec', 'shell', 'system', | |
| 528 | + ); | |
| 529 | + | |
| 530 | + /** | |
| 531 | + * Extensions a remote inclusion payload is served with | |
| 532 | + * | |
| 533 | + * The point of the target is that it carries code, or text the vulnerable | |
| 534 | + * script will treat as code. Media extensions are not here on purpose: an | |
| 535 | + * embedded .mp4 or .jpg from a CDN is what the editor sends all day. | |
| 536 | + * | |
| 537 | + * @since 2.11.1 | |
| 538 | + * | |
| 539 | + * @var string[] | |
| 540 | + */ | |
| 541 | + private static $inclusion_extensions = array( | |
| 542 | + 'php', 'php2', 'php3', 'php4', 'php5', 'php6', 'php7', 'php8', | |
| 543 | + 'phps', 'phtml', 'pht', 'phar', 'inc', 'txt', 'log', 'ini', 'cfg', | |
| 544 | + 'conf', 'asp', 'aspx', 'jsp', 'jspx', 'cgi', 'pl', 'py', 'rb', 'sh', | |
| 545 | + 'bash', 'exe', 'dll', 'so', 'bak', 'old', 'env', | |
| 546 | + ); | |
| 547 | + | |
| 548 | + /** | |
| 549 | + * Whether the request carries a remote file inclusion attempt | |
| 550 | + * | |
| 551 | + * Works on the parsed parameters rather than on a pattern match over the | |
| 552 | + * whole string, for two reasons: a link back to the site itself is not | |
| 553 | + * mistaken for an attack, and an encoded payload is seen for what it is. | |
| 554 | + * The copy of the query string kept for logging goes through | |
| 555 | + * sanitize_text_field(), which strips every %XX sequence instead of | |
| 556 | + * decoding it, so the encoded form never looked like a URL there. | |
| 557 | + * | |
| 558 | + * Until 2.11.0 an external URL in any parameter was the whole signature, | |
| 559 | + * and that is not what an inclusion looks like, it is what a link looks | |
| 560 | + * like. WordPress core's own /wp-json/oembed/1.0/proxy?url=... is the | |
| 561 | + * clearest case: the block editor asks the site to resolve a YouTube URL, | |
| 562 | + * the rule read it as RFI, and embedding was dead on every site with the | |
| 563 | + * rule on while the classic editor kept working, because it posts the same | |
| 564 | + * URL to admin-ajax instead of putting it in a query string. Since 2.11.1 | |
| 565 | + * an external URL is an inclusion attempt when it travels in a parameter | |
| 566 | + * that is read as a path, or when it points at something includable. | |
| 567 | + * | |
| 568 | + * @since 2.9.9 | |
| 569 | + * | |
| 570 | + * @return bool | |
| 571 | + */ | |
| 572 | + private function has_remote_inclusion() { | |
| 573 | + $query = $this->request_data['query_raw']; | |
| 574 | + | |
| 575 | + if ( '' === $query ) { | |
| 576 | + return false; | |
| 577 | + } | |
| 578 | + | |
| 579 | + // parse_str() decodes as it splits, so this sees the same values PHP | |
| 580 | + // would have put in $_GET, without reading the superglobal. | |
| 581 | + $params = array(); | |
| 582 | + parse_str( $query, $params ); | |
| 583 | + | |
| 584 | + $home_host = $this->normalize_host( wp_parse_url( home_url(), PHP_URL_HOST ) ); | |
| 585 | + | |
| 586 | + foreach ( $this->flatten_query_params( $params ) as $pair ) { | |
| 587 | + list( $names, $value ) = $pair; | |
| 588 | + | |
| 589 | + if ( ! preg_match_all( '/(?:https?|ftp):\/\/[^\s\'"<>]+/i', $value, $matches ) ) { | |
| 590 | + continue; | |
| 591 | + } | |
| 592 | + | |
| 593 | + foreach ( $matches[0] as $url ) { | |
| 594 | + $host = $this->normalize_host( wp_parse_url( $url, PHP_URL_HOST ) ); | |
| 595 | + | |
| 596 | + // A URL pointing at this very site is not an inclusion. | |
| 597 | + if ( '' !== $host && $host === $home_host ) { | |
| 598 | + continue; | |
| 599 | + } | |
| 600 | + | |
| 601 | + if ( $this->looks_like_inclusion( $names, $url ) ) { | |
| 602 | + return true; | |
| 603 | + } | |
| 604 | + } | |
| 605 | + } | |
| 606 | + | |
| 607 | + return false; | |
| 608 | + } | |
| 609 | + | |
| 610 | + /** | |
| 611 | + * Query parameters as (name segments, value) pairs | |
| 612 | + * | |
| 613 | + * Keeps every segment of a nested name, so opts[file]=http://... is seen | |
| 614 | + * as the inclusion parameter it is and not as an anonymous value. | |
| 615 | + * | |
| 616 | + * @since 2.11.1 | |
| 617 | + * | |
| 618 | + * @param array $params Parsed parameters. | |
| 619 | + * @param string[] $inherited Name segments of the parent levels. | |
| 620 | + * @return array List of array( string[] $names, string $value ). | |
| 621 | + */ | |
| 622 | + private function flatten_query_params( $params, $inherited = array() ) { | |
| 623 | + $pairs = array(); | |
| 624 | + | |
| 625 | + foreach ( $params as $key => $value ) { | |
| 626 | + $names = array_merge( $inherited, array( strtolower( (string) $key ) ) ); | |
| 627 | + | |
| 628 | + if ( is_array( $value ) ) { | |
| 629 | + $pairs = array_merge( $pairs, $this->flatten_query_params( $value, $names ) ); | |
| 630 | + continue; | |
| 631 | + } | |
| 632 | + | |
| 633 | + if ( is_scalar( $value ) ) { | |
| 634 | + $pairs[] = array( $names, (string) $value ); | |
| 635 | + } | |
| 636 | + } | |
| 637 | + | |
| 638 | + return $pairs; | |
| 639 | + } | |
| 640 | + | |
| 641 | + /** | |
| 642 | + * Whether an external URL in this parameter is an inclusion attempt | |
| 643 | + * | |
| 644 | + * Two independent signals, either is enough: the parameter is one a | |
| 645 | + * vulnerable script reads as a path, or the target is something that gets | |
| 646 | + * included rather than linked. The trailing '?' and the null byte are the | |
| 647 | + * two ways a payload truncates whatever the script appends to it. | |
| 648 | + * | |
| 649 | + * What this deliberately no longer catches, so nobody reads its silence as | |
| 650 | + * coverage: an external URL with no includable extension travelling in a | |
| 651 | + * parameter whose name is not on the list. That shape is a link, which is | |
| 652 | + * why the site's own search box and every return_url tripped the rule | |
| 653 | + * before. A payload in it still has to reach an include() in some other | |
| 654 | + * plugin's code to do anything, and the wrappers, the system paths and the | |
| 655 | + * traversal rules below are untouched. | |
| 656 | + * | |
| 657 | + * @since 2.11.1 | |
| 658 | + * | |
| 659 | + * @param string[] $names Name segments of the parameter. | |
| 660 | + * @param string $url The external URL found in its value. | |
| 661 | + * @return bool | |
| 662 | + */ | |
| 663 | + private function looks_like_inclusion( $names, $url ) { | |
| 664 | + foreach ( $names as $name ) { | |
| 665 | + if ( in_array( $name, self::$inclusion_param_names, true ) ) { | |
| 666 | + return true; | |
| 667 | + } | |
| 668 | + } | |
| 669 | + | |
| 670 | + // ftp:// is never how a page links to something; it is how a payload | |
| 671 | + // is fetched. | |
| 672 | + if ( 0 === stripos( $url, 'ftp://' ) ) { | |
| 673 | + return true; | |
| 674 | + } | |
| 675 | + | |
| 676 | + // Truncation of the suffix the vulnerable script appends. | |
| 677 | + if ( '?' === substr( $url, -1 ) || false !== stripos( $url, '%00' ) || false !== strpos( $url, "\0" ) ) { | |
| 678 | + return true; | |
| 679 | + } | |
| 680 | + | |
| 681 | + $path = (string) wp_parse_url( $url, PHP_URL_PATH ); | |
| 682 | + | |
| 683 | + if ( '' === $path ) { | |
| 684 | + return false; | |
| 685 | + } | |
| 686 | + | |
| 687 | + $extension = strtolower( pathinfo( $path, PATHINFO_EXTENSION ) ); | |
| 688 | + | |
| 689 | + return ( '' !== $extension && in_array( $extension, self::$inclusion_extensions, true ) ); | |
| 690 | + } | |
| 691 | + | |
| 692 | + /** | |
| 693 | + * The REST route of the current request, or '' when it is not a REST call | |
| 694 | + * | |
| 695 | + * Read from the request itself because REST_REQUEST is not defined yet: | |
| 696 | + * the firewall runs on init, and rest_api_loaded() defines it later, on | |
| 697 | + * parse_request. Both shapes are covered, the pretty /wp-json/<route> and | |
| 698 | + * the plain ?rest_route=<route>, and the prefix is asked for rather than | |
| 699 | + * assumed, since rest_url_prefix filters it. | |
| 700 | + * | |
| 701 | + * @since 2.11.1 | |
| 702 | + * | |
| 703 | + * @return string Route with a leading slash, or '' when there is none. | |
| 704 | + */ | |
| 705 | + private function current_rest_route() { | |
| 706 | + $params = array(); | |
| 707 | + parse_str( (string) ( $this->request_data['query_raw'] ?? '' ), $params ); | |
| 708 | + | |
| 709 | + if ( isset( $params['rest_route'] ) && is_string( $params['rest_route'] ) ) { | |
| 710 | + return '/' . ltrim( $params['rest_route'], '/' ); | |
| 711 | + } | |
| 712 | + | |
| 713 | + $uri = (string) ( $this->request_data['uri_raw'] ?? '' ); | |
| 714 | + $path = (string) wp_parse_url( $uri, PHP_URL_PATH ); | |
| 715 | + $needle = '/' . trim( rest_get_url_prefix(), '/' ) . '/'; | |
| 716 | + $at = strpos( $path, $needle ); | |
| 717 | + | |
| 718 | + if ( false === $at ) { | |
| 719 | + return ''; | |
| 720 | + } | |
| 721 | + | |
| 722 | + return '/' . ltrim( substr( $path, $at + strlen( $needle ) ), '/' ); | |
| 723 | + } | |
| 724 | + | |
| 725 | + /** | |
| 726 | + * Whether this is a logged-in editor asking core to resolve an embed | |
| 727 | + * | |
| 728 | + * /wp-json/oembed/1.0/proxy is where the block editor sends the URL the | |
| 729 | + * author pasted, so an external URL there is the request, not an attack. | |
| 730 | + * The exemption is not the route on its own: it asks for the capability | |
| 731 | + * that route's own permission_callback asks for, so an anonymous scanner | |
| 732 | + * probing it is still blocked and still logged. The other core embed | |
| 733 | + * route, /oembed/1.0/embed, only ever answers for this site's own URLs, | |
| 734 | + * which the check already leaves alone. | |
| 735 | + * | |
| 736 | + * @since 2.11.1 | |
| 737 | + * | |
| 738 | + * @return bool | |
| 739 | + */ | |
| 740 | + private function is_core_embed_proxy_request() { | |
| 741 | + if ( 0 !== strpos( $this->current_rest_route(), '/oembed/1.0/proxy' ) ) { | |
| 742 | + return false; | |
| 743 | + } | |
| 744 | + | |
| 745 | + return ( is_user_logged_in() && current_user_can( 'edit_posts' ) ); | |
| 746 | + } | |
| 747 | + | |
| 748 | + /** | |
| 749 | + * Host in a comparable form: lowercase and without a leading www. | |
| 750 | + * | |
| 751 | + * @since 2.9.9 | |
| 752 | + * | |
| 753 | + * @param string|null $host Host to normalize. | |
| 754 | + * @return string | |
| 755 | + */ | |
| 756 | + private function normalize_host( $host ) { | |
| 757 | + $host = strtolower( trim( (string) $host ) ); | |
| 758 | + | |
| 759 | + return ( 0 === strpos( $host, 'www.' ) ) ? substr( $host, 4 ) : $host; | |
| 760 | + } | |
| 761 | + | |
| 762 | + /** | |
| 384 | 763 | * Check for directory traversal attacks |
| 385 | 764 | * |
| 386 | 765 | * @return string|false Error message or false if safe. |
| 387 | 766 | */ |
| 388 | 767 | private function check_directory_traversal() { |
| 389 | - $uri = $this->request_data['uri']; | |
| 390 | - $query = $this->request_data['query_string']; | |
| 391 | - $combined = urldecode( $uri . ' ' . $query ); | |
| 768 | + $combined = $this->inspection_haystack(); | |
| 392 | 769 | |
| 393 | 770 | if ( empty( $combined ) ) { |
| 394 | 771 | return false; |
| 395 | 772 | } |
| @@ -416,9 +793,9 @@ | ||
| 416 | 793 | * |
| 417 | 794 | * @return string|false Error message or false if safe. |
| 418 | 795 | */ |
| 419 | 796 | private function check_php_in_uploads() { |
| 420 | - $uri = $this->request_data['uri']; | |
| 797 | + $uri = $this->inspection_haystack(); | |
| 421 | 798 | |
| 422 | 799 | // Check if accessing PHP in uploads directory |
| 423 | 800 | if ( preg_match( '/\/wp-content\/uploads\/.*\.ph(p[345s]?|tml)/i', $uri ) ) { |
| 424 | 801 | return __( 'PHP execution in uploads blocked', 'vigilante' ); |
| @@ -427,48 +804,8 @@ | ||
| 427 | 804 | return false; |
| 428 | 805 | } |
| 429 | 806 | |
| 430 | 807 | /** |
| 431 | - * Check for access to sensitive files | |
| 432 | - * | |
| 433 | - * @return string|false Error message or false if safe. | |
| 434 | - */ | |
| 435 | - private function check_sensitive_files() { | |
| 436 | - $uri = strtolower( $this->request_data['uri'] ); | |
| 437 | - | |
| 438 | - // Sensitive file patterns | |
| 439 | - $sensitive_patterns = array( | |
| 440 | - '/\.htaccess$/i', | |
| 441 | - '/\.htpasswd$/i', | |
| 442 | - '/wp-config\.php$/i', | |
| 443 | - '/wp-config-sample\.php$/i', | |
| 444 | - '/readme\.html$/i', | |
| 445 | - '/licen(se|cia)\.txt$/i', | |
| 446 | - '/xmlrpc\.php$/i', // If XML-RPC is disabled | |
| 447 | - '/\.git/i', | |
| 448 | - '/\.svn/i', | |
| 449 | - '/\.env$/i', | |
| 450 | - '/composer\.(json|lock)$/i', | |
| 451 | - '/package(-lock)?\.json$/i', | |
| 452 | - '/\.sql$/i', | |
| 453 | - '/\.bak$/i', | |
| 454 | - '/\.old$/i', | |
| 455 | - '/\.log$/i', | |
| 456 | - '/\.ini$/i', | |
| 457 | - '/debug\.log$/i', | |
| 458 | - '/error_log$/i', | |
| 459 | - ); | |
| 460 | - | |
| 461 | - foreach ( $sensitive_patterns as $pattern ) { | |
| 462 | - if ( preg_match( $pattern, $uri ) ) { | |
| 463 | - return __( 'Access to sensitive file blocked', 'vigilante' ); | |
| 464 | - } | |
| 465 | - } | |
| 466 | - | |
| 467 | - return false; | |
| 468 | - } | |
| 469 | - | |
| 470 | - /** | |
| 471 | 808 | * Check for bad bots |
| 472 | 809 | * |
| 473 | 810 | * @return string|false Error message or false if safe. |
| 474 | 811 | */ |
| @@ -678,10 +1015,49 @@ | ||
| 678 | 1015 | return false; |
| 679 | 1016 | } |
| 680 | 1017 | |
| 681 | 1018 | /** |
| 1019 | + * Whether the request is addressed to the REST API itself | |
| 1020 | + * | |
| 1021 | + * The method filter lets the REST API through, and until 2.11.8 it asked | |
| 1022 | + * whether "/wp-json/" appeared anywhere in the address, query string | |
| 1023 | + * included: TRACE /?x=/wp-json/ skipped the filter and reached a page that | |
| 1024 | + * is not the REST API at all. Found by the audit of the firewall for | |
| 1025 | + * 2.11.8. Core routes the pretty REST URLs from the start of the home | |
| 1026 | + * path, directly or through index.php, so the path has to start there. | |
| 1027 | + * The ?rest_route= form never matched the old test and still does not: | |
| 1028 | + * widening the exemption was not the point. | |
| 1029 | + * | |
| 1030 | + * @since 2.11.8 | |
| 1031 | + * | |
| 1032 | + * @return bool | |
| 1033 | + */ | |
| 1034 | + private function is_rest_api_request() { | |
| 1035 | + /* | |
| 1036 | + * The path is cut by hand, not with wp_parse_url(): with two leading | |
| 1037 | + * slashes that reads "//wp-json/..." as a host, and WordPress still routes | |
| 1038 | + * it to the REST API. And both the home path and the root are accepted, | |
| 1039 | + * for the language folders some multilingual plugins add to home_url(). | |
| 1040 | + * Both from the cross review of 2.11.8. | |
| 1041 | + */ | |
| 1042 | + $path = preg_replace( '#/{2,}#', '/', (string) preg_replace( '/[?#].*$/s', '', (string) ( $this->request_data['uri_raw'] ?? '' ) ) ); | |
| 1043 | + $home = trailingslashit( (string) wp_parse_url( home_url( '/' ), PHP_URL_PATH ) ); | |
| 1044 | + $prefix = trim( rest_get_url_prefix(), '/' ); | |
| 1045 | + | |
| 1046 | + foreach ( array_unique( array( $home, '/' ) ) as $root ) { | |
| 1047 | + foreach ( array( $root . $prefix, $root . 'index.php/' . $prefix ) as $base ) { | |
| 1048 | + if ( $path === $base || 0 === strpos( (string) $path, $base . '/' ) ) { | |
| 1049 | + return true; | |
| 1050 | + } | |
| 1051 | + } | |
| 1052 | + } | |
| 1053 | + | |
| 1054 | + return false; | |
| 1055 | + } | |
| 1056 | + | |
| 1057 | + /** | |
| 682 | 1058 | * Check HTTP method |
| 683 | - * | |
| 1059 | + * | |
| 684 | 1060 | * Logged-in users with edit capabilities are excluded to ensure |
| 685 | 1061 | * Gutenberg, REST API, and page builders work correctly. |
| 686 | 1062 | */ |
| 687 | 1063 | private function check_http_method() { |
| @@ -693,10 +1069,9 @@ | ||
| 693 | 1069 | |
| 694 | 1070 | // Skip for WordPress REST API requests |
| 695 | 1071 | // The REST API uses PUT, DELETE, PATCH for legitimate operations and has its own |
| 696 | 1072 | // authentication and authorization layer — no need to filter methods here |
| 697 | - $rest_prefix = rest_get_url_prefix(); // Typically 'wp-json' | |
| 698 | - if ( false !== strpos( $this->request_data['uri'], '/' . $rest_prefix . '/' ) ) { | |
| 1073 | + if ( $this->is_rest_api_request() ) { | |
| 699 | 1074 | return; |
| 700 | 1075 | } |
| 701 | 1076 | |
| 702 | 1077 | $method = strtoupper( $this->request_data['method'] ); |
| @@ -717,8 +1092,60 @@ | ||
| 717 | 1092 | } |
| 718 | 1093 | } |
| 719 | 1094 | |
| 720 | 1095 | /** |
| 1096 | + * Upper bound of the vigilante_firewall_blocks index | |
| 1097 | + * | |
| 1098 | + * The index only feeds the admin screen; enforcement reads a transient per | |
| 1099 | + * IP. Under a distributed attack the oldest entries are dropped first, so | |
| 1100 | + * the option cannot grow without limit (S6). | |
| 1101 | + * | |
| 1102 | + * @since 2.11.0 | |
| 1103 | + */ | |
| 1104 | + const MAX_TRACKED_BLOCKS = 500; | |
| 1105 | + | |
| 1106 | + /** | |
| 1107 | + * Add a block to the bounded admin index | |
| 1108 | + * | |
| 1109 | + * Prunes expired entries on every write, not only when an administrator | |
| 1110 | + * opens the Firewall tab, and keeps at most MAX_TRACKED_BLOCKS entries, | |
| 1111 | + * dropping the oldest by blocked_at. | |
| 1112 | + * | |
| 1113 | + * @since 2.11.0 | |
| 1114 | + * | |
| 1115 | + * @param string $ip Blocked address. | |
| 1116 | + * @param array $block Block data (expires, blocked_at, duration, reason, strikes). | |
| 1117 | + */ | |
| 1118 | + private static function index_block( $ip, $block ) { | |
| 1119 | + $blocks = get_option( 'vigilante_firewall_blocks', array() ); | |
| 1120 | + $now = time(); | |
| 1121 | + | |
| 1122 | + if ( ! is_array( $blocks ) ) { | |
| 1123 | + $blocks = array(); | |
| 1124 | + } | |
| 1125 | + | |
| 1126 | + foreach ( $blocks as $blocked_ip => $data ) { | |
| 1127 | + if ( ! is_array( $data ) || ! isset( $data['expires'] ) || $now >= (int) $data['expires'] ) { | |
| 1128 | + unset( $blocks[ $blocked_ip ] ); | |
| 1129 | + } | |
| 1130 | + } | |
| 1131 | + | |
| 1132 | + $blocks[ $ip ] = $block; | |
| 1133 | + | |
| 1134 | + if ( count( $blocks ) > self::MAX_TRACKED_BLOCKS ) { | |
| 1135 | + uasort( | |
| 1136 | + $blocks, | |
| 1137 | + static function ( $a, $b ) { | |
| 1138 | + return (int) ( $a['blocked_at'] ?? 0 ) <=> (int) ( $b['blocked_at'] ?? 0 ); | |
| 1139 | + } | |
| 1140 | + ); | |
| 1141 | + $blocks = array_slice( $blocks, count( $blocks ) - self::MAX_TRACKED_BLOCKS, null, true ); | |
| 1142 | + } | |
| 1143 | + | |
| 1144 | + update_option( 'vigilante_firewall_blocks', $blocks, false ); | |
| 1145 | + } | |
| 1146 | + | |
| 1147 | + /** | |
| 721 | 1148 | * Check rate limiting |
| 722 | 1149 | */ |
| 723 | 1150 | public function check_rate_limit() { |
| 724 | 1151 | // Skip rate limiting for whitelisted IPs |
| @@ -730,11 +1157,12 @@ | ||
| 730 | 1157 | if ( is_user_logged_in() && current_user_can( 'manage_options' ) ) { |
| 731 | 1158 | return; |
| 732 | 1159 | } |
| 733 | 1160 | |
| 734 | - // Allow other modules to opt out — Under Attack mode uses this so that | |
| 735 | - // visitors who already passed the JS challenge don't burn the | |
| 736 | - // aggressive 30 req/min cap loading a normal page's assets. | |
| 1161 | + // Allow other code to opt out. Under Attack mode used this until 2.11.8 | |
| 1162 | + // to exempt visitors who had passed the JS challenge, which exempted a | |
| 1163 | + // bot that solved it once, too; it now raises their limit instead, | |
| 1164 | + // through vigilante_rate_limit_requests below. | |
| 737 | 1165 | if ( apply_filters( 'vigilante_skip_rate_limit', false ) ) { |
| 738 | 1166 | return; |
| 739 | 1167 | } |
| 740 | 1168 | |
| @@ -740,25 +1168,39 @@ | ||
| 740 | 1168 | |
| 741 | 1169 | $ip = $this->get_client_ip(); |
| 742 | 1170 | $rate_limit = $this->options['rate_limiting']; |
| 743 | 1171 | |
| 744 | - // Check if already blocked via queryable option (fast path) | |
| 745 | - $active_blocks = get_option( 'vigilante_firewall_blocks', array() ); | |
| 746 | - if ( isset( $active_blocks[ $ip ] ) ) { | |
| 747 | - if ( time() < $active_blocks[ $ip ]['expires'] ) { | |
| 748 | - if ( ! headers_sent() ) { | |
| 749 | - status_header( 429 ); | |
| 750 | - nocache_headers(); | |
| 751 | - } | |
| 752 | - wp_die( | |
| 753 | - esc_html__( 'Rate limit exceeded. Please try again later.', 'vigilante' ), | |
| 754 | - esc_html__( 'Too Many Requests', 'vigilante' ), | |
| 755 | - array( 'response' => 429 ) | |
| 756 | - ); | |
| 1172 | + /* | |
| 1173 | + * What the count and the block are kept under: the address, unless a | |
| 1174 | + * filter narrows it. Under Attack mode gives visitors who passed its | |
| 1175 | + * challenge a count of their own, because counting them with everybody | |
| 1176 | + * else at their address let one unverified client behind the same NAT | |
| 1177 | + * lock them out for fifteen minutes with its own block. Found by the | |
| 1178 | + * cross review of 2.11.8, the same shape as the challenge nonce the | |
| 1179 | + * automated review reported on 2.11.7. | |
| 1180 | + */ | |
| 1181 | + $key = (string) apply_filters( 'vigilante_rate_limit_key', $ip ); | |
| 1182 | + $key = '' !== $key ? $key : $ip; | |
| 1183 | + $hash = md5( $key ); | |
| 1184 | + | |
| 1185 | + // Check if already blocked (fast path). The active block lives in a | |
| 1186 | + // transient keyed by IP, so this path, which runs on every | |
| 1187 | + // unauthenticated request, reads one row and not the whole index of | |
| 1188 | + // blocked addresses. Until 2.11.0 it loaded vigilante_firewall_blocks | |
| 1189 | + // entire, an array with no upper bound that a distributed attack grew | |
| 1190 | + // by one entry per new address, so the firewall amplified the attack it | |
| 1191 | + // was blocking (S6). The transient expires with the block itself. | |
| 1192 | + $block = get_transient( 'vigilante_rate_block_' . $hash ); | |
| 1193 | + if ( is_array( $block ) && isset( $block['expires'] ) && time() < (int) $block['expires'] ) { | |
| 1194 | + if ( ! headers_sent() ) { | |
| 1195 | + status_header( 429 ); | |
| 1196 | + nocache_headers(); | |
| 757 | 1197 | } |
| 758 | - // Expired — clean up | |
| 759 | - unset( $active_blocks[ $ip ] ); | |
| 760 | - update_option( 'vigilante_firewall_blocks', $active_blocks, false ); | |
| 1198 | + wp_die( | |
| 1199 | + esc_html__( 'Rate limit exceeded. Please try again later.', 'vigilante' ), | |
| 1200 | + esc_html__( 'Too Many Requests', 'vigilante' ), | |
| 1201 | + array( 'response' => 429 ) | |
| 1202 | + ); | |
| 761 | 1203 | } |
| 762 | 1204 | |
| 763 | 1205 | $max_requests = absint( $rate_limit['requests_per_minute'] ); |
| 764 | 1206 | |
| @@ -774,9 +1216,9 @@ | ||
| 774 | 1216 | // silence". A logged-in editor publishing several posts in a row could |
| 775 | 1217 | // pile up 150+ requests while never exceeding 60 in any single minute, |
| 776 | 1218 | // and got a 429. Storing the window start makes the reset explicit |
| 777 | 1219 | // instead of relying on the transient expiring. |
| 778 | - $transient_key = 'vigilante_rate_' . md5( $ip ); | |
| 1220 | + $transient_key = 'vigilante_rate_' . $hash; | |
| 779 | 1221 | $window = get_transient( $transient_key ); |
| 780 | 1222 | $now = time(); |
| 781 | 1223 | |
| 782 | 1224 | // Counts stored before 2.9.5 were a bare integer with no window start. |
| @@ -810,9 +1252,9 @@ | ||
| 810 | 1252 | $strikes = 1; |
| 811 | 1253 | |
| 812 | 1254 | // Progressive blocking: double duration on each repeat offense |
| 813 | 1255 | if ( ! empty( $rate_limit['progressive'] ) ) { |
| 814 | - $strikes_key = 'vigilante_strikes_' . md5( $ip ); | |
| 1256 | + $strikes_key = 'vigilante_strikes_' . $hash; | |
| 815 | 1257 | $strikes = absint( get_transient( $strikes_key ) ) + 1; |
| 816 | 1258 | |
| 817 | 1259 | $max_duration = absint( $rate_limit['max_block_duration'] ?? 86400 ); |
| 818 | 1260 | $duration = min( |
| @@ -823,18 +1265,23 @@ | ||
| 823 | 1265 | // Persist strikes for 24h so they accumulate across blocks |
| 824 | 1266 | set_transient( $strikes_key, $strikes, 86400 ); |
| 825 | 1267 | } |
| 826 | 1268 | |
| 827 | - // Store block in queryable option for admin UI | |
| 828 | - $active_blocks[ $ip ] = array( | |
| 1269 | + $block = array( | |
| 829 | 1270 | 'expires' => time() + $duration, |
| 830 | 1271 | 'blocked_at' => time(), |
| 831 | 1272 | 'duration' => $duration, |
| 832 | 1273 | 'reason' => 'rate_limit', |
| 833 | 1274 | 'strikes' => $strikes, |
| 1275 | + 'key' => $key, | |
| 834 | 1276 | ); |
| 835 | - update_option( 'vigilante_firewall_blocks', $active_blocks, false ); | |
| 836 | 1277 | |
| 1278 | + // The block itself, read by the fast path above on every request. | |
| 1279 | + set_transient( 'vigilante_rate_block_' . $hash, $block, $duration ); | |
| 1280 | + | |
| 1281 | + // The bounded index the admin screen lists. | |
| 1282 | + self::index_block( $ip, $block ); | |
| 1283 | + | |
| 837 | 1284 | $this->block_request( 'rate_limit', __( 'Rate limit exceeded. Please try again later.', 'vigilante' ), 429 ); |
| 838 | 1285 | } |
| 839 | 1286 | |
| 840 | 1287 | // The TTL only garbage-collects the payload once the IP goes quiet; what |
| @@ -856,12 +1303,12 @@ | ||
| 856 | 1303 | 'firewall', |
| 857 | 1304 | 'blocked', |
| 858 | 1305 | $message, |
| 859 | 1306 | array( |
| 860 | - 'reason' => $reason, | |
| 861 | - 'uri' => $this->request_data['uri'] ?? '', | |
| 862 | - 'ip' => $this->get_client_ip(), | |
| 863 | - 'user_agent'=> $this->request_data['user_agent'] ?? '', | |
| 1307 | + 'reason' => $reason, | |
| 1308 | + 'request_uri' => $this->loggable_uri(), | |
| 1309 | + 'ip' => $this->get_client_ip(), | |
| 1310 | + 'user_agent' => $this->request_data['user_agent'] ?? '', | |
| 864 | 1311 | ), |
| 865 | 1312 | 'warning' |
| 866 | 1313 | ); |
| 867 | 1314 | } |
| @@ -871,8 +1318,24 @@ | ||
| 871 | 1318 | status_header( $status_code ); |
| 872 | 1319 | nocache_headers(); |
| 873 | 1320 | } |
| 874 | 1321 | |
| 1322 | + // A REST client gets the refusal in the shape it can read. Until | |
| 1323 | + // 2.11.0 every block answered with the HTML "Forbidden" page, so the | |
| 1324 | + // block editor could only show its own generic message and the reason | |
| 1325 | + // was reachable only by opening the activity log. Same status code, | |
| 1326 | + // same message, the envelope core uses for an error. | |
| 1327 | + if ( '' !== $this->current_rest_route() ) { | |
| 1328 | + wp_send_json( | |
| 1329 | + array( | |
| 1330 | + 'code' => 'vigilante_firewall_blocked', | |
| 1331 | + 'message' => $message, | |
| 1332 | + 'data' => array( 'status' => $status_code ), | |
| 1333 | + ), | |
| 1334 | + $status_code | |
| 1335 | + ); | |
| 1336 | + } | |
| 1337 | + | |
| 875 | 1338 | // Return appropriate response |
| 876 | 1339 | if ( 429 === $status_code ) { |
| 877 | 1340 | wp_die( |
| 878 | 1341 | esc_html( $message ), |
| @@ -888,8 +1351,46 @@ | ||
| 888 | 1351 | ); |
| 889 | 1352 | } |
| 890 | 1353 | |
| 891 | 1354 | /** |
| 1355 | + * Upper bound of the address stored with a logged block | |
| 1356 | + * | |
| 1357 | + * @since 2.11.1 | |
| 1358 | + */ | |
| 1359 | + const MAX_LOGGED_URI = 512; | |
| 1360 | + | |
| 1361 | + /** | |
| 1362 | + * The blocked address, in a form that still says what was blocked | |
| 1363 | + * | |
| 1364 | + * The copy kept for logging goes through sanitize_text_field(), which | |
| 1365 | + * deletes every %XX sequence instead of decoding it. A browser percent | |
| 1366 | + * encodes the URL it puts in a parameter, so a blocked embed was recorded | |
| 1367 | + * as "/wp-json/oembed/1.0/proxy?url=httpswww.youtube.comwatchv..." and the | |
| 1368 | + * owner could not tell what the request had been. Reported on 9 sep 2026. | |
| 1369 | + * | |
| 1370 | + * Nothing is sanitized away here beyond control characters, and that is | |
| 1371 | + * the point. The value is stored, never executed: it is escaped where it | |
| 1372 | + * is shown, by escapeHtml() in the log detail and by csvCell() in the | |
| 1373 | + * export. Invalid UTF-8 is stripped because wp_json_encode() returns false | |
| 1374 | + * on it, which would have thrown away the whole entry's context. | |
| 1375 | + * | |
| 1376 | + * @since 2.11.1 | |
| 1377 | + * | |
| 1378 | + * @return string | |
| 1379 | + */ | |
| 1380 | + private function loggable_uri() { | |
| 1381 | + $uri = (string) ( $this->request_data['uri_raw'] ?? '' ); | |
| 1382 | + $uri = (string) preg_replace( '/[\x00-\x1F\x7F]/', '', $uri ); | |
| 1383 | + $uri = wp_check_invalid_utf8( $uri, true ); | |
| 1384 | + | |
| 1385 | + if ( strlen( $uri ) > self::MAX_LOGGED_URI ) { | |
| 1386 | + $uri = substr( $uri, 0, self::MAX_LOGGED_URI ) . '...'; | |
| 1387 | + } | |
| 1388 | + | |
| 1389 | + return $uri; | |
| 1390 | + } | |
| 1391 | + | |
| 1392 | + /** | |
| 892 | 1393 | * Check if current IP is whitelisted |
| 893 | 1394 | * |
| 894 | 1395 | * @return bool |
| 895 | 1396 | */ |
| @@ -1029,16 +1530,26 @@ | ||
| 1029 | 1530 | if ( ! isset( $blocks[ $ip ] ) ) { |
| 1030 | 1531 | return false; |
| 1031 | 1532 | } |
| 1032 | 1533 | |
| 1534 | + // The address, and the narrower key the block was kept under, if any | |
| 1535 | + // (a verified visitor of Under Attack mode, since 2.11.8). | |
| 1536 | + $keys = array( $ip ); | |
| 1537 | + | |
| 1538 | + if ( is_array( $blocks[ $ip ] ) && ! empty( $blocks[ $ip ]['key'] ) && is_string( $blocks[ $ip ]['key'] ) ) { | |
| 1539 | + $keys[] = $blocks[ $ip ]['key']; | |
| 1540 | + } | |
| 1541 | + | |
| 1033 | 1542 | unset( $blocks[ $ip ] ); |
| 1034 | 1543 | update_option( 'vigilante_firewall_blocks', $blocks, false ); |
| 1035 | 1544 | |
| 1036 | 1545 | // Clean related transients |
| 1037 | - $hash = md5( $ip ); | |
| 1038 | - delete_transient( 'vigilante_rate_block_' . $hash ); | |
| 1039 | - delete_transient( 'vigilante_rate_' . $hash ); | |
| 1040 | - delete_transient( 'vigilante_strikes_' . $hash ); | |
| 1546 | + foreach ( array_unique( $keys ) as $key ) { | |
| 1547 | + $hash = md5( $key ); | |
| 1548 | + delete_transient( 'vigilante_rate_block_' . $hash ); | |
| 1549 | + delete_transient( 'vigilante_rate_' . $hash ); | |
| 1550 | + delete_transient( 'vigilante_strikes_' . $hash ); | |
| 1551 | + } | |
| 1041 | 1552 | |
| 1042 | 1553 | return true; |
| 1043 | 1554 | } |
| 1044 | 1555 | } |