get_error_message() ); if ( class_exists( '\WpOrg\Requests\Exception' ) ) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- $message is esc_html()ed above; it is consumed by the Requests library, never printed. throw new \WpOrg\Requests\Exception( $message, 'airanksuite_ssrf', $location ); } // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- same escaped message, legacy class for WP < 6.2. throw new \Requests_Exception( $message, 'airanksuite_ssrf', $location ); // WP < 6.2. @phpstan-ignore-line } /** * pre_http_request handler: short-circuit blocked destinations with a WP_Error. * * @param false|array|\WP_Error $pre Existing short-circuit, if any. * @param array $args Request args (unused). * @param string $url Target URL. * @return false|array|\WP_Error */ public static function intercept( $pre, $args, $url ) { // Respect a short-circuit another filter already set. if ( false !== $pre ) { return $pre; } $error = self::validate( (string) $url ); return $error instanceof \WP_Error ? $error : $pre; } /** * Validate a URL against the SSRF policy. * * @return true|\WP_Error True when safe, WP_Error naming the reason otherwise. */ public static function validate( string $url ) { $scheme = strtolower( (string) wp_parse_url( $url, PHP_URL_SCHEME ) ); if ( ! in_array( $scheme, array( 'http', 'https' ), true ) ) { return new \WP_Error( 'airanksuite_ssrf_scheme', __( 'Only http and https URLs may be fetched.', 'ai-rank-suite' ) ); } // Credentials in a URL are how a request gets aimed somewhere the // reviewer of the link never sees — http://trusted.example@127.0.0.1/ // reads as "trusted.example" to a human and resolves to loopback. if ( null !== wp_parse_url( $url, PHP_URL_USER ) || null !== wp_parse_url( $url, PHP_URL_PASS ) ) { return new \WP_Error( 'airanksuite_ssrf_credentials', __( 'URLs carrying a username or password are not fetched.', 'ai-rank-suite' ) ); } // Ports: only the ones the web actually answers on. Everything else is // a service (databases, caches, admin panels) that a link checker has // no business probing. $port = wp_parse_url( $url, PHP_URL_PORT ); if ( null !== $port ) { /** * Filter the ports an outbound plugin request may reach. * * @param array $ports Allowed port numbers. */ $allowed_ports = array( 80, 443, 8080, 8443 ); // The site's own port is always its own business: a staging site // on :8000 must be able to read its own sitemap and scan its own // URLs, and refusing it would make the URL inventory silently // incomplete on exactly the installs people test on. $own_port = wp_parse_url( home_url(), PHP_URL_PORT ); if ( null !== $own_port && strtolower( (string) wp_parse_url( $url, PHP_URL_HOST ) ) === strtolower( (string) wp_parse_url( home_url(), PHP_URL_HOST ) ) ) { $allowed_ports[] = (int) $own_port; } $allowed_ports = (array) apply_filters( 'airanksuite/ssrf/allowed_ports', $allowed_ports ); if ( ! in_array( (int) $port, array_map( 'intval', $allowed_ports ), true ) ) { return new \WP_Error( 'airanksuite_ssrf_port', /* translators: %d: port number. */ sprintf( __( 'Refusing to reach port %d.', 'ai-rank-suite' ), (int) $port ) ); } } $host = strtolower( trim( (string) wp_parse_url( $url, PHP_URL_HOST ) ) ); $host = trim( $host, '[]' ); // Strip IPv6 literal brackets. if ( '' === $host ) { return new \WP_Error( 'airanksuite_ssrf_host', __( 'The URL has no host.', 'ai-rank-suite' ) ); } // The site's own host is the one host the plugin exists to fetch — // its sitemap, its pages for the URL scan, its own head for the live // check. WordPress core makes the same exception in // wp_http_validate_url(): an intranet or staging install whose name // resolves to a private address must still be able to read itself, // or the URL inventory is silently incomplete on exactly those sites. $own_host = strtolower( trim( (string) wp_parse_url( home_url(), PHP_URL_HOST ) ) ); if ( '' !== $own_host && $host === $own_host ) { return true; } $ips = self::resolve( $host ); if ( empty( $ips ) ) { // Fail closed. A host we cannot resolve is a host we cannot vet, // and the request would otherwise proceed on the strength of a // lookup that returned nothing. return new \WP_Error( 'airanksuite_ssrf_unresolvable', /* translators: %s: hostname. */ sprintf( __( 'Refusing to reach a host that does not resolve (%s).', 'ai-rank-suite' ), $host ) ); } foreach ( $ips as $ip ) { if ( ! self::is_public_ip( $ip ) ) { return new \WP_Error( 'airanksuite_ssrf_blocked', /* translators: %s: resolved IP address. */ sprintf( __( 'Refusing to reach a private or reserved address (%s).', 'ai-rank-suite' ), $ip ) ); } } return true; } /** * Resolve a host to every IP it points at (literals pass through unchanged). * * @return array */ private static function resolve( string $host ): array { if ( filter_var( $host, FILTER_VALIDATE_IP ) ) { return array( $host ); } $ips = array(); $v4 = gethostbynamel( $host ); if ( is_array( $v4 ) ) { $ips = $v4; } // gethostbynamel is IPv4-only; pull AAAA records so an IPv6 answer can't // slip a link-local or loopback destination past the check. $aaaa = @dns_get_record( $host, DNS_AAAA ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged if ( is_array( $aaaa ) ) { foreach ( $aaaa as $record ) { if ( ! empty( $record['ipv6'] ) ) { $ips[] = (string) $record['ipv6']; } } } // Deliberately no fallback here: an empty result means "could not // resolve", and validate() treats that as a refusal rather than as // permission. A resolver that fails — or is made to fail — must not be // a way past the guard. return $ips; } /** * Is this IP a public, routable destination? * * Uses PHP's private- and reserved-range filters (which cover loopback, * RFC 1918, link-local incl. 169.254.169.254, IPv6 ULA/link-local/loopback) * and adds CGNAT 100.64.0.0/10, which those flags do not exclude. */ public static function is_public_ip( string $ip ): bool { // Normalise IPv4-mapped IPv6 (e.g. ::ffff:169.254.169.254) to its v4 form. if ( 0 === stripos( $ip, '::ffff:' ) && false !== strpos( $ip, '.' ) ) { $ip = substr( $ip, (int) strrpos( $ip, ':' ) + 1 ); } if ( ! filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ) ) { return false; } // Carrier-grade NAT (100.64.0.0/10) is routable-looking but not public and // is not excluded by the reserved-range flag. if ( filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 ) ) { $long = ip2long( $ip ); if ( false !== $long && ( $long & 0xFFC00000 ) === ( ip2long( '100.64.0.0' ) & 0xFFC00000 ) ) { return false; } } return true; } }