mixed result set is exotic but possible.) * An empty / failed result here is NOT fatal on its own — an * IPv6-only host has no A records but legitimately resolves via * AAAA. We treat A-failure as "consult AAAA" rather than refuse. */ $ipv4_set = @\gethostbynamel( $host ); if ( false === $ipv4_set ) { $ipv4_set = array(); } foreach ( $ipv4_set as $ip ) { if ( ! \is_string( $ip ) || ! self::is_public_ip( $ip ) ) { return false; } } /** * Resolve AAAA records (v6). `dns_get_record()` is PHP-builtin * but disabled on some hardened hosts, so its absence isn't an * error — we just lose the v6 leg of the check. */ $ipv6_set = array(); if ( \function_exists( 'dns_get_record' ) ) { $aaaa = @\dns_get_record( $host, DNS_AAAA ); if ( \is_array( $aaaa ) ) { foreach ( $aaaa as $rec ) { if ( isset( $rec['ipv6'] ) && \is_string( $rec['ipv6'] ) ) { $ipv6_set[] = $rec['ipv6']; } } } } foreach ( $ipv6_set as $ip ) { if ( ! self::is_public_ip( $ip ) ) { return false; } } /** * Refuse hosts that have NO public address at all — i.e. neither * a v4 resolution nor a v6 resolution. This covers both the * genuinely-unresolvable case and the "dns_get_record disabled * and gethostbynamel returned nothing" edge. */ if ( empty( $ipv4_set ) && empty( $ipv6_set ) ) { return false; } return true; } /** * Returns true when the URL has an http/https scheme and its host * matches this installation's own home or site host. * * The comparison is a case-insensitive host-string match — no DNS * lookup. Requests the plugin issues to its own front-end (e.g. * priming this site's own minify cache files) must keep working on * installs whose hostname resolves to an internal address * (intranet, staging, split-horizon DNS), where a resolution-based * check like {@see self::is_public_host()} would refuse them. * * @since 2.10.1 * * @param string $w3tc_url Candidate URL. * * @return bool */ public static function is_self_host_url( $w3tc_url ) { if ( ! self::is_valid_http_scheme( $w3tc_url ) ) { return false; } $host = \wp_parse_url( $w3tc_url, PHP_URL_HOST ); if ( ! \is_string( $host ) || '' === $host ) { return false; } $self_hosts = array( \wp_parse_url( \home_url(), PHP_URL_HOST ), \wp_parse_url( \site_url(), PHP_URL_HOST ), ); foreach ( $self_hosts as $self_host ) { if ( \is_string( $self_host ) && '' !== $self_host && 0 === \strcasecmp( $host, $self_host ) ) { return true; } } return false; } /** * Returns true when a URL may be fetched by plugin HTTP helpers: * this site's own host, or a host that resolves only to public * addresses (see {@see self::is_public_host()}). * * Protocol-relative URLs (`//example.com/...`) are normalized with * the current request scheme before evaluation. * * @since 2.10.6 * * @param string $w3tc_url Candidate URL. * * @return bool */ public static function is_allowed_outbound_url( $w3tc_url ) { $w3tc_url = self::normalize_protocol_relative_url( $w3tc_url ); if ( '' === $w3tc_url ) { return false; } return self::is_self_host_url( $w3tc_url ) || self::is_public_host( $w3tc_url ); } /** * Expands a protocol-relative URL using the current request scheme. * * @since 2.10.6 * * @param string $w3tc_url Candidate URL. * * @return string Normalized URL, or empty string when input is invalid. */ public static function normalize_protocol_relative_url( $w3tc_url ) { if ( ! \is_string( $w3tc_url ) || '' === $w3tc_url ) { return ''; } if ( 0 === \strpos( $w3tc_url, '//' ) ) { return ( Util_Environment::is_https() ? 'https:' : 'http:' ) . $w3tc_url; } return $w3tc_url; } /** * Returns true for an http or https URL; refuses every other * scheme. `gopher`, `file`, `php`, `data`, etc. each open * out-of-band fetch paths in their own right and have no place * in a `wp_remote_*` call. * * @since 2.10.0 * * @param string $w3tc_url Candidate URL. * * @return bool */ public static function is_valid_http_scheme( $w3tc_url ) { if ( ! \is_string( $w3tc_url ) || '' === $w3tc_url ) { return false; } $scheme = \wp_parse_url( $w3tc_url, PHP_URL_SCHEME ); return 'http' === $scheme || 'https' === $scheme; } /** * Returns true when the given IP literal is a public-range address. * * Wraps `filter_var` with `FILTER_FLAG_NO_PRIV_RANGE` (rejects * 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, fc00::/7) and * `FILTER_FLAG_NO_RES_RANGE` (rejects 0.0.0.0/8, 127.0.0.0/8, * 169.254.0.0/16 / link-local, 224.0.0.0/4 multicast, 240.0.0.0/4 * reserved-future, ::1, fe80::/10, fc00::/7, ff00::/8). * * Adds an explicit IPv4-mapped-IPv6 check (`::ffff:127.0.0.1`) — * PHP's filter sometimes lets these through depending on version, * and unwrapping the embedded v4 lets us re-run the v4 check. * * @since 2.10.0 * * @param string $ip IP literal (v4 or v6). * * @return bool */ public static function is_public_ip( $ip ) { if ( ! \is_string( $ip ) || '' === $ip ) { return false; } /** * IPv4-mapped IPv6: ::ffff:127.0.0.1 — pull out the embedded v4 * and re-run the check (otherwise FILTER_VALIDATE_IP would treat * the whole thing as a "public v6 address" with no privacy * markers, even though it routes to a v4 loopback). */ if ( false !== \stripos( $ip, '::ffff:' ) ) { $tail = \substr( $ip, \stripos( $ip, '::ffff:' ) + 7 ); if ( false !== \filter_var( $tail, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 ) ) { return self::is_public_ip( $tail ); } } return false !== \filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ); } /** * Returns true for an IP that is safe as a target for outbound * internal-services traffic — i.e. RFC1918 / private-range hosts * are accepted (a Varnish or memcached server on `10.x` or * `192.168.x` is a normal deployment) BUT loopback, link-local * (incl. AWS instance-metadata `169.254.169.254`), broadcast, and * reserved-future ranges are still refused. * * Uses `FILTER_FLAG_NO_RES_RANGE` only — without `NO_PRIV_RANGE`. * Per the PHP docs, `NO_RES_RANGE` blocks `0.0.0.0/8`, * `127.0.0.0/8`, `169.254.0.0/16`, `240.0.0.0/4`, `::1/128`, * `::/128`, `::ffff:0:0/96`, and `fe80::/10` — exactly the * dangerous set for SSRF — while leaving RFC1918 ranges valid. * * Use this (not {@see self::is_public_ip()}) at sinks like * Varnish purge where the destination may be a private-network * host. Loopback is refused here; callers that need loopback on * an HTTP/Varnish port should use * {@see self::is_safe_varnish_purge_target()}. * * @since 2.10.0 * * @param string $ip IP literal (v4 or v6). * * @return bool */ public static function is_safe_internal_ip( $ip ) { if ( ! \is_string( $ip ) || '' === $ip ) { return false; } // IPv4-mapped IPv6 — pull out and re-check the embedded v4. if ( false !== \stripos( $ip, '::ffff:' ) ) { $tail = \substr( $ip, \stripos( $ip, '::ffff:' ) + 7 ); if ( false !== \filter_var( $tail, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 ) ) { return self::is_safe_internal_ip( $tail ); } } return false !== \filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_RES_RANGE ); } /** * Returns true when `$host` is a hostname or IP literal that * resolves only to safe outbound destinations — i.e. {@see * self::is_safe_internal_ip()} returns true for every IP the host * resolves to. * * Mirrors {@see self::is_public_host()}'s resolution shape (literal- * IP fast path, gethostbynamel for A records, dns_get_record for * AAAA, refuse when neither resolves) but with the looser * RFC1918-allowing IP policy. * * The caller is responsible for any scheme / port / path validation * separately — this method takes a hostname or literal, not a URL. * * @since 2.10.0 * * @param string $host Hostname or IP literal (no scheme, no port). * * @return bool */ public static function host_resolves_safe_internal( $host ) { if ( ! \is_string( $host ) || '' === $host ) { return false; } // Strip surrounding brackets from an IPv6 literal. if ( '[' === \substr( $host, 0, 1 ) && ']' === \substr( $host, -1 ) ) { $host = \substr( $host, 1, -1 ); } // Literal IP — validate directly. if ( false !== \filter_var( $host, FILTER_VALIDATE_IP ) ) { return self::is_safe_internal_ip( $host ); } $ipv4_set = @\gethostbynamel( $host ); if ( false === $ipv4_set ) { $ipv4_set = array(); } foreach ( $ipv4_set as $ip ) { if ( ! \is_string( $ip ) || ! self::is_safe_internal_ip( $ip ) ) { return false; } } $ipv6_set = array(); if ( \function_exists( 'dns_get_record' ) ) { $aaaa = @\dns_get_record( $host, DNS_AAAA ); if ( \is_array( $aaaa ) ) { foreach ( $aaaa as $rec ) { if ( isset( $rec['ipv6'] ) && \is_string( $rec['ipv6'] ) ) { $ipv6_set[] = $rec['ipv6']; } } } } foreach ( $ipv6_set as $ip ) { if ( ! self::is_safe_internal_ip( $ip ) ) { return false; } } // Refuse hosts with no resolved address at all. return ! empty( $ipv4_set ) || ! empty( $ipv6_set ); } /** * Returns true when `$ip` is a loopback address (IPv4 `127.0.0.0/8` * or IPv6 `::1`, including IPv4-mapped `::ffff:127.x.x.x`). * * @since 2.10.2 * * @param string $ip IP literal (v4 or v6). * * @return bool */ public static function is_loopback_ip( $ip ) { if ( ! \is_string( $ip ) || '' === $ip ) { return false; } if ( false !== \stripos( $ip, '::ffff:' ) ) { $tail = \substr( $ip, \stripos( $ip, '::ffff:' ) + 7 ); if ( false !== \filter_var( $tail, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 ) ) { return self::is_loopback_ip( $tail ); } } if ( false !== \filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 ) ) { return 0 === \strpos( $ip, '127.' ); } if ( false !== \filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6 ) ) { $packed = @\inet_pton( $ip ); $loop = @\inet_pton( '::1' ); return false !== $packed && false !== $loop && $packed === $loop; } return false; } /** * Returns true when `$host` resolves only to loopback addresses. * * @since 2.10.2 * * @param string $host Hostname or IP literal (no scheme, no port). * * @return bool */ public static function host_resolves_loopback_only( $host ) { if ( ! \is_string( $host ) || '' === $host ) { return false; } if ( '[' === \substr( $host, 0, 1 ) && ']' === \substr( $host, -1 ) ) { $host = \substr( $host, 1, -1 ); } if ( false !== \filter_var( $host, FILTER_VALIDATE_IP ) ) { return self::is_loopback_ip( $host ); } $ipv4_set = @\gethostbynamel( $host ); if ( false === $ipv4_set ) { $ipv4_set = array(); } foreach ( $ipv4_set as $ip ) { if ( ! \is_string( $ip ) || ! self::is_loopback_ip( $ip ) ) { return false; } } $ipv6_set = array(); if ( \function_exists( 'dns_get_record' ) ) { $aaaa = @\dns_get_record( $host, DNS_AAAA ); if ( \is_array( $aaaa ) ) { foreach ( $aaaa as $rec ) { if ( isset( $rec['ipv6'] ) && \is_string( $rec['ipv6'] ) ) { $ipv6_set[] = $rec['ipv6']; } } } } foreach ( $ipv6_set as $ip ) { if ( ! self::is_loopback_ip( $ip ) ) { return false; } } return ! empty( $ipv4_set ) || ! empty( $ipv6_set ); } /** * Returns true when `$port` is a common HTTP / Varnish listen port. * * Filterable via `w3tc_varnish_http_ports`. * * @since 2.10.2 * * @param int $port Destination port. * * @return bool */ public static function is_varnish_http_port( $port ) { $port = (int) $port; $ports = array( 80, 443, 8080, 6081, 6082 ); /** * Filters the HTTP/Varnish ports allowed for loopback PURGE. * * @since 2.10.2 * * @param int[] $ports Allowed destination ports. */ $ports = \apply_filters( 'w3tc_varnish_http_ports', $ports ); if ( ! \is_array( $ports ) ) { return false; } $ports = \array_map( 'intval', $ports ); return \in_array( $port, $ports, true ); } /** * Returns true when `$host`:`$port` is an allowed Varnish PURGE target. * * Same hosts as {@see self::host_resolves_safe_internal()}, plus * loopback when the port is a common HTTP/Varnish listen port. * * @since 2.10.2 * * @param string $host Hostname or IP literal (no scheme, no port). * @param int|string $port Destination port. * * @return bool */ public static function is_safe_varnish_purge_target( $host, $port ) { if ( self::host_resolves_safe_internal( $host ) ) { return true; } return self::host_resolves_loopback_only( $host ) && self::is_varnish_http_port( $port ); } /** * Returns true when the URL parses, has an https scheme, every * resolved IP is public (per {@see self::is_public_host()}), AND * the host ends with one of the entries in `$allowed_suffixes` * (matched case-insensitively against the FQDN's tail). * * The suffix list MUST be authoritative-host patterns such as * `.rackspacecloud.com` — leading dot included so a literal * `rackspacecloud.com` (without subdomain) is NOT incorrectly * matched by a `rackspacecloud.com` suffix on an attacker-owned * `foorackspacecloud.com.evil.example`. * * @since 2.10.0 * * @param string $w3tc_url Candidate URL. * @param array $allowed_suffixes Host suffixes such as * `array( '.rackspacecloud.com', * '.rackcdn.com' )`. * * @return bool */ public static function is_https_public_host_with_suffix( $w3tc_url, array $allowed_suffixes ) { if ( ! \is_string( $w3tc_url ) || '' === $w3tc_url ) { return false; } if ( 'https' !== \wp_parse_url( $w3tc_url, PHP_URL_SCHEME ) ) { return false; } if ( ! self::is_public_host( $w3tc_url ) ) { return false; } $host = \wp_parse_url( $w3tc_url, PHP_URL_HOST ); if ( ! \is_string( $host ) || '' === $host ) { return false; } $host_lc = \strtolower( $host ); foreach ( $allowed_suffixes as $suffix ) { if ( ! \is_string( $suffix ) || '' === $suffix ) { continue; } $suffix_lc = \strtolower( $suffix ); $slen = \strlen( $suffix_lc ); $hlen = \strlen( $host_lc ); if ( $hlen >= $slen && \substr( $host_lc, -$slen ) === $suffix_lc ) { return true; } } return false; } /** * Constant-override allowlist: https scheme plus exact host or * leading-dot suffix match. * * Used for vendored API URLs declared behind `if ( ! defined() )` * guards. Does not resolve DNS — destination policy is host-shape * only, matching the license-API helper. Exact hosts cover apex * names (`w3-edge.com`); suffixes MUST include the leading dot * (`.w3-edge.com`) so `xw3-edge.com` cannot pass. * * @since 2.10.6 * * @param string $w3tc_url Candidate URL. * @param array $exact_hosts Exact hostnames (lowercase-compared). * @param array $allowed_suffixes Leading-dot suffixes. * * @return bool */ public static function is_https_host_allowlisted( $w3tc_url, array $exact_hosts, array $allowed_suffixes ) { if ( ! \is_string( $w3tc_url ) || '' === $w3tc_url ) { return false; } $scheme = \wp_parse_url( $w3tc_url, PHP_URL_SCHEME ); $host = \wp_parse_url( $w3tc_url, PHP_URL_HOST ); if ( 'https' !== $scheme || ! \is_string( $host ) || '' === $host ) { return false; } $host_lc = \strtolower( $host ); foreach ( $exact_hosts as $exact ) { if ( \is_string( $exact ) && '' !== $exact && \strtolower( $exact ) === $host_lc ) { return true; } } foreach ( $allowed_suffixes as $suffix ) { if ( ! \is_string( $suffix ) || '' === $suffix || '.' !== $suffix[0] ) { continue; } $suffix_lc = \strtolower( $suffix ); $slen = \strlen( $suffix_lc ); $hlen = \strlen( $host_lc ); if ( $hlen > $slen && \substr( $host_lc, -$slen ) === $suffix_lc ) { return true; } } return false; } }