PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.7.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.7.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 1.0.2 1.1.0 All 49 releases
← All changes | includes/core/class-url-safety.php +87 -7 2.0.02.7.0 View file →
@@ -140,8 +140,28 @@
140 140 * @param string $url URL to validate.
141 141 * @return true|WP_Error True when safe to fetch, WP_Error otherwise.
142 142 */
143 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) {
144 164 $parts = wp_parse_url($url);
145 165
146 166 if (empty($parts['scheme']) || empty($parts['host'])) {
147 167 return new WP_Error('invalid_url', 'The URL is not allowed.', ['status' => 400]);
@@ -164,12 +184,67 @@
164 184 return new WP_Error('invalid_url', 'The URL is not allowed.', ['status' => 400]);
165 185 }
166 186 }
167 187
168 - return true;
188 + return $ips;
169 189 }
170 190
171 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 + /**
172 247 * Whether a URL is safe to fetch.
173 248 *
174 249 * Boolean convenience wrapper around {@see self::validate_public_url()} for
175 250 * call sites that only branch on safe/unsafe.
@@ -189,11 +264,16 @@
189 264 *
190 265 * wp_safe_remote_get() re-validates redirect targets with
191 266 * wp_http_validate_url(), which shares the link-local/CGNAT blind spot, so
192 267 * 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.
268 + * checked before it is requested.
195 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 + *
196 276 * @since 1.29.0
197 277 *
198 278 * @param string $url URL to fetch.
199 279 * @param array $args Optional. wp_safe_remote_get() arguments.
@@ -204,14 +284,14 @@
204 284 if (!wp_http_validate_url($url)) {
205 285 return new WP_Error('invalid_url', 'The URL is not allowed.', ['status' => 400]);
206 286 }
207 287
208 - $host_check = self::validate_public_url($url);
209 - if (is_wp_error($host_check)) {
210 - return $host_check;
288 + $ips = self::validated_ips($url);
289 + if (is_wp_error($ips)) {
290 + return $ips;
211 291 }
212 292
213 - $response = wp_safe_remote_get($url, array_merge($args, ['redirection' => 0]));
293 + $response = self::request_pinned($url, array_merge($args, ['redirection' => 0]), $ips);
214 294
215 295 if (is_wp_error($response)) {
216 296 return $response;
217 297 }