PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / trunk
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO vtrunk
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 1.11.0 All 47 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 trunk, at includes/core/class-url-safety.php

374 lines 13.6 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 $ips = self::validated_ips($url);
145
146 return is_wp_error($ips) ? $ips : true;
147 }
148
149 /**
150 * The addresses a URL's host resolves to, once every one has been checked
151 * against the block list.
152 *
153 * Returned rather than discarded so the fetch can be pinned to them:
154 * handing the *hostname* to the HTTP transport lets it resolve a second
155 * time, and a host answering a public address on this lookup and a private
156 * one on the fetch walks straight past the block list (#405).
157 *
158 * @since 2.0.1
159 *
160 * @param string $url URL to validate.
161 * @return string[]|WP_Error Validated IPs, or the reason the URL is refused.
162 */
163 private static function validated_ips(string $url) {
164 $parts = wp_parse_url($url);
165
166 if (empty($parts['scheme']) || empty($parts['host'])) {
167 return new WP_Error('invalid_url', 'The URL is not allowed.', ['status' => 400]);
168 }
169
170 if (!in_array(strtolower($parts['scheme']), ['http', 'https'], true)) {
171 return new WP_Error('invalid_url', 'Only http and https URLs can be fetched.', ['status' => 400]);
172 }
173
174 // Strip IPv6 literal brackets, if present.
175 $host = trim($parts['host'], '[]');
176
177 $ips = filter_var($host, FILTER_VALIDATE_IP) ? [$host] : self::resolve_host_ips($host);
178 if (empty($ips)) {
179 return new WP_Error('invalid_url', 'The URL host could not be resolved.', ['status' => 400]);
180 }
181
182 foreach ($ips as $ip) {
183 if (!self::is_public_ip($ip)) {
184 return new WP_Error('invalid_url', 'The URL is not allowed.', ['status' => 400]);
185 }
186 }
187
188 return $ips;
189 }
190
191 /**
192 * Perform the request against the addresses validate_public_url() approved.
193 *
194 * CURLOPT_RESOLVE pre-seeds cURL's name cache, so the connection goes to a
195 * checked address while the hostname — and therefore SNI and certificate
196 * validation — stays intact. Without it the transport performs its own
197 * lookup and a 0-TTL record can answer differently the second time.
198 *
199 * The pin only applies to the cURL transport. On a site whose HTTP requests
200 * go through the PHP streams fallback the request still runs, with the
201 * pre-flight check alone — the behaviour before this change — rather than
202 * failing closed on an install that simply lacks cURL.
203 *
204 * @since 2.0.1
205 *
206 * @param string $url URL to fetch.
207 * @param array $args wp_safe_remote_get() arguments.
208 * @param string[] $ips Validated addresses for the URL's host.
209 * @return array|WP_Error Response array on success, WP_Error otherwise.
210 */
211 private static function request_pinned(string $url, array $args, array $ips) {
212 $parts = wp_parse_url($url);
213 $host = trim((string) ($parts['host'] ?? ''), '[]');
214
215 if ('' === $host || empty($ips)) {
216 return wp_safe_remote_get($url, $args);
217 }
218
219 $port = isset($parts['port'])
220 ? (int) $parts['port']
221 : ('https' === strtolower((string) ($parts['scheme'] ?? '')) ? 443 : 80);
222
223 // One entry per host:port, listing every validated address — pinning a
224 // single one would turn a multi-A-record host into a single point of
225 // failure, and they have all passed the same check.
226 $resolve = sprintf('%s:%d:%s', $host, $port, implode(',', $ips));
227
228 $pin = static function ($handle) use ($resolve): void {
229 if (!defined('CURLOPT_RESOLVE')) {
230 return;
231 }
232
233 // 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.
234 curl_setopt($handle, CURLOPT_RESOLVE, [$resolve]);
235 };
236
237 add_action('http_api_curl', $pin, 10, 1);
238
239 try {
240 return wp_safe_remote_get($url, $args);
241 } finally {
242 remove_action('http_api_curl', $pin, 10);
243 }
244 }
245
246 /**
247 * Whether a URL is safe to fetch.
248 *
249 * Boolean convenience wrapper around {@see self::validate_public_url()} for
250 * call sites that only branch on safe/unsafe.
251 *
252 * @since 1.29.0
253 *
254 * @param string $url URL to check.
255 * @return bool True when safe to fetch.
256 */
257 public static function is_safe_public_url(string $url): bool {
258 return true === self::validate_public_url($url);
259 }
260
261 /**
262 * Fetch a URL, re-validating the host against the block list on every
263 * redirect hop.
264 *
265 * wp_safe_remote_get() re-validates redirect targets with
266 * wp_http_validate_url(), which shares the link-local/CGNAT blind spot, so
267 * redirects are followed manually (`redirection => 0`) and each hop is
268 * checked before it is requested.
269 *
270 * Each hop is then *pinned* to the addresses its check approved, via
271 * CURLOPT_RESOLVE. Per-hop revalidation alone closes the rebinding window
272 * across hops but not the one inside a single hop, between resolving the
273 * host and connecting to it — the transport resolved the name a second
274 * time, and a 0-TTL record could answer differently (#405).
275 *
276 * @since 1.29.0
277 *
278 * @param string $url URL to fetch.
279 * @param array $args Optional. wp_safe_remote_get() arguments.
280 * @return array|WP_Error Response array on success, WP_Error otherwise.
281 */
282 public static function safe_remote_get(string $url, array $args = []) {
283 for ($hop = 0; $hop <= self::MAX_REDIRECTS; $hop++) {
284 if (!wp_http_validate_url($url)) {
285 return new WP_Error('invalid_url', 'The URL is not allowed.', ['status' => 400]);
286 }
287
288 $ips = self::validated_ips($url);
289 if (is_wp_error($ips)) {
290 return $ips;
291 }
292
293 $response = self::request_pinned($url, array_merge($args, ['redirection' => 0]), $ips);
294
295 if (is_wp_error($response)) {
296 return $response;
297 }
298
299 $code = (int) wp_remote_retrieve_response_code($response);
300 if ($code < 300 || $code >= 400) {
301 return $response;
302 }
303
304 $location = trim((string) wp_remote_retrieve_header($response, 'location'));
305 if ('' === $location) {
306 return $response; // Redirect without a target — treat as final.
307 }
308
309 // Resolve a relative Location against the current URL.
310 $url = (string) WP_Http::make_absolute_url($location, $url);
311 if ('' === $url) {
312 return new WP_Error('invalid_url', 'The URL is not allowed.', ['status' => 400]);
313 }
314 }
315
316 return new WP_Error('too_many_redirects', 'The URL redirected too many times.', ['status' => 400]);
317 }
318
319 /**
320 * Whether an IPv4 address falls within a CIDR block.
321 *
322 * @param string $ip IPv4 address.
323 * @param string $cidr CIDR block (e.g. 100.64.0.0/10).
324 * @return bool
325 */
326 private static function ipv4_in_cidr(string $ip, string $cidr): bool {
327 if (strpos($ip, ':') !== false) {
328 return false; // IPv6 is not covered by these IPv4 blocks.
329 }
330
331 [$subnet, $bits] = array_pad(explode('/', $cidr, 2), 2, '32');
332 $ip_long = ip2long($ip);
333 $subnet_long = ip2long($subnet);
334 if (false === $ip_long || false === $subnet_long) {
335 return false;
336 }
337
338 $mask = -1 << (32 - (int) $bits);
339 return ($ip_long & $mask) === ($subnet_long & $mask);
340 }
341
342 /**
343 * Resolve a hostname to its IPv4 + IPv6 addresses.
344 *
345 * @param string $host Hostname.
346 * @return string[] Resolved IP addresses (may be empty).
347 */
348 private static function resolve_host_ips(string $host): array {
349 $ips = [];
350
351 // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- dns_get_record() emits a warning on NXDOMAIN, which is a normal answer here, not an error.
352 $records = @dns_get_record($host, DNS_A + DNS_AAAA);
353 if (is_array($records)) {
354 foreach ($records as $record) {
355 if (!empty($record['ip'])) {
356 $ips[] = $record['ip']; // A record.
357 } elseif (!empty($record['ipv6'])) {
358 $ips[] = $record['ipv6']; // AAAA record.
359 }
360 }
361 }
362
363 // Fallback where dns_get_record is unavailable or returns nothing.
364 if (empty($ips)) {
365 $resolved = gethostbyname($host);
366 if ($resolved && $resolved !== $host) {
367 $ips[] = $resolved;
368 }
369 }
370
371 return $ips;
372 }
373 }
374