PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.10.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.10.0
2.10.0 2.9.0 2.8.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 All 51 releases
← All changes | includes/core/class-url-safety.php +101 -17 1.29.0 → 2.10.0 View file →
@@ -59,8 +59,22 @@
59 59 * @param string $ip IP address (v4 or v6).
60 60 * @return bool True when the address is public.
61 61 */
62 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 +
63 77 if (!filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) {
64 78 return false;
65 79 }
66 80
@@ -69,18 +83,8 @@
69 83 return false;
70 84 }
71 85 }
72 86
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 87 return true;
84 88 }
85 89
86 90 /**
@@ -136,8 +140,28 @@
136 140 * @param string $url URL to validate.
137 141 * @return true|WP_Error True when safe to fetch, WP_Error otherwise.
138 142 */
139 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) {
140 164 $parts = wp_parse_url($url);
141 165
142 166 if (empty($parts['scheme']) || empty($parts['host'])) {
143 167 return new WP_Error('invalid_url', 'The URL is not allowed.', ['status' => 400]);
@@ -160,12 +184,67 @@
160 184 return new WP_Error('invalid_url', 'The URL is not allowed.', ['status' => 400]);
161 185 }
162 186 }
163 187
164 - return true;
188 + return $ips;
165 189 }
166 190
167 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 + /**
168 247 * Whether a URL is safe to fetch.
169 248 *
170 249 * Boolean convenience wrapper around {@see self::validate_public_url()} for
171 250 * call sites that only branch on safe/unsafe.
@@ -185,11 +264,16 @@
185 264 *
186 265 * wp_safe_remote_get() re-validates redirect targets with
187 266 * wp_http_validate_url(), which shares the link-local/CGNAT blind spot, so
188 267 * 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.
268 + * checked before it is requested.
191 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 + *
192 276 * @since 1.29.0
193 277 *
194 278 * @param string $url URL to fetch.
195 279 * @param array $args Optional. wp_safe_remote_get() arguments.
@@ -200,14 +284,14 @@
200 284 if (!wp_http_validate_url($url)) {
201 285 return new WP_Error('invalid_url', 'The URL is not allowed.', ['status' => 400]);
202 286 }
203 287
204 - $host_check = self::validate_public_url($url);
205 - if (is_wp_error($host_check)) {
206 - return $host_check;
288 + $ips = self::validated_ips($url);
289 + if (is_wp_error($ips)) {
290 + return $ips;
207 291 }
208 292
209 - $response = wp_safe_remote_get($url, array_merge($args, ['redirection' => 0]));
293 + $response = self::request_pinned($url, array_merge($args, ['redirection' => 0]), $ips);
210 294
211 295 if (is_wp_error($response)) {
212 296 return $response;
213 297 }