PluginProbe
Visualizer – Tables & Charts Manager with Built-in AI Generator / 4.0.8
Visualizer – Tables & Charts Manager with Built-in AI Generator v4.0.8
4.0.8 4.0.7 4.0.6 4.0.5 4.0.4 4.0.3 3.0.5 3.0.6 3.0.7 3.0.8 3.0.9 3.1.0 3.1.1 3.1.2 3.1.3 3.10.0 3.10.1 3.10.10 3.10.11 3.10.12 3.10.13 3.10.14 3.10.15 3.10.2 3.10.3 All 149 releases
visualizer / classes / Visualizer / Remote / Fetch.php

Fetch.php in Visualizer – Tables & Charts Manager with Built-in AI Generator 4.0.8, at classes/Visualizer/Remote/Fetch.php

426 lines 14.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Safe access to user-controlled remote resources.
4 *
5 * @package Visualizer
6 */
7
8 /**
9 * Centralizes the network policy for remote chart imports.
10 */
11 class Visualizer_Remote_Fetch {
12
13 const MAX_DOWNLOAD_BYTES = 10485760;
14 const MAX_REDIRECTS = 5;
15
16 /**
17 * Performs a request after validating every destination.
18 *
19 * @param string $url Remote URL.
20 * @param array<string, mixed> $args Optional WordPress HTTP arguments.
21 * @return array<string, mixed>|WP_Error
22 */
23 public static function request( $url, $args = array() ) {
24 $args = wp_parse_args(
25 $args,
26 array(
27 'method' => 'GET',
28 'timeout' => 15,
29 )
30 );
31
32 $redirects = isset( $args['redirection'] ) ? min( self::MAX_REDIRECTS, max( 0, (int) $args['redirection'] ) ) : self::MAX_REDIRECTS;
33 unset( $args['redirection'] );
34
35 $args = self::enforce_request_policy( $args );
36 if ( is_wp_error( $args ) ) {
37 return $args;
38 }
39
40 for ( $redirect = 0; $redirect <= $redirects; $redirect++ ) {
41 $ips = array();
42 $validated_url = self::validate_url( $url, $ips );
43 if ( is_wp_error( $validated_url ) ) {
44 return $validated_url;
45 }
46
47 $request_args = $args;
48 $request_args['redirection'] = 0;
49 $request_args['reject_unsafe_urls'] = true;
50
51 $pin = self::pin_validated_addresses( $validated_url, $ips );
52 if ( is_wp_error( $pin ) ) {
53 return $pin;
54 }
55 $response = wp_safe_remote_request( $validated_url, $request_args );
56 if ( $pin ) {
57 remove_action( 'http_api_curl', $pin );
58 }
59 if ( is_wp_error( $response ) ) {
60 return $response;
61 }
62
63 $status = (int) wp_remote_retrieve_response_code( $response );
64 $location = wp_remote_retrieve_header( $response, 'location' );
65 if ( $status < 300 || $status > 399 || empty( $location ) ) {
66 return $response;
67 }
68
69 if ( $redirect === $redirects ) {
70 return new WP_Error( 'visualizer_too_many_redirects', 'The remote URL redirected too many times.' );
71 }
72
73 $next_url = WP_Http::make_absolute_url( $location, $validated_url );
74 if ( ! self::same_origin( $validated_url, $next_url ) ) {
75 $args['headers'] = self::headers_for_cross_origin_redirect( isset( $args['headers'] ) ? $args['headers'] : array() );
76 unset( $args['cookies'] );
77 } else {
78 $response_cookies = wp_remote_retrieve_cookies( $response );
79 if ( ! empty( $response_cookies ) ) {
80 $args['cookies'] = array_merge( isset( $args['cookies'] ) ? $args['cookies'] : array(), $response_cookies );
81 }
82 }
83
84 if ( in_array( $status, array( 302, 303 ), true ) ) {
85 $args['method'] = 'GET';
86 unset( $args['body'] );
87 }
88
89 $url = $next_url;
90 }
91
92 return new WP_Error( 'visualizer_remote_request', 'The remote request could not be completed.' );
93 }
94
95 /**
96 * Downloads a remote resource to a temporary file.
97 *
98 * The caller is responsible for deleting the returned file.
99 *
100 * @param string $url Remote URL.
101 * @param array<string, mixed> $args Optional WordPress HTTP arguments.
102 * @return string|WP_Error
103 */
104 public static function download( $url, $args = array() ) {
105 require_once ABSPATH . 'wp-admin/includes/file.php';
106
107 $max_bytes = isset( $args['limit_response_size'] ) ? (int) $args['limit_response_size'] : self::MAX_DOWNLOAD_BYTES;
108 if ( $max_bytes < 1 ) {
109 return new WP_Error( 'visualizer_remote_size', 'The remote file size limit is invalid.' );
110 }
111
112 $tmpfile = wp_tempnam( (string) wp_parse_url( $url, PHP_URL_PATH ) );
113 if ( ! $tmpfile ) {
114 return new WP_Error( 'visualizer_temp_file', 'Could not create a temporary file.' );
115 }
116
117 $args['stream'] = true;
118 $args['filename'] = $tmpfile;
119 $args['limit_response_size'] = $max_bytes < PHP_INT_MAX ? $max_bytes + 1 : $max_bytes;
120 $response = self::request( $url, $args );
121
122 if ( is_wp_error( $response ) ) {
123 wp_delete_file( $tmpfile );
124 return $response;
125 }
126
127 if ( 200 !== (int) wp_remote_retrieve_response_code( $response ) ) {
128 wp_delete_file( $tmpfile );
129 return new WP_Error( 'visualizer_remote_status', 'The remote server returned an unexpected response.' );
130 }
131
132 // Download one extra byte so an exactly-at-limit file remains valid.
133 clearstatcache( true, $tmpfile );
134 if ( filesize( $tmpfile ) > $max_bytes ) {
135 wp_delete_file( $tmpfile );
136 return new WP_Error( 'visualizer_remote_size', 'The remote file is too large to import.' );
137 }
138
139 return $tmpfile;
140 }
141
142 /**
143 * Applies method and header restrictions.
144 *
145 * @param array<string, mixed> $args HTTP arguments.
146 * @return array<string, mixed>|WP_Error
147 */
148 private static function enforce_request_policy( $args ) {
149 $args['method'] = strtoupper( (string) $args['method'] );
150 if ( ! preg_match( '/^[!#$%&\'*+\-.^_`|~0-9A-Z]+$/', $args['method'] ) || in_array( $args['method'], array( 'CONNECT', 'TRACE' ), true ) ) {
151 return new WP_Error( 'visualizer_remote_method', 'The remote request method is not allowed.' );
152 }
153
154 if ( isset( $args['headers'] ) && is_string( $args['headers'] ) ) {
155 $headers = array();
156 foreach ( preg_split( '/\r\n|\r|\n/', $args['headers'] ) as $header ) {
157 if ( false === strpos( $header, ':' ) ) {
158 continue;
159 }
160 list( $name, $value ) = explode( ':', $header, 2 );
161 $headers[ strtolower( trim( $name ) ) ] = trim( $value );
162 }
163 $args['headers'] = $headers;
164 } elseif ( ! isset( $args['headers'] ) || ! is_array( $args['headers'] ) ) {
165 $args['headers'] = array();
166 }
167
168 $blocked_headers = array( 'connection', 'content-length', 'host', 'proxy-authorization', 'proxy-connection', 'te', 'trailer', 'transfer-encoding', 'upgrade' );
169 foreach ( $args['headers'] as $name => $value ) {
170 if ( in_array( strtolower( (string) $name ), $blocked_headers, true ) ) {
171 unset( $args['headers'][ $name ] );
172 }
173 }
174
175 $args['timeout'] = min( 30, max( 1, (int) $args['timeout'] ) );
176
177 return $args;
178 }
179
180 /**
181 * Binds the cURL transport to the addresses that passed validation.
182 *
183 * Without this the transport re-resolves the host on connect, letting a
184 * rebinding nameserver answer with a private address after validation
185 * passed. Hostname requests fail closed when cURL pinning is unavailable.
186 *
187 * @param string $url Validated URL.
188 * @param string[] $ips Validated addresses.
189 * @param string|null $curl_version Optional cURL version override.
190 * @return callable|WP_Error|null The registered hook to remove after dispatch, an error when pinning is unavailable, or null for an IP literal or exempt host.
191 */
192 private static function pin_validated_addresses( $url, $ips, $curl_version = null ) {
193 if ( empty( $ips ) ) {
194 return null;
195 }
196
197 $parsed = wp_parse_url( $url );
198 if ( filter_var( rtrim( $parsed['host'], '.' ), FILTER_VALIDATE_IP ) ) {
199 return null;
200 }
201
202 if ( ! function_exists( 'curl_init' ) || ! function_exists( 'curl_exec' ) || ! defined( 'CURLOPT_RESOLVE' ) ) {
203 return new WP_Error( 'visualizer_remote_transport', 'The remote host cannot be fetched securely on this server.' );
204 }
205
206 $curl = curl_version();
207 if ( 'https' === strtolower( $parsed['scheme'] ) ) {
208 if ( empty( $curl['features'] ) || ! defined( 'CURL_VERSION_SSL' ) || ! ( $curl['features'] & CURL_VERSION_SSL ) ) {
209 return new WP_Error( 'visualizer_remote_transport', 'The remote host cannot be fetched securely on this server.' );
210 }
211 }
212 if ( null === $curl_version ) {
213 $curl_version = isset( $curl['version'] ) ? $curl['version'] : '0.0.0';
214 }
215
216 $proxy = new WP_HTTP_Proxy();
217 if ( $proxy->is_enabled() && $proxy->send_through_proxy( $url ) ) {
218 return new WP_Error( 'visualizer_remote_transport', 'The remote host cannot be fetched securely through the configured proxy.' );
219 }
220
221 if ( version_compare( $curl_version, '7.59.0', '<' ) ) {
222 foreach ( $ips as $ip ) {
223 if ( filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 ) ) {
224 $ips = array( $ip );
225 break;
226 }
227 }
228 $ips = array( reset( $ips ) );
229 }
230
231 if ( version_compare( $curl_version, '7.57.0', '<' ) && filter_var( reset( $ips ), FILTER_VALIDATE_IP, FILTER_FLAG_IPV6 ) ) {
232 return new WP_Error( 'visualizer_remote_transport', 'The remote host cannot be fetched securely on this server.' );
233 }
234
235 $addresses = array();
236 foreach ( $ips as $ip ) {
237 $addresses[] = filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6 ) ? '[' . $ip . ']' : $ip;
238 }
239
240 $entry = sprintf( '%s:%d:%s', strtolower( rtrim( $parsed['host'], '.' ) ), self::url_port( $parsed ), implode( ',', $addresses ) );
241 $pin = function ( $handle ) use ( $entry ) {
242 curl_setopt( $handle, CURLOPT_RESOLVE, array( $entry ) );
243 };
244 add_action( 'http_api_curl', $pin );
245
246 return $pin;
247 }
248
249 /**
250 * Validates URL syntax and every address returned by DNS.
251 *
252 * @param string $url Remote URL.
253 * @param string[] $ips Filled with the validated addresses; stays empty when the host is exempt from the check.
254 * @return string|WP_Error
255 */
256 private static function validate_url( $url, &$ips = array() ) {
257 $ips = array();
258 $validated_url = wp_http_validate_url( $url );
259 if ( false === $validated_url ) {
260 // WordPress 7.1+ rejects non-public IP literals inside wp_http_validate_url()
261 // itself; older cores let them through to our is_global_ip() check below. Keep
262 // the distinct "unsafe destination" error on every core version so callers can
263 // tell a policy block from a malformed URL.
264 $scheme = strtolower( (string) wp_parse_url( $url, PHP_URL_SCHEME ) );
265 $host = (string) wp_parse_url( $url, PHP_URL_HOST );
266 if ( in_array( $scheme, array( 'http', 'https' ), true ) && filter_var( $host, FILTER_VALIDATE_IP ) && ! self::is_global_ip( $host ) ) {
267 return new WP_Error( 'visualizer_unsafe_remote_url', 'The remote URL resolves to a non-public address.' );
268 }
269 return new WP_Error( 'visualizer_invalid_remote_url', 'The remote URL is not allowed.' );
270 }
271
272 $host = strtolower( rtrim( (string) wp_parse_url( $validated_url, PHP_URL_HOST ), '.' ) );
273
274 // Mirror core's same-host exemption so media library URLs import on hosts that resolve internally.
275 $home_host = strtolower( rtrim( (string) wp_parse_url( get_option( 'home' ), PHP_URL_HOST ), '.' ) );
276 if ( $host === $home_host ) {
277 return $validated_url;
278 }
279
280 $ips = self::resolve_host( $host );
281 if ( empty( $ips ) ) {
282 return new WP_Error( 'visualizer_remote_dns', 'The remote host could not be resolved.' );
283 }
284
285 foreach ( $ips as $ip ) {
286 if ( ! self::is_global_ip( $ip ) ) {
287 return new WP_Error( 'visualizer_unsafe_remote_url', 'The remote URL resolves to a non-public address.' );
288 }
289 }
290
291 return $validated_url;
292 }
293
294 /**
295 * Resolves every IPv4 and IPv6 address for a host.
296 *
297 * @param string $host Host name or IP literal.
298 * @return string[]
299 */
300 private static function resolve_host( $host ) {
301 if ( filter_var( $host, FILTER_VALIDATE_IP ) ) {
302 return array( $host );
303 }
304
305 $ips = array();
306 if ( function_exists( 'dns_get_record' ) ) {
307 // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- DNS failures are handled below.
308 $records = @dns_get_record( $host, DNS_A | DNS_AAAA );
309 foreach ( is_array( $records ) ? $records : array() as $record ) {
310 if ( ! empty( $record['ip'] ) ) {
311 $ips[] = $record['ip'];
312 } elseif ( ! empty( $record['ipv6'] ) ) {
313 $ips[] = $record['ipv6'];
314 }
315 }
316 }
317
318 if ( empty( $ips ) ) {
319 // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- DNS failures are handled by returning no addresses.
320 $ipv4 = @gethostbynamel( $host );
321 $ips = is_array( $ipv4 ) ? $ipv4 : array();
322 }
323
324 return array_values( array_unique( $ips ) );
325 }
326
327 /**
328 * Whether an address is globally routable.
329 *
330 * @param string $ip IP address.
331 * @return bool
332 */
333 private static function is_global_ip( $ip ) {
334 if ( self::ip_in_range( $ip, '::ffff:0:0/96' ) ) {
335 $packed = inet_pton( $ip );
336 $ip = inet_ntop( substr( $packed, 12 ) );
337 }
338
339 if ( false === filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ) ) {
340 return false;
341 }
342
343 $ranges = false !== strpos( $ip, ':' )
344 ? array( 'fc00::/7', 'fe80::/10', 'ff00::/8' )
345 : array( '100.64.0.0/10', '192.0.0.0/24', '192.0.2.0/24', '198.18.0.0/15', '198.51.100.0/24', '203.0.113.0/24', '224.0.0.0/4' );
346
347 foreach ( $ranges as $range ) {
348 if ( self::ip_in_range( $ip, $range ) ) {
349 return false;
350 }
351 }
352
353 return true;
354 }
355
356 /**
357 * Checks whether an IP belongs to a CIDR range.
358 *
359 * @param string $ip IP address.
360 * @param string $cidr CIDR range.
361 * @return bool
362 */
363 private static function ip_in_range( $ip, $cidr ) {
364 list( $network, $prefix ) = explode( '/', $cidr, 2 );
365 $address = inet_pton( $ip );
366 $network = inet_pton( $network );
367 if ( false === $address || false === $network || strlen( $address ) !== strlen( $network ) ) {
368 return false;
369 }
370
371 $bytes = intdiv( (int) $prefix, 8 );
372 $bits = (int) $prefix % 8;
373 if ( substr( $address, 0, $bytes ) !== substr( $network, 0, $bytes ) ) {
374 return false;
375 }
376
377 return 0 === $bits || ( ord( $address[ $bytes ] ) & ( 0xff << ( 8 - $bits ) ) ) === ( ord( $network[ $bytes ] ) & ( 0xff << ( 8 - $bits ) ) );
378 }
379
380 /**
381 * Whether two URLs share scheme, host, and port.
382 *
383 * @param string $first First URL.
384 * @param string $second Second URL.
385 * @return bool
386 */
387 private static function same_origin( $first, $second ) {
388 $first = wp_parse_url( $first );
389 $second = wp_parse_url( $second );
390 if ( ! is_array( $first ) || ! is_array( $second ) || empty( $first['scheme'] ) || empty( $first['host'] ) || empty( $second['scheme'] ) || empty( $second['host'] ) ) {
391 return false;
392 }
393
394 return strtolower( $first['scheme'] ) === strtolower( $second['scheme'] )
395 && strtolower( $first['host'] ) === strtolower( $second['host'] )
396 && self::url_port( $first ) === self::url_port( $second );
397 }
398
399 /**
400 * Gets an explicit or scheme-default URL port.
401 *
402 * @param array<string, mixed> $url Parsed URL.
403 * @return int
404 */
405 private static function url_port( $url ) {
406 return isset( $url['port'] ) ? (int) $url['port'] : ( 'https' === strtolower( $url['scheme'] ) ? 443 : 80 );
407 }
408
409 /**
410 * Retains only non-sensitive headers across an origin change.
411 *
412 * @param array<string, mixed> $headers Request headers.
413 * @return array<string, mixed>
414 */
415 private static function headers_for_cross_origin_redirect( $headers ) {
416 $allowed = array( 'accept', 'accept-encoding', 'range', 'user-agent' );
417 return array_filter(
418 $headers,
419 function ( $value, $name ) use ( $allowed ) {
420 return in_array( strtolower( (string) $name ), $allowed, true );
421 },
422 ARRAY_FILTER_USE_BOTH
423 );
424 }
425 }
426