PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 1.32.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v1.32.0
2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 1.26.0 1.25.0 trunk 1.0.0 1.0.1 1.0.2 1.1.0 1.10.0 All 48 releases
thinkrank / includes / core / class-url-safety.php

class-url-safety.php in ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO 1.32.0, at includes/core/class-url-safety.php

294 lines 10.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Shared SSRF guard for server-side fetches of user-supplied URLs.
4 *
5 * @package ThinkRank
6 * @subpackage Core
7 * @since 1.29.0
8 */
9
10 declare(strict_types=1);
11
12 namespace ThinkRank\Core;
13
14 use WP_Error;
15 use WP_Http;
16
17 if (!defined('ABSPATH')) {
18 exit;
19 }
20
21 /**
22 * URL safety checks for outbound requests.
23 *
24 * Every feature that fetches a URL the user supplied (schema import, competitor
25 * analysis, …) must go through here rather than carrying its own copy of the
26 * block list — divergent copies are how a range like CGNAT ends up blocked in
27 * one place and reachable in another.
28 *
29 * @since 1.29.0
30 */
31 class Url_Safety {
32
33 /**
34 * Maximum redirect hops followed by {@see self::safe_remote_get()}.
35 */
36 private const MAX_REDIRECTS = 5;
37
38 /**
39 * Reserved IPv4 blocks PHP's FILTER_FLAG_NO_RES_RANGE does not cover.
40 *
41 * @var string[]
42 */
43 private const EXTRA_RESERVED_BLOCKS = [
44 '100.64.0.0/10', // Shared address space / CGNAT (RFC 6598).
45 '192.0.0.0/24', // IETF protocol assignments (RFC 6890).
46 '198.18.0.0/15', // Benchmarking (RFC 2544).
47 '192.88.99.0/24', // 6to4 relay anycast (RFC 7526).
48 ];
49
50 /**
51 * Whether an IP address is a routable public address.
52 *
53 * Combines PHP's private/reserved-range filters with explicit blocks for the
54 * reserved IPv4 ranges FILTER_FLAG_NO_RES_RANGE misses — most importantly
55 * 100.64.0.0/10 (CGNAT), which some clouds route metadata over.
56 *
57 * @since 1.29.0
58 *
59 * @param string $ip IP address (v4 or v6).
60 * @return bool True when the address is public.
61 */
62 public static function is_public_ip(string $ip): bool {
63 // An IPv6 address that embeds an IPv4 target (IPv4-mapped ::ffff:a.b.c.d,
64 // deprecated IPv4-compatible ::a.b.c.d, or NAT64 64:ff9b::a.b.c.d) routes
65 // to that IPv4 address, so it is judged by that address alone — decided
66 // here, before PHP's own filters, because those disagree with themselves
67 // across versions on ::ffff:0:0/96: up to PHP 8.2 the whole block passes
68 // as public (so ::ffff:169.254.169.254 reached cloud metadata), and from
69 // PHP 8.3 the whole block is reserved (so a perfectly routable
70 // ::ffff:8.8.8.8 was refused). Neither answer is usable; the embedded
71 // IPv4 is, and it gives identical results on every supported version.
72 $embedded = self::embedded_ipv4($ip);
73 if (null !== $embedded) {
74 return self::is_public_ip($embedded);
75 }
76
77 if (!filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) {
78 return false;
79 }
80
81 foreach (self::EXTRA_RESERVED_BLOCKS as $cidr) {
82 if (self::ipv4_in_cidr($ip, $cidr)) {
83 return false;
84 }
85 }
86
87 return true;
88 }
89
90 /**
91 * Extract the embedded IPv4 target from an IPv4-in-IPv6 address.
92 *
93 * Covers the three forms that actually carry a routable IPv4 destination:
94 * IPv4-mapped (::ffff:0:0/96), deprecated IPv4-compatible (::/96, excluding
95 * the :: and ::1 specials), and NAT64 (64:ff9b::/96).
96 *
97 * @param string $ip IP address (v4 or v6).
98 * @return string|null Dotted-quad IPv4 when one is embedded, otherwise null.
99 */
100 private static function embedded_ipv4(string $ip): ?string {
101 if (strpos($ip, ':') === false) {
102 return null; // Plain IPv4.
103 }
104
105 // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- inet_pton() warns on a malformed literal, which is a normal reject-path here, not an error.
106 $packed = @inet_pton($ip);
107 if (false === $packed || strlen($packed) !== 16) {
108 return null;
109 }
110
111 $prefix = substr($packed, 0, 12);
112 $mapped = "\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff"; // ::ffff:0:0/96
113 $nat64 = "\x00\x64\xff\x9b\x00\x00\x00\x00\x00\x00\x00\x00"; // 64:ff9b::/96
114 $compat = "\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00"; // ::/96 (IPv4-compatible)
115
116 // A 4-byte packed string renders as a dotted-quad IPv4 via inet_ntop.
117 if ($prefix === $mapped || $prefix === $nat64) {
118 return (string) inet_ntop(substr($packed, 12));
119 }
120
121 if ($prefix === $compat) {
122 $v4 = substr($packed, 12);
123 // Skip :: (unspecified) and ::1 (loopback); the IPv6 range filter
124 // already rejects those, and they carry no meaningful IPv4 target.
125 if ($v4 === "\x00\x00\x00\x00" || $v4 === "\x00\x00\x00\x01") {
126 return null;
127 }
128 return (string) inet_ntop($v4);
129 }
130
131 return null;
132 }
133
134 /**
135 * Validate that a URL is an http(s) URL whose host resolves only to public
136 * addresses.
137 *
138 * @since 1.29.0
139 *
140 * @param string $url URL to validate.
141 * @return true|WP_Error True when safe to fetch, WP_Error otherwise.
142 */
143 public static function validate_public_url(string $url) {
144 $parts = wp_parse_url($url);
145
146 if (empty($parts['scheme']) || empty($parts['host'])) {
147 return new WP_Error('invalid_url', 'The URL is not allowed.', ['status' => 400]);
148 }
149
150 if (!in_array(strtolower($parts['scheme']), ['http', 'https'], true)) {
151 return new WP_Error('invalid_url', 'Only http and https URLs can be fetched.', ['status' => 400]);
152 }
153
154 // Strip IPv6 literal brackets, if present.
155 $host = trim($parts['host'], '[]');
156
157 $ips = filter_var($host, FILTER_VALIDATE_IP) ? [$host] : self::resolve_host_ips($host);
158 if (empty($ips)) {
159 return new WP_Error('invalid_url', 'The URL host could not be resolved.', ['status' => 400]);
160 }
161
162 foreach ($ips as $ip) {
163 if (!self::is_public_ip($ip)) {
164 return new WP_Error('invalid_url', 'The URL is not allowed.', ['status' => 400]);
165 }
166 }
167
168 return true;
169 }
170
171 /**
172 * Whether a URL is safe to fetch.
173 *
174 * Boolean convenience wrapper around {@see self::validate_public_url()} for
175 * call sites that only branch on safe/unsafe.
176 *
177 * @since 1.29.0
178 *
179 * @param string $url URL to check.
180 * @return bool True when safe to fetch.
181 */
182 public static function is_safe_public_url(string $url): bool {
183 return true === self::validate_public_url($url);
184 }
185
186 /**
187 * Fetch a URL, re-validating the host against the block list on every
188 * redirect hop.
189 *
190 * wp_safe_remote_get() re-validates redirect targets with
191 * wp_http_validate_url(), which shares the link-local/CGNAT blind spot, so
192 * redirects are followed manually (`redirection => 0`) and each hop is
193 * checked before it is requested. That also closes the DNS-rebinding window
194 * a single pre-flight check would leave open across hops.
195 *
196 * @since 1.29.0
197 *
198 * @param string $url URL to fetch.
199 * @param array $args Optional. wp_safe_remote_get() arguments.
200 * @return array|WP_Error Response array on success, WP_Error otherwise.
201 */
202 public static function safe_remote_get(string $url, array $args = []) {
203 for ($hop = 0; $hop <= self::MAX_REDIRECTS; $hop++) {
204 if (!wp_http_validate_url($url)) {
205 return new WP_Error('invalid_url', 'The URL is not allowed.', ['status' => 400]);
206 }
207
208 $host_check = self::validate_public_url($url);
209 if (is_wp_error($host_check)) {
210 return $host_check;
211 }
212
213 $response = wp_safe_remote_get($url, array_merge($args, ['redirection' => 0]));
214
215 if (is_wp_error($response)) {
216 return $response;
217 }
218
219 $code = (int) wp_remote_retrieve_response_code($response);
220 if ($code < 300 || $code >= 400) {
221 return $response;
222 }
223
224 $location = trim((string) wp_remote_retrieve_header($response, 'location'));
225 if ('' === $location) {
226 return $response; // Redirect without a target — treat as final.
227 }
228
229 // Resolve a relative Location against the current URL.
230 $url = (string) WP_Http::make_absolute_url($location, $url);
231 if ('' === $url) {
232 return new WP_Error('invalid_url', 'The URL is not allowed.', ['status' => 400]);
233 }
234 }
235
236 return new WP_Error('too_many_redirects', 'The URL redirected too many times.', ['status' => 400]);
237 }
238
239 /**
240 * Whether an IPv4 address falls within a CIDR block.
241 *
242 * @param string $ip IPv4 address.
243 * @param string $cidr CIDR block (e.g. 100.64.0.0/10).
244 * @return bool
245 */
246 private static function ipv4_in_cidr(string $ip, string $cidr): bool {
247 if (strpos($ip, ':') !== false) {
248 return false; // IPv6 is not covered by these IPv4 blocks.
249 }
250
251 [$subnet, $bits] = array_pad(explode('/', $cidr, 2), 2, '32');
252 $ip_long = ip2long($ip);
253 $subnet_long = ip2long($subnet);
254 if (false === $ip_long || false === $subnet_long) {
255 return false;
256 }
257
258 $mask = -1 << (32 - (int) $bits);
259 return ($ip_long & $mask) === ($subnet_long & $mask);
260 }
261
262 /**
263 * Resolve a hostname to its IPv4 + IPv6 addresses.
264 *
265 * @param string $host Hostname.
266 * @return string[] Resolved IP addresses (may be empty).
267 */
268 private static function resolve_host_ips(string $host): array {
269 $ips = [];
270
271 // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- dns_get_record() emits a warning on NXDOMAIN, which is a normal answer here, not an error.
272 $records = @dns_get_record($host, DNS_A + DNS_AAAA);
273 if (is_array($records)) {
274 foreach ($records as $record) {
275 if (!empty($record['ip'])) {
276 $ips[] = $record['ip']; // A record.
277 } elseif (!empty($record['ipv6'])) {
278 $ips[] = $record['ipv6']; // AAAA record.
279 }
280 }
281 }
282
283 // Fallback where dns_get_record is unavailable or returns nothing.
284 if (empty($ips)) {
285 $resolved = gethostbyname($host);
286 if ($resolved && $resolved !== $host) {
287 $ips[] = $resolved;
288 }
289 }
290
291 return $ips;
292 }
293 }
294