rules() as $rule ) { if ( $this->matches( $ip, $rule ) ) { return true; } } return false; } /** * Returns the configured rules, one per line. * * @return array The rules. */ public function rules(): array { $raw = Settings::text( 'blocked_ips' ); $lines = preg_split( '/[\r\n,]+/', $raw ) ?: array(); $rules = array(); foreach ( $lines as $line ) { $line = trim( (string) $line ); // Lets a site keep a note next to an entry about why it is there. if ( '' === $line || str_starts_with( $line, '#' ) ) { continue; } $rules[] = $line; } /** * Filters the blocked address rules. * * @since 1.0.0 * * @param array $rules Rules, one per entry. */ return (array) apply_filters( 'formpilot_blocked_ips', $rules ); } /** * Tests one address against one rule. * * @param string $ip Address to test. * @param string $rule Exact address, CIDR range or trailing wildcard. * * @return bool True when the rule covers the address. */ private function matches( string $ip, string $rule ): bool { if ( str_contains( $rule, '/' ) ) { return $this->in_range( $ip, $rule ); } if ( str_contains( $rule, '*' ) ) { /* * A wildcard matches the address as it was *written*, and IPv6 has many * spellings of the same address — `2001:db8::1`, `2001:0db8:0:0:0:0:0:1` * and `2001:db8:0:0:0:0:0:1` are one address and three strings. A rule * like `2001:db8:*` therefore missed a visitor whose address arrived in * a different form, while the CIDR path would have caught them. The * address is normalised to its canonical text first, so the comparison * is against one spelling rather than whichever the client happened to * send. */ $subject = $this->canonical( $ip ); $pattern = '/^' . str_replace( '\*', '.*', preg_quote( $this->canonical( $rule ), '/' ) ) . '$/i'; return 1 === preg_match( $pattern, $subject ); } // Compared as packed bytes so that the many spellings of one IPv6 address // all match the one that was written down. $left = inet_pton( $ip ); $right = inet_pton( $rule ); if ( false === $left || false === $right ) { return strtolower( $ip ) === strtolower( $rule ); } return hash_equals( $right, $left ); } /** * Returns an address in its canonical textual form. * * Anything that is not an address — a wildcard rule, mostly — comes back * unchanged, so the caller can pass either side through here. * * @param string $value Address or rule fragment. */ private function canonical( string $value ): string { $packed = inet_pton( $value ); if ( false === $packed ) { return strtolower( $value ); } $text = inet_ntop( $packed ); return false === $text ? strtolower( $value ) : strtolower( $text ); } /** * Tests an address against a CIDR range. * * @param string $ip Address to test. * @param string $rule Range in CIDR notation. * * @return bool True when the address falls inside the range. */ private function in_range( string $ip, string $rule ): bool { list( $subnet, $bits ) = array_pad( explode( '/', $rule, 2 ), 2, '' ); $address = inet_pton( $ip ); $network = inet_pton( trim( $subnet ) ); if ( false === $address || false === $network || strlen( $address ) !== strlen( $network ) ) { return false; } $bits = '' === $bits ? strlen( $address ) * 8 : (int) $bits; $bits = max( 0, min( strlen( $address ) * 8, $bits ) ); $whole = intdiv( $bits, 8 ); $rest = $bits % 8; if ( $whole > 0 && ! hash_equals( substr( $network, 0, $whole ), substr( $address, 0, $whole ) ) ) { return false; } if ( 0 === $rest ) { return true; } // The byte the prefix stops inside: compare only its leading bits. $mask = ~( ( 1 << ( 8 - $rest ) ) - 1 ) & 0xFF; return ( ord( $address[ $whole ] ) & $mask ) === ( ord( $network[ $whole ] ) & $mask ); } }