block( 'waf_block', 'waf_blocks', sprintf( __( 'Rule %1$s in %2$s on %3$s', '007aj-security-scanner-firewall' ), $hit['rule'], $hit['field'], Request::path() ), 403 ); } } /** * Run the rules over the path, the query string and POST field names. * * @return array|null Hit, or null. */ public static function check_request() { // The firewall reads raw input on purpose (read-only, never output or stored as-is). Nonces do not apply. // phpcs:disable WordPress.Security.NonceVerification, WordPress.Security.ValidatedSanitizedInput $uri = isset( $_SERVER['REQUEST_URI'] ) ? (string) wp_unslash( $_SERVER['REQUEST_URI'] ) : ''; $path = (string) strtok( $uri, '?' ); $get = isset( $_GET ) && is_array( $_GET ) ? wp_unslash( $_GET ) : array(); $post = isset( $_POST ) && is_array( $_POST ) ? wp_unslash( $_POST ) : array(); // phpcs:enable $rule = Rules::match( $path ); if ( in_array( $rule, array( 'null-byte', 'path-traversal', 'sensitive-file' ), true ) ) { return array( 'rule' => $rule, 'field' => 'path', ); } $hit = Rules::inspect( $get, true ); if ( $hit ) { $hit['field'] = 'GET ' . $hit['field']; return $hit; } $hit = Rules::inspect( $post, false ); if ( $hit ) { $hit['field'] = 'POST ' . $hit['field']; return $hit; } return null; } /** * Refuse requests to xmlrpc.php. */ public function block_xmlrpc() { if ( defined( 'XMLRPC_REQUEST' ) && XMLRPC_REQUEST ) { $this->block( 'xmlrpc_blocked', 'xmlrpc_blocked', __( 'XML-RPC is turned off on this site', '007aj-security-scanner-firewall' ), 403 ); } } /** * Drop the X-Pingback header. * * @param array $headers Headers. * @return array */ public function remove_pingback_header( $headers ) { unset( $headers['X-Pingback'] ); return $headers; } /** * Limit POST requests to wp-login.php per IP address. */ public function rate_limit_login() { if ( 'POST' !== Request::method() ) { return; } $ip = Request::ip(); if ( '' === $ip ) { return; } $key = 'ajsec_rl_' . Request::ip_key( $ip ); $window = Settings::int( 'waf_login_rate_win' ) * MINUTE_IN_SECONDS; $max = Settings::int( 'waf_login_rate_max' ); $now = time(); $state = get_transient( $key ); if ( ! is_array( $state ) || ! isset( $state['first'] ) || ( $now - (int) $state['first'] ) >= $window ) { $state = array( 'count' => 0, 'first' => $now, 'logged' => 0, ); } $state['count'] = (int) $state['count'] + 1; $remaining = max( 1, $window - ( $now - (int) $state['first'] ) ); $over = $state['count'] > $max; $first_hit = $over && empty( $state['logged'] ); if ( $first_hit ) { $state['logged'] = 1; } set_transient( $key, $state, $remaining ); if ( ! $over ) { return; } if ( ! headers_sent() ) { header( 'Retry-After: ' . (int) $remaining ); } if ( $first_hit ) { /* translators: 1: request limit, 2: minutes. */ $this->block( 'rate_limited', 'rate_limited', sprintf( __( 'More than %1$d login requests in %2$d minutes', '007aj-security-scanner-firewall' ), $max, Settings::int( 'waf_login_rate_win' ) ), 429 ); } $this->block( '', 'rate_limited', '', 429 ); // Already logged in this window: refuse without another log row. } /** * Count, log and stop the request. * * @param string $event Log event ('' to skip logging). * @param string $stat Stats counter. * @param string $detail Log detail. * @param int $status HTTP status. */ private function block( $event, $stat, $detail, $status ) { Stats::bump( $stat ); if ( '' !== $event ) { Log::add( $event, $detail ); } /** * Fires before the firewall stops a request. Tests use this to observe blocks. * * @param string $event Event key. * @param int $status HTTP status. */ do_action( 'ajsec_blocked', $event, $status ); $message = 429 === $status ? __( 'Too many login requests from your address. Please wait a few minutes and try again.', '007aj-security-scanner-firewall' ) : __( 'This request was blocked by the site\'s security settings.', '007aj-security-scanner-firewall' ); if ( defined( 'XMLRPC_REQUEST' ) && XMLRPC_REQUEST ) { // The XML-RPC server object does not exist yet at this point, so wp_die() would send an empty 200. if ( ! headers_sent() ) { status_header( (int) $status ); nocache_headers(); header( 'Content-Type: text/plain; charset=' . get_option( 'blog_charset' ) ); } echo esc_html( $message ); exit; } nocache_headers(); wp_die( esc_html( $message ), esc_html__( 'Request blocked', '007aj-security-scanner-firewall' ), array( 'response' => (int) $status ) ); } }