PluginProbe
Visualizer – Tables & Charts Manager with Built-in AI Generator / 4.0.7
Visualizer – Tables & Charts Manager with Built-in AI Generator v4.0.7
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 3.10.4 All 148 releases
visualizer / classes / Visualizer / Remote / Fetch.php

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

417 lines 13.7 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 return new WP_Error( 'visualizer_invalid_remote_url', 'The remote URL is not allowed.' );
261 }
262
263 $host = strtolower( rtrim( (string) wp_parse_url( $validated_url, PHP_URL_HOST ), '.' ) );
264
265 // Mirror core's same-host exemption so media library URLs import on hosts that resolve internally.
266 $home_host = strtolower( rtrim( (string) wp_parse_url( get_option( 'home' ), PHP_URL_HOST ), '.' ) );
267 if ( $host === $home_host ) {
268 return $validated_url;
269 }
270
271 $ips = self::resolve_host( $host );
272 if ( empty( $ips ) ) {
273 return new WP_Error( 'visualizer_remote_dns', 'The remote host could not be resolved.' );
274 }
275
276 foreach ( $ips as $ip ) {
277 if ( ! self::is_global_ip( $ip ) ) {
278 return new WP_Error( 'visualizer_unsafe_remote_url', 'The remote URL resolves to a non-public address.' );
279 }
280 }
281
282 return $validated_url;
283 }
284
285 /**
286 * Resolves every IPv4 and IPv6 address for a host.
287 *
288 * @param string $host Host name or IP literal.
289 * @return string[]
290 */
291 private static function resolve_host( $host ) {
292 if ( filter_var( $host, FILTER_VALIDATE_IP ) ) {
293 return array( $host );
294 }
295
296 $ips = array();
297 if ( function_exists( 'dns_get_record' ) ) {
298 // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- DNS failures are handled below.
299 $records = @dns_get_record( $host, DNS_A | DNS_AAAA );
300 foreach ( is_array( $records ) ? $records : array() as $record ) {
301 if ( ! empty( $record['ip'] ) ) {
302 $ips[] = $record['ip'];
303 } elseif ( ! empty( $record['ipv6'] ) ) {
304 $ips[] = $record['ipv6'];
305 }
306 }
307 }
308
309 if ( empty( $ips ) ) {
310 // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- DNS failures are handled by returning no addresses.
311 $ipv4 = @gethostbynamel( $host );
312 $ips = is_array( $ipv4 ) ? $ipv4 : array();
313 }
314
315 return array_values( array_unique( $ips ) );
316 }
317
318 /**
319 * Whether an address is globally routable.
320 *
321 * @param string $ip IP address.
322 * @return bool
323 */
324 private static function is_global_ip( $ip ) {
325 if ( self::ip_in_range( $ip, '::ffff:0:0/96' ) ) {
326 $packed = inet_pton( $ip );
327 $ip = inet_ntop( substr( $packed, 12 ) );
328 }
329
330 if ( false === filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ) ) {
331 return false;
332 }
333
334 $ranges = false !== strpos( $ip, ':' )
335 ? array( 'fc00::/7', 'fe80::/10', 'ff00::/8' )
336 : 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' );
337
338 foreach ( $ranges as $range ) {
339 if ( self::ip_in_range( $ip, $range ) ) {
340 return false;
341 }
342 }
343
344 return true;
345 }
346
347 /**
348 * Checks whether an IP belongs to a CIDR range.
349 *
350 * @param string $ip IP address.
351 * @param string $cidr CIDR range.
352 * @return bool
353 */
354 private static function ip_in_range( $ip, $cidr ) {
355 list( $network, $prefix ) = explode( '/', $cidr, 2 );
356 $address = inet_pton( $ip );
357 $network = inet_pton( $network );
358 if ( false === $address || false === $network || strlen( $address ) !== strlen( $network ) ) {
359 return false;
360 }
361
362 $bytes = intdiv( (int) $prefix, 8 );
363 $bits = (int) $prefix % 8;
364 if ( substr( $address, 0, $bytes ) !== substr( $network, 0, $bytes ) ) {
365 return false;
366 }
367
368 return 0 === $bits || ( ord( $address[ $bytes ] ) & ( 0xff << ( 8 - $bits ) ) ) === ( ord( $network[ $bytes ] ) & ( 0xff << ( 8 - $bits ) ) );
369 }
370
371 /**
372 * Whether two URLs share scheme, host, and port.
373 *
374 * @param string $first First URL.
375 * @param string $second Second URL.
376 * @return bool
377 */
378 private static function same_origin( $first, $second ) {
379 $first = wp_parse_url( $first );
380 $second = wp_parse_url( $second );
381 if ( ! is_array( $first ) || ! is_array( $second ) || empty( $first['scheme'] ) || empty( $first['host'] ) || empty( $second['scheme'] ) || empty( $second['host'] ) ) {
382 return false;
383 }
384
385 return strtolower( $first['scheme'] ) === strtolower( $second['scheme'] )
386 && strtolower( $first['host'] ) === strtolower( $second['host'] )
387 && self::url_port( $first ) === self::url_port( $second );
388 }
389
390 /**
391 * Gets an explicit or scheme-default URL port.
392 *
393 * @param array<string, mixed> $url Parsed URL.
394 * @return int
395 */
396 private static function url_port( $url ) {
397 return isset( $url['port'] ) ? (int) $url['port'] : ( 'https' === strtolower( $url['scheme'] ) ? 443 : 80 );
398 }
399
400 /**
401 * Retains only non-sensitive headers across an origin change.
402 *
403 * @param array<string, mixed> $headers Request headers.
404 * @return array<string, mixed>
405 */
406 private static function headers_for_cross_origin_redirect( $headers ) {
407 $allowed = array( 'accept', 'accept-encoding', 'range', 'user-agent' );
408 return array_filter(
409 $headers,
410 function ( $value, $name ) use ( $allowed ) {
411 return in_array( strtolower( (string) $name ), $allowed, true );
412 },
413 ARRAY_FILTER_USE_BOTH
414 );
415 }
416 }
417