settings = $settings; $this->activity_log = $activity_log; $this->options = $settings->get_section( 'firewall' ); // Run firewall checks - must be after plugin init (priority 1) add_action( 'init', array( $this, 'run_firewall' ), 2 ); // Rate limiting if ( ! empty( $this->options['rate_limiting']['enabled'] ) ) { add_action( 'init', array( $this, 'check_rate_limit' ), 2 ); } } /** * Run all firewall checks */ public function run_firewall() { // Skip for whitelisted IPs if ( $this->is_ip_whitelisted() ) { return; } /* * The User-Agent whitelist no longer skips the firewall. * * Until 2.11.1 a matching User-Agent returned here, before the IP * blacklist and every request check, so anyone who guessed a * configured substring ("ManageWP", "MainWP") walked past the SQL * injection, script injection, file inclusion, traversal, bot and * HTTP method rules by setting a header they control. A header a * client chooses cannot stand in for an identity. Reported by the * automated security review of wp.org on 9 sep 2026 and fixed in * 2.11.2. * * What the option is actually for is keeping a remote manager from * being turned away as a bot, so that is all it does now: it exempts * the User-Agent rules, resolved further down, and nothing else. The * list is empty by default, so only sites that had configured one were * ever exposed. */ $ua_whitelisted = $this->is_ua_whitelisted(); // Gather request data first: a block is logged with the address it // turned away, and until 2.11.1 the blacklist ran before this, so the // entry for a blacklisted IP recorded no address at all. $this->gather_request_data(); // Check if IP is blacklisted if ( $this->is_ip_blacklisted() ) { $this->block_request( 'ip_blacklisted', __( 'IP address is blacklisted', 'vigilante' ) ); } // Check if User-Agent is blacklisted (after gathering request data). // An explicitly whitelisted agent still wins over the blacklist, which // is what an administrator who wrote it there expects. if ( ! $ua_whitelisted && $this->is_ua_blacklisted() ) { $this->block_request( 'ua_blacklisted', __( 'User-Agent is blacklisted', 'vigilante' ) ); } // Run security checks // NOTE: These are PHP-based checks that complement htaccess rules // Some protections exist in both layers for defense in depth $checks = array( // PHP request filtering (complements htaccess block_bad_query_strings) 'block_bad_query_strings' => 'check_query_strings', 'block_sql_injection' => 'check_sql_injection', 'block_xss_attacks' => 'check_xss_attacks', 'block_file_inclusion' => 'check_file_inclusion', 'block_directory_traversal' => 'check_directory_traversal', // Bot protection (complements htaccess block_bad_bots) 'block_bad_bots' => 'check_bad_bots', 'block_empty_user_agent' => 'check_empty_user_agent', ); // The rules a whitelisted User-Agent is exempt from, and only these. $ua_rules = array( 'block_bad_bots', 'block_empty_user_agent' ); foreach ( $checks as $option => $method ) { if ( $ua_whitelisted && in_array( $option, $ua_rules, true ) ) { continue; } if ( ! empty( $this->options[ $option ] ) && method_exists( $this, $method ) ) { $result = $this->$method(); if ( is_string( $result ) ) { $this->block_request( $option, $result ); } } } // Check HTTP method if limit_http_methods is enabled if ( ! empty( $this->options['limit_http_methods'] ) ) { $this->check_http_method(); } } /** * Gather current request data */ private function gather_request_data() { $this->haystack = null; $this->request_data = array( 'uri' => isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '', 'query_string'=> isset( $_SERVER['QUERY_STRING'] ) ? sanitize_text_field( wp_unslash( $_SERVER['QUERY_STRING'] ) ) : '', /* * Copies that keep the percent encoding, used only as the haystack * of the pattern checks and never logged, printed or stored. * * They exist because sanitize_text_field() deletes every %XX * sequence instead of decoding it: the copies above are the payload * with the evidence removed, so an encoded attack was invisible to * every rule that reads them. Measured on 22 aug 2026 against 2.9.8, * ?x=%3Cscript%3E, javascript%3A, php%3A%2F%2F and GLOBALS%5B all * reached the checks as harmless text and went straight through. * * No sanitizer is applied, and that is the point: every one of them * destroys exactly what has to be matched. sanitize_text_field() * deletes the %XX sequences and strips tags. esc_url_raw() is worse * here: measured on 22 aug 2026, it returns an empty string for a * query that carries an unencoded :// , which is precisely the * remote inclusion shape, so it would blind the firewall instead of * arming it. These two values are never echoed, never stored and * never reach a query; they are the haystack of preg_match() and * nothing else. */ // 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. 'uri_raw' => isset( $_SERVER['REQUEST_URI'] ) ? wp_unslash( $_SERVER['REQUEST_URI'] ) : '', // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- same as uri_raw. 'query_raw' => isset( $_SERVER['QUERY_STRING'] ) ? wp_unslash( $_SERVER['QUERY_STRING'] ) : '', 'user_agent' => isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : '', 'referer' => isset( $_SERVER['HTTP_REFERER'] ) ? esc_url_raw( wp_unslash( $_SERVER['HTTP_REFERER'] ) ) : '', 'method' => isset( $_SERVER['REQUEST_METHOD'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) : 'GET', 'ip' => $this->get_client_ip(), ); } /** * What the pattern checks run against: the request as it arrived, plus its decoded form * * Both forms on purpose. Some patterns look for the encoded shape, such as * the null byte %00 or the %5b of GLOBALS[, and others for the decoded one, * such as