# thinkrank/2.1.0/includes/core/class-url-safety.php

ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console &amp; Local SEO, version 2.1.0. 374 lines.

- Page: https://pluginprobe.com/plugins/thinkrank/2.1.0/code/includes/core/class-url-safety.php
- Raw: https://pluginprobe.com/plugins/thinkrank/2.1.0/raw/includes/core/class-url-safety.php
- Modified: 2026-08-23T12:33:12+00:00

Line numbers below start at 1. Link to a line or a range by appending a fragment to the
page URL, for example `https://pluginprobe.com/plugins/thinkrank/2.1.0/code/includes/core/class-url-safety.php#L10-L20`.

```php
<?php
/**
 * Shared SSRF guard for server-side fetches of user-supplied URLs.
 *
 * @package ThinkRank
 * @subpackage Core
 * @since 1.29.0
 */

declare(strict_types=1);

namespace ThinkRank\Core;

use WP_Error;
use WP_Http;

if (!defined('ABSPATH')) {
    exit;
}

/**
 * URL safety checks for outbound requests.
 *
 * Every feature that fetches a URL the user supplied (schema import, competitor
 * analysis, …) must go through here rather than carrying its own copy of the
 * block list — divergent copies are how a range like CGNAT ends up blocked in
 * one place and reachable in another.
 *
 * @since 1.29.0
 */
class Url_Safety {

    /**
     * Maximum redirect hops followed by {@see self::safe_remote_get()}.
     */
    private const MAX_REDIRECTS = 5;

    /**
     * Reserved IPv4 blocks PHP's FILTER_FLAG_NO_RES_RANGE does not cover.
     *
     * @var string[]
     */
    private const EXTRA_RESERVED_BLOCKS = [
        '100.64.0.0/10',   // Shared address space / CGNAT (RFC 6598).
        '192.0.0.0/24',    // IETF protocol assignments (RFC 6890).
        '198.18.0.0/15',   // Benchmarking (RFC 2544).
        '192.88.99.0/24',  // 6to4 relay anycast (RFC 7526).
    ];

    /**
     * Whether an IP address is a routable public address.
     *
     * Combines PHP's private/reserved-range filters with explicit blocks for the
     * reserved IPv4 ranges FILTER_FLAG_NO_RES_RANGE misses — most importantly
     * 100.64.0.0/10 (CGNAT), which some clouds route metadata over.
     *
     * @since 1.29.0
     *
     * @param string $ip IP address (v4 or v6).
     * @return bool True when the address is public.
     */
    public static function is_public_ip(string $ip): bool {
        // An IPv6 address that embeds an IPv4 target (IPv4-mapped ::ffff:a.b.c.d,
        // deprecated IPv4-compatible ::a.b.c.d, or NAT64 64:ff9b::a.b.c.d) routes
        // to that IPv4 address, so it is judged by that address alone — decided
        // here, before PHP's own filters, because those disagree with themselves
        // across versions on ::ffff:0:0/96: up to PHP 8.2 the whole block passes
        // as public (so ::ffff:169.254.169.254 reached cloud metadata), and from
        // PHP 8.3 the whole block is reserved (so a perfectly routable
        // ::ffff:8.8.8.8 was refused). Neither answer is usable; the embedded
        // IPv4 is, and it gives identical results on every supported version.
        $embedded = self::embedded_ipv4($ip);
        if (null !== $embedded) {
            return self::is_public_ip($embedded);
        }

        if (!filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) {
            return false;
        }

        foreach (self::EXTRA_RESERVED_BLOCKS as $cidr) {
            if (self::ipv4_in_cidr($ip, $cidr)) {
                return false;
            }
        }

        return true;
    }

    /**
     * Extract the embedded IPv4 target from an IPv4-in-IPv6 address.
     *
     * Covers the three forms that actually carry a routable IPv4 destination:
     * IPv4-mapped (::ffff:0:0/96), deprecated IPv4-compatible (::/96, excluding
     * the :: and ::1 specials), and NAT64 (64:ff9b::/96).
     *
     * @param string $ip IP address (v4 or v6).
     * @return string|null Dotted-quad IPv4 when one is embedded, otherwise null.
     */
    private static function embedded_ipv4(string $ip): ?string {
        if (strpos($ip, ':') === false) {
            return null; // Plain IPv4.
        }

        // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- inet_pton() warns on a malformed literal, which is a normal reject-path here, not an error.
        $packed = @inet_pton($ip);
        if (false === $packed || strlen($packed) !== 16) {
            return null;
        }

        $prefix = substr($packed, 0, 12);
        $mapped = "\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff";       // ::ffff:0:0/96
        $nat64  = "\x00\x64\xff\x9b\x00\x00\x00\x00\x00\x00\x00\x00";       // 64:ff9b::/96
        $compat = "\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00";       // ::/96 (IPv4-compatible)

        // A 4-byte packed string renders as a dotted-quad IPv4 via inet_ntop.
        if ($prefix === $mapped || $prefix === $nat64) {
            return (string) inet_ntop(substr($packed, 12));
        }

        if ($prefix === $compat) {
            $v4 = substr($packed, 12);
            // Skip :: (unspecified) and ::1 (loopback); the IPv6 range filter
            // already rejects those, and they carry no meaningful IPv4 target.
            if ($v4 === "\x00\x00\x00\x00" || $v4 === "\x00\x00\x00\x01") {
                return null;
            }
            return (string) inet_ntop($v4);
        }

        return null;
    }

    /**
     * Validate that a URL is an http(s) URL whose host resolves only to public
     * addresses.
     *
     * @since 1.29.0
     *
     * @param string $url URL to validate.
     * @return true|WP_Error True when safe to fetch, WP_Error otherwise.
     */
    public static function validate_public_url(string $url) {
        $ips = self::validated_ips($url);

        return is_wp_error($ips) ? $ips : true;
    }

    /**
     * The addresses a URL's host resolves to, once every one has been checked
     * against the block list.
     *
     * Returned rather than discarded so the fetch can be pinned to them:
     * handing the *hostname* to the HTTP transport lets it resolve a second
     * time, and a host answering a public address on this lookup and a private
     * one on the fetch walks straight past the block list (#405).
     *
     * @since 2.0.1
     *
     * @param string $url URL to validate.
     * @return string[]|WP_Error Validated IPs, or the reason the URL is refused.
     */
    private static function validated_ips(string $url) {
        $parts = wp_parse_url($url);

        if (empty($parts['scheme']) || empty($parts['host'])) {
            return new WP_Error('invalid_url', 'The URL is not allowed.', ['status' => 400]);
        }

        if (!in_array(strtolower($parts['scheme']), ['http', 'https'], true)) {
            return new WP_Error('invalid_url', 'Only http and https URLs can be fetched.', ['status' => 400]);
        }

        // Strip IPv6 literal brackets, if present.
        $host = trim($parts['host'], '[]');

        $ips = filter_var($host, FILTER_VALIDATE_IP) ? [$host] : self::resolve_host_ips($host);
        if (empty($ips)) {
            return new WP_Error('invalid_url', 'The URL host could not be resolved.', ['status' => 400]);
        }

        foreach ($ips as $ip) {
            if (!self::is_public_ip($ip)) {
                return new WP_Error('invalid_url', 'The URL is not allowed.', ['status' => 400]);
            }
        }

        return $ips;
    }

    /**
     * Perform the request against the addresses validate_public_url() approved.
     *
     * CURLOPT_RESOLVE pre-seeds cURL's name cache, so the connection goes to a
     * checked address while the hostname — and therefore SNI and certificate
     * validation — stays intact. Without it the transport performs its own
     * lookup and a 0-TTL record can answer differently the second time.
     *
     * The pin only applies to the cURL transport. On a site whose HTTP requests
     * go through the PHP streams fallback the request still runs, with the
     * pre-flight check alone — the behaviour before this change — rather than
     * failing closed on an install that simply lacks cURL.
     *
     * @since 2.0.1
     *
     * @param string   $url  URL to fetch.
     * @param array    $args wp_safe_remote_get() arguments.
     * @param string[] $ips  Validated addresses for the URL's host.
     * @return array|WP_Error Response array on success, WP_Error otherwise.
     */
    private static function request_pinned(string $url, array $args, array $ips) {
        $parts = wp_parse_url($url);
        $host  = trim((string) ($parts['host'] ?? ''), '[]');

        if ('' === $host || empty($ips)) {
            return wp_safe_remote_get($url, $args);
        }

        $port = isset($parts['port'])
            ? (int) $parts['port']
            : ('https' === strtolower((string) ($parts['scheme'] ?? '')) ? 443 : 80);

        // One entry per host:port, listing every validated address — pinning a
        // single one would turn a multi-A-record host into a single point of
        // failure, and they have all passed the same check.
        $resolve = sprintf('%s:%d:%s', $host, $port, implode(',', $ips));

        $pin = static function ($handle) use ($resolve): void {
            if (!defined('CURLOPT_RESOLVE')) {
                return;
            }

            // phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_setopt -- pinning the connection to an address the block list already approved; there is no WP_Http equivalent.
            curl_setopt($handle, CURLOPT_RESOLVE, [$resolve]);
        };

        add_action('http_api_curl', $pin, 10, 1);

        try {
            return wp_safe_remote_get($url, $args);
        } finally {
            remove_action('http_api_curl', $pin, 10);
        }
    }

    /**
     * Whether a URL is safe to fetch.
     *
     * Boolean convenience wrapper around {@see self::validate_public_url()} for
     * call sites that only branch on safe/unsafe.
     *
     * @since 1.29.0
     *
     * @param string $url URL to check.
     * @return bool True when safe to fetch.
     */
    public static function is_safe_public_url(string $url): bool {
        return true === self::validate_public_url($url);
    }

    /**
     * Fetch a URL, re-validating the host against the block list on every
     * redirect hop.
     *
     * wp_safe_remote_get() re-validates redirect targets with
     * wp_http_validate_url(), which shares the link-local/CGNAT blind spot, so
     * redirects are followed manually (`redirection => 0`) and each hop is
     * checked before it is requested.
     *
     * Each hop is then *pinned* to the addresses its check approved, via
     * CURLOPT_RESOLVE. Per-hop revalidation alone closes the rebinding window
     * across hops but not the one inside a single hop, between resolving the
     * host and connecting to it — the transport resolved the name a second
     * time, and a 0-TTL record could answer differently (#405).
     *
     * @since 1.29.0
     *
     * @param string $url  URL to fetch.
     * @param array  $args Optional. wp_safe_remote_get() arguments.
     * @return array|WP_Error Response array on success, WP_Error otherwise.
     */
    public static function safe_remote_get(string $url, array $args = []) {
        for ($hop = 0; $hop <= self::MAX_REDIRECTS; $hop++) {
            if (!wp_http_validate_url($url)) {
                return new WP_Error('invalid_url', 'The URL is not allowed.', ['status' => 400]);
            }

            $ips = self::validated_ips($url);
            if (is_wp_error($ips)) {
                return $ips;
            }

            $response = self::request_pinned($url, array_merge($args, ['redirection' => 0]), $ips);

            if (is_wp_error($response)) {
                return $response;
            }

            $code = (int) wp_remote_retrieve_response_code($response);
            if ($code < 300 || $code >= 400) {
                return $response;
            }

            $location = trim((string) wp_remote_retrieve_header($response, 'location'));
            if ('' === $location) {
                return $response; // Redirect without a target — treat as final.
            }

            // Resolve a relative Location against the current URL.
            $url = (string) WP_Http::make_absolute_url($location, $url);
            if ('' === $url) {
                return new WP_Error('invalid_url', 'The URL is not allowed.', ['status' => 400]);
            }
        }

        return new WP_Error('too_many_redirects', 'The URL redirected too many times.', ['status' => 400]);
    }

    /**
     * Whether an IPv4 address falls within a CIDR block.
     *
     * @param string $ip   IPv4 address.
     * @param string $cidr CIDR block (e.g. 100.64.0.0/10).
     * @return bool
     */
    private static function ipv4_in_cidr(string $ip, string $cidr): bool {
        if (strpos($ip, ':') !== false) {
            return false; // IPv6 is not covered by these IPv4 blocks.
        }

        [$subnet, $bits] = array_pad(explode('/', $cidr, 2), 2, '32');
        $ip_long     = ip2long($ip);
        $subnet_long = ip2long($subnet);
        if (false === $ip_long || false === $subnet_long) {
            return false;
        }

        $mask = -1 << (32 - (int) $bits);
        return ($ip_long & $mask) === ($subnet_long & $mask);
    }

    /**
     * Resolve a hostname to its IPv4 + IPv6 addresses.
     *
     * @param string $host Hostname.
     * @return string[] Resolved IP addresses (may be empty).
     */
    private static function resolve_host_ips(string $host): array {
        $ips = [];

        // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- dns_get_record() emits a warning on NXDOMAIN, which is a normal answer here, not an error.
        $records = @dns_get_record($host, DNS_A + DNS_AAAA);
        if (is_array($records)) {
            foreach ($records as $record) {
                if (!empty($record['ip'])) {
                    $ips[] = $record['ip'];       // A record.
                } elseif (!empty($record['ipv6'])) {
                    $ips[] = $record['ipv6'];     // AAAA record.
                }
            }
        }

        // Fallback where dns_get_record is unavailable or returns nothing.
        if (empty($ips)) {
            $resolved = gethostbyname($host);
            if ($resolved && $resolved !== $host) {
                $ips[] = $resolved;
            }
        }

        return $ips;
    }
}

```
