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

290 lines 10.0 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 if (!filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) {
64 return false;
65 }
66
67 foreach (self::EXTRA_RESERVED_BLOCKS as $cidr) {
68 if (self::ipv4_in_cidr($ip, $cidr)) {
69 return false;
70 }
71 }
72
73 // An IPv6 address that embeds an IPv4 target (IPv4-mapped ::ffff:a.b.c.d,
74 // deprecated IPv4-compatible ::a.b.c.d, or NAT64 64:ff9b::a.b.c.d) routes
75 // to that IPv4 address, but PHP's range filters above judge only the IPv6
76 // form and let it through. Re-run the full check on the embedded IPv4 so
77 // e.g. ::ffff:169.254.169.254 is blocked exactly like 169.254.169.254.
78 $embedded = self::embedded_ipv4($ip);
79 if (null !== $embedded && !self::is_public_ip($embedded)) {
80 return false;
81 }
82
83 return true;
84 }
85
86 /**
87 * Extract the embedded IPv4 target from an IPv4-in-IPv6 address.
88 *
89 * Covers the three forms that actually carry a routable IPv4 destination:
90 * IPv4-mapped (::ffff:0:0/96), deprecated IPv4-compatible (::/96, excluding
91 * the :: and ::1 specials), and NAT64 (64:ff9b::/96).
92 *
93 * @param string $ip IP address (v4 or v6).
94 * @return string|null Dotted-quad IPv4 when one is embedded, otherwise null.
95 */
96 private static function embedded_ipv4(string $ip): ?string {
97 if (strpos($ip, ':') === false) {
98 return null; // Plain IPv4.
99 }
100
101 // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- inet_pton() warns on a malformed literal, which is a normal reject-path here, not an error.
102 $packed = @inet_pton($ip);
103 if (false === $packed || strlen($packed) !== 16) {
104 return null;
105 }
106
107 $prefix = substr($packed, 0, 12);
108 $mapped = "\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff"; // ::ffff:0:0/96
109 $nat64 = "\x00\x64\xff\x9b\x00\x00\x00\x00\x00\x00\x00\x00"; // 64:ff9b::/96
110 $compat = "\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00"; // ::/96 (IPv4-compatible)
111
112 // A 4-byte packed string renders as a dotted-quad IPv4 via inet_ntop.
113 if ($prefix === $mapped || $prefix === $nat64) {
114 return (string) inet_ntop(substr($packed, 12));
115 }
116
117 if ($prefix === $compat) {
118 $v4 = substr($packed, 12);
119 // Skip :: (unspecified) and ::1 (loopback); the IPv6 range filter
120 // already rejects those, and they carry no meaningful IPv4 target.
121 if ($v4 === "\x00\x00\x00\x00" || $v4 === "\x00\x00\x00\x01") {
122 return null;
123 }
124 return (string) inet_ntop($v4);
125 }
126
127 return null;
128 }
129
130 /**
131 * Validate that a URL is an http(s) URL whose host resolves only to public
132 * addresses.
133 *
134 * @since 1.29.0
135 *
136 * @param string $url URL to validate.
137 * @return true|WP_Error True when safe to fetch, WP_Error otherwise.
138 */
139 public static function validate_public_url(string $url) {
140 $parts = wp_parse_url($url);
141
142 if (empty($parts['scheme']) || empty($parts['host'])) {
143 return new WP_Error('invalid_url', 'The URL is not allowed.', ['status' => 400]);
144 }
145
146 if (!in_array(strtolower($parts['scheme']), ['http', 'https'], true)) {
147 return new WP_Error('invalid_url', 'Only http and https URLs can be fetched.', ['status' => 400]);
148 }
149
150 // Strip IPv6 literal brackets, if present.
151 $host = trim($parts['host'], '[]');
152
153 $ips = filter_var($host, FILTER_VALIDATE_IP) ? [$host] : self::resolve_host_ips($host);
154 if (empty($ips)) {
155 return new WP_Error('invalid_url', 'The URL host could not be resolved.', ['status' => 400]);
156 }
157
158 foreach ($ips as $ip) {
159 if (!self::is_public_ip($ip)) {
160 return new WP_Error('invalid_url', 'The URL is not allowed.', ['status' => 400]);
161 }
162 }
163
164 return true;
165 }
166
167 /**
168 * Whether a URL is safe to fetch.
169 *
170 * Boolean convenience wrapper around {@see self::validate_public_url()} for
171 * call sites that only branch on safe/unsafe.
172 *
173 * @since 1.29.0
174 *
175 * @param string $url URL to check.
176 * @return bool True when safe to fetch.
177 */
178 public static function is_safe_public_url(string $url): bool {
179 return true === self::validate_public_url($url);
180 }
181
182 /**
183 * Fetch a URL, re-validating the host against the block list on every
184 * redirect hop.
185 *
186 * wp_safe_remote_get() re-validates redirect targets with
187 * wp_http_validate_url(), which shares the link-local/CGNAT blind spot, so
188 * redirects are followed manually (`redirection => 0`) and each hop is
189 * checked before it is requested. That also closes the DNS-rebinding window
190 * a single pre-flight check would leave open across hops.
191 *
192 * @since 1.29.0
193 *
194 * @param string $url URL to fetch.
195 * @param array $args Optional. wp_safe_remote_get() arguments.
196 * @return array|WP_Error Response array on success, WP_Error otherwise.
197 */
198 public static function safe_remote_get(string $url, array $args = []) {
199 for ($hop = 0; $hop <= self::MAX_REDIRECTS; $hop++) {
200 if (!wp_http_validate_url($url)) {
201 return new WP_Error('invalid_url', 'The URL is not allowed.', ['status' => 400]);
202 }
203
204 $host_check = self::validate_public_url($url);
205 if (is_wp_error($host_check)) {
206 return $host_check;
207 }
208
209 $response = wp_safe_remote_get($url, array_merge($args, ['redirection' => 0]));
210
211 if (is_wp_error($response)) {
212 return $response;
213 }
214
215 $code = (int) wp_remote_retrieve_response_code($response);
216 if ($code < 300 || $code >= 400) {
217 return $response;
218 }
219
220 $location = trim((string) wp_remote_retrieve_header($response, 'location'));
221 if ('' === $location) {
222 return $response; // Redirect without a target — treat as final.
223 }
224
225 // Resolve a relative Location against the current URL.
226 $url = (string) WP_Http::make_absolute_url($location, $url);
227 if ('' === $url) {
228 return new WP_Error('invalid_url', 'The URL is not allowed.', ['status' => 400]);
229 }
230 }
231
232 return new WP_Error('too_many_redirects', 'The URL redirected too many times.', ['status' => 400]);
233 }
234
235 /**
236 * Whether an IPv4 address falls within a CIDR block.
237 *
238 * @param string $ip IPv4 address.
239 * @param string $cidr CIDR block (e.g. 100.64.0.0/10).
240 * @return bool
241 */
242 private static function ipv4_in_cidr(string $ip, string $cidr): bool {
243 if (strpos($ip, ':') !== false) {
244 return false; // IPv6 is not covered by these IPv4 blocks.
245 }
246
247 [$subnet, $bits] = array_pad(explode('/', $cidr, 2), 2, '32');
248 $ip_long = ip2long($ip);
249 $subnet_long = ip2long($subnet);
250 if (false === $ip_long || false === $subnet_long) {
251 return false;
252 }
253
254 $mask = -1 << (32 - (int) $bits);
255 return ($ip_long & $mask) === ($subnet_long & $mask);
256 }
257
258 /**
259 * Resolve a hostname to its IPv4 + IPv6 addresses.
260 *
261 * @param string $host Hostname.
262 * @return string[] Resolved IP addresses (may be empty).
263 */
264 private static function resolve_host_ips(string $host): array {
265 $ips = [];
266
267 // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- dns_get_record() emits a warning on NXDOMAIN, which is a normal answer here, not an error.
268 $records = @dns_get_record($host, DNS_A + DNS_AAAA);
269 if (is_array($records)) {
270 foreach ($records as $record) {
271 if (!empty($record['ip'])) {
272 $ips[] = $record['ip']; // A record.
273 } elseif (!empty($record['ipv6'])) {
274 $ips[] = $record['ipv6']; // AAAA record.
275 }
276 }
277 }
278
279 // Fallback where dns_get_record is unavailable or returns nothing.
280 if (empty($ips)) {
281 $resolved = gethostbyname($host);
282 if ($resolved && $resolved !== $host) {
283 $ips[] = $resolved;
284 }
285 }
286
287 return $ips;
288 }
289 }
290