*/ private $resolver; /** * Constructor. * * @param callable|null $resolver Returns the IP addresses (A and AAAA) for a host name. Defaults to DNS. */ public function __construct( ?callable $resolver = null ) { $this->resolver = $resolver ?? array( self::class, 'resolve' ); } /** * Checks a feed URL the server is about to fetch. Includes name resolution. * * @param string $url Absolute URL, with no surrounding whitespace. * @return string|null Error code, or null when the URL is acceptable. */ public function check_feed_url( string $url ): ?string { $result = $this->inspect( $url, array( 'http', 'https' ), self::FEED_PORTS ); if ( null !== $result['error'] ) { return $result['error']; } $ips = array_values( array_filter( (array) ( $this->resolver )( $result['host'] ), 'is_string' ) ); if ( array() === $ips ) { return self::UNRESOLVABLE; } $has_ipv4 = false; foreach ( $ips as $ip ) { if ( ! self::is_public_ip( $ip ) ) { return self::PRIVATE_ADDRESS; } $has_ipv4 = $has_ipv4 || false !== filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 ); } // Connections are pinned to an IPv4 address (see Feed_Fetcher), so IPv6-only hosts are not supported. return $has_ipv4 ? null : self::IPV6_ONLY; } /** * Resolves a host and returns its addresses only if every one of them is public. * * Used to pin a connection to the addresses that were checked, so the name cannot resolve to * something else between the check and the connection (DNS rebinding). * * @param string $host Host name. * @return array Public addresses, or an empty array if there are none or any is not public. */ public function public_addresses( string $host ): array { $ips = array_values( array_filter( (array) ( $this->resolver )( strtolower( rtrim( $host, '.' ) ) ), 'is_string' ) ); foreach ( $ips as $ip ) { if ( ! self::is_public_ip( $ip ) ) { return array(); } } return $ips; } /** * Validates an article link, returning it unchanged when acceptable. * * No name resolution: the viewer's browser follows the link, not this server. * * @param string $url Candidate URL. * @return string|null The URL, or null when it must not be used. */ public function article_url( string $url ): ?string { $url = trim( $url ); return null === $this->inspect( $url, array( 'http', 'https' ), array( 80, 443, 8080 ) )['error'] ? $url : null; } /** * Validates an image URL, returning it unchanged when acceptable. HTTPS only. * * @param string $url Candidate URL. * @return string|null The URL, or null when it must not be used. */ public function image_url( string $url ): ?string { $url = trim( $url ); return null === $this->inspect( $url, array( 'https' ), array( 443 ) )['error'] ? $url : null; } /** * Whether an IP address is publicly routable. * * @param string $ip IPv4 or IPv6 address. */ public static function is_public_ip( string $ip ): bool { if ( false === filter_var( $ip, FILTER_VALIDATE_IP ) ) { return false; } $bin = inet_pton( $ip ); if ( false === $bin ) { return false; } if ( 4 === strlen( $bin ) ) { return ! self::in_any_range( $bin, self::BLOCKED_V4 ); } // IPv4-mapped (::ffff:0:0/96) and NAT64 (64:ff9b::/96) addresses are only as public as the IPv4 address inside. if ( self::in_range( $bin, '::ffff:0:0', 96 ) || self::in_range( $bin, '64:ff9b::', 96 ) ) { return self::is_public_ip( (string) inet_ntop( substr( $bin, 12 ) ) ); } if ( ! self::in_range( $bin, '2000::', 3 ) ) { return false; } return ! self::in_any_range( $bin, self::BLOCKED_V6 ); } /** * Default resolver: A and AAAA records. The trailing dot stops the system search domain being appended. * * @param string $host Host name. * @return array */ public static function resolve( string $host ): array { $ips = array(); $records = @dns_get_record( $host . '.', DNS_A | DNS_AAAA ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- Warns on lookup failure; the empty result is handled. foreach ( is_array( $records ) ? $records : array() as $record ) { if ( isset( $record['ip'] ) ) { $ips[] = $record['ip']; } elseif ( isset( $record['ipv6'] ) ) { $ips[] = $record['ipv6']; } } if ( array() === $ips ) { $fallback = @gethostbynamel( $host . '.' ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- As above. $ips = is_array( $fallback ) ? $fallback : array(); } return array_values( array_unique( $ips ) ); } /** * Shared syntax, scheme, credential, port and host checks. * * @param string $url URL to inspect. * @param array $schemes Allowed schemes, lower case. * @param array $ports Allowed explicit ports. The scheme's default port is always allowed. * @return array{error: string|null, host: string} */ private function inspect( string $url, array $schemes, array $ports ): array { $fail = static fn( string $code ): array => array( 'error' => $code, 'host' => '', ); if ( '' === $url || strlen( $url ) > self::MAX_LENGTH ) { return $fail( self::INVALID_URL ); } // Whitespace, control characters and characters that are never valid in a URL (including backslash). if ( 1 === preg_match( '/[\x00-\x20\x7f"<>\\\\^`{|}]/', $url ) ) { return $fail( self::INVALID_URL ); } if ( 1 !== preg_match( '#^([a-z][a-z0-9+.\-]*):#i', $url, $scheme_match ) ) { return $fail( self::INVALID_URL ); } if ( ! in_array( strtolower( $scheme_match[1] ), $schemes, true ) ) { return $fail( self::UNSUPPORTED_SCHEME ); } $parts = parse_url( $url ); // phpcs:ignore WordPress.WP.AlternativeFunctions.parse_url_parse_url -- This class is deliberately WordPress-free so it can be unit tested; PHP 8.1+ behaves consistently. if ( false === $parts || ! isset( $parts['host'] ) || '' === $parts['host'] ) { return $fail( self::INVALID_URL ); } if ( isset( $parts['user'] ) || isset( $parts['pass'] ) ) { return $fail( self::CREDENTIALS ); } if ( isset( $parts['port'] ) && ! in_array( $parts['port'], $ports, true ) ) { return $fail( self::BLOCKED_PORT ); } $host = self::public_host_shape( (string) $parts['host'] ); if ( null === $host ) { return $fail( self::BLOCKED_HOST ); } return array( 'error' => null, 'host' => $host, ); } /** * Accepts only host names that look like a public, fully qualified domain. * * Rejects IP literals in every notation, single-label names, local and intranet suffixes, and * top-level labels that are not letters (which is what `127.1`, `2130706433` and `0x7f.0.0.1` * look like). The shape check needs no DNS. * * @param string $host Host as parsed from the URL. * @return string|null Normalized host (lower case, no trailing dot), or null if not acceptable. */ private static function public_host_shape( string $host ): ?string { $host = strtolower( rtrim( $host, '.' ) ); if ( '' === $host || strlen( $host ) > 253 ) { return null; } // IPv6 literals arrive bracketed. IPv4 literals are caught by the top-level label rule below. if ( '[' === $host[0] || false !== filter_var( $host, FILTER_VALIDATE_IP ) ) { return null; } if ( 1 !== preg_match( '/^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)+$/', $host ) ) { return null; } $labels = explode( '.', $host ); $tld = end( $labels ); if ( 1 !== preg_match( '/^([a-z]{2,63}|xn--[a-z0-9-]{1,59})$/', $tld ) ) { return null; } foreach ( self::BLOCKED_SUFFIXES as $suffix ) { if ( $host === $suffix || str_ends_with( $host, '.' . $suffix ) ) { return null; } } return $host; } /** * Whether a binary address falls inside any of the given ranges. * * @param string $bin Packed address from `inet_pton()`. * @param array $ranges Ranges as `network/prefix`. */ private static function in_any_range( string $bin, array $ranges ): bool { foreach ( $ranges as $range ) { list( $network, $bits ) = explode( '/', $range ); if ( self::in_range( $bin, $network, (int) $bits ) ) { return true; } } return false; } /** * Whether a binary address falls inside one network. * * @param string $bin Packed address. * @param string $network Network address. * @param int $bits Prefix length. */ private static function in_range( string $bin, string $network, int $bits ): bool { $net = inet_pton( $network ); if ( false === $net || strlen( $net ) !== strlen( $bin ) ) { return false; } $bytes = intdiv( $bits, 8 ); $extra = $bits % 8; if ( $bytes > 0 && substr( $bin, 0, $bytes ) !== substr( $net, 0, $bytes ) ) { return false; } if ( 0 === $extra ) { return true; } $mask = ( 0xFF << ( 8 - $extra ) ) & 0xFF; return ( ord( $bin[ $bytes ] ) & $mask ) === ( ord( $net[ $bytes ] ) & $mask ); } }