Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions classes/Visualizer/Module/AIBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,7 @@ public function fetchChart(): void {
/**
* Determines whether a remote URL serves an XLSX file.
*
* Uses wp_safe_remote_get() and checks ZIP magic number (PK\x03\x04).
* Uses the shared remote-fetch policy and checks ZIP magic number (PK\x03\x04).
*
* @access private
* @param string $url The remote URL to probe.
Expand All @@ -218,7 +218,7 @@ private static function _url_is_xlsx( $url ) {
return false;
}

$response = wp_safe_remote_get(
$response = Visualizer_Remote_Fetch::request(
Comment thread
Soare-Robert-Daniel marked this conversation as resolved.
$url,
array(
'timeout' => 15,
Expand Down
7 changes: 4 additions & 3 deletions classes/Visualizer/Module/Chart.php
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,8 @@ public function getJsonData() {

$chart_id = $params['chart'];

if ( empty( $chart_id ) ) {
$chart = $chart_id ? get_post( $chart_id ) : null;
if ( ! $chart || Visualizer_Plugin::CPT_VISUALIZER !== $chart->post_type || ! current_user_can( 'edit_post', $chart_id ) ) {
Comment thread
Soare-Robert-Daniel marked this conversation as resolved.
wp_die();
}

Expand Down Expand Up @@ -1037,7 +1038,7 @@ public function renderFlattrScript() {
* Used as a fallback when the URL path has no recognisable file extension
* (e.g. SharePoint, signed S3 URLs, or "download?id=…" endpoints).
*
* Uses wp_safe_remote_get() to block requests to private/loopback addresses,
* Uses the shared remote-fetch policy to block non-public destinations,
* and streams the response to a temp file so no body data is held in memory
* regardless of whether the server honours the Range header.
*
Expand All @@ -1056,7 +1057,7 @@ private static function _url_is_xlsx( $url ) {
return false;
}

$response = wp_safe_remote_get(
$response = Visualizer_Remote_Fetch::request(
Comment thread
Soare-Robert-Daniel marked this conversation as resolved.
$url,
array(
'timeout' => 10,
Expand Down
347 changes: 347 additions & 0 deletions classes/Visualizer/Remote/Fetch.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,347 @@
<?php
/**
* Safe access to user-controlled remote resources.
*
* @package Visualizer
*/

/**
* Centralizes the network policy for remote chart imports.
*/
class Visualizer_Remote_Fetch {

const MAX_DOWNLOAD_BYTES = 10485760;
const MAX_REDIRECTS = 5;

/**
* Performs a request after validating every destination.
*
* @param string $url Remote URL.
* @param array<string, mixed> $args Optional WordPress HTTP arguments.
* @return array<string, mixed>|WP_Error
*/
public static function request( $url, $args = array() ) {
$args = wp_parse_args(
$args,
array(
'method' => 'GET',
'timeout' => 15,
)
);

$redirects = isset( $args['redirection'] ) ? min( self::MAX_REDIRECTS, max( 0, (int) $args['redirection'] ) ) : self::MAX_REDIRECTS;
unset( $args['redirection'] );

$args = self::enforce_request_policy( $args );
if ( is_wp_error( $args ) ) {
return $args;
}

for ( $redirect = 0; $redirect <= $redirects; $redirect++ ) {
$ips = array();
$validated_url = self::validate_url( $url, $ips );
if ( is_wp_error( $validated_url ) ) {
return $validated_url;
}

$request_args = $args;
$request_args['redirection'] = 0;
$request_args['reject_unsafe_urls'] = true;

$pin = self::pin_validated_addresses( $validated_url, $ips );
$response = wp_safe_remote_request( $validated_url, $request_args );
Comment thread
Soare-Robert-Daniel marked this conversation as resolved.
if ( $pin ) {
remove_action( 'http_api_curl', $pin );
}
if ( is_wp_error( $response ) ) {
return $response;
}

$status = (int) wp_remote_retrieve_response_code( $response );
$location = wp_remote_retrieve_header( $response, 'location' );
if ( $status < 300 || $status > 399 || empty( $location ) ) {
return $response;
}

if ( $redirect === $redirects ) {
return new WP_Error( 'visualizer_too_many_redirects', 'The remote URL redirected too many times.' );
}

$next_url = WP_Http::make_absolute_url( $location, $validated_url );
if ( ! self::same_origin( $validated_url, $next_url ) ) {
$args['headers'] = self::headers_for_cross_origin_redirect( isset( $args['headers'] ) ? $args['headers'] : array() );
unset( $args['cookies'] );
}
Comment thread
Soare-Robert-Daniel marked this conversation as resolved.

if ( 303 === $status || ( in_array( $status, array( 301, 302 ), true ) && 'POST' === $args['method'] ) ) {
$args['method'] = 'GET';
unset( $args['body'] );
}

$url = $next_url;
}

return new WP_Error( 'visualizer_remote_request', 'The remote request could not be completed.' );
}

/**
* Downloads a remote resource to a temporary file.
*
* The caller is responsible for deleting the returned file.
*
* @param string $url Remote URL.
* @param array<string, mixed> $args Optional WordPress HTTP arguments.
* @return string|WP_Error
*/
public static function download( $url, $args = array() ) {
require_once ABSPATH . 'wp-admin/includes/file.php';

$tmpfile = wp_tempnam( (string) wp_parse_url( $url, PHP_URL_PATH ) );
if ( ! $tmpfile ) {
return new WP_Error( 'visualizer_temp_file', 'Could not create a temporary file.' );
}

$args['stream'] = true;
$args['filename'] = $tmpfile;
$args['limit_response_size'] = self::MAX_DOWNLOAD_BYTES;
$response = self::request( $url, $args );

if ( is_wp_error( $response ) ) {
wp_delete_file( $tmpfile );
return $response;
}

if ( 200 !== (int) wp_remote_retrieve_response_code( $response ) ) {
wp_delete_file( $tmpfile );
return new WP_Error( 'visualizer_remote_status', 'The remote server returned an unexpected response.' );
}

// The transport truncates silently at the limit, so a file that reached it cannot be trusted.
clearstatcache( true, $tmpfile );
if ( filesize( $tmpfile ) >= self::MAX_DOWNLOAD_BYTES ) {
wp_delete_file( $tmpfile );
return new WP_Error( 'visualizer_remote_size', 'The remote file is too large to import.' );
}

return $tmpfile;
}

/**
* Applies method and header restrictions.
*
* @param array<string, mixed> $args HTTP arguments.
* @return array<string, mixed>|WP_Error
*/
private static function enforce_request_policy( $args ) {
$args['method'] = strtoupper( (string) $args['method'] );
if ( ! in_array( $args['method'], array( 'GET', 'POST' ), true ) ) {
return new WP_Error( 'visualizer_remote_method', 'Only GET and POST remote requests are allowed.' );
}

$blocked_headers = array( 'connection', 'content-length', 'host', 'proxy-authorization', 'proxy-connection', 'te', 'trailer', 'transfer-encoding', 'upgrade' );
foreach ( isset( $args['headers'] ) ? $args['headers'] : array() as $name => $value ) {
if ( in_array( strtolower( (string) $name ), $blocked_headers, true ) ) {
unset( $args['headers'][ $name ] );
}
}

$args['timeout'] = min( 30, max( 1, (int) $args['timeout'] ) );

return $args;
}

/**
* Binds the cURL transport to the addresses that passed validation.
*
* Without this the transport re-resolves the host on connect, letting a
* rebinding nameserver answer with a private address after validation
* passed. Covers the default cURL transport; the PHP streams fallback
* keeps core's standard re-resolve behavior.
Comment thread
Soare-Robert-Daniel marked this conversation as resolved.
Outdated
*
* @param string $url Validated URL.
* @param string[] $ips Validated addresses.
* @return callable|null The registered hook to remove after dispatch, or null when there is nothing to pin.
*/
private static function pin_validated_addresses( $url, $ips ) {
if ( empty( $ips ) || ! defined( 'CURLOPT_RESOLVE' ) ) {
return null;
}

$parsed = wp_parse_url( $url );
$entry = sprintf( '%s:%d:%s', strtolower( rtrim( $parsed['host'], '.' ) ), self::url_port( $parsed ), implode( ',', $ips ) );
$pin = function ( $handle ) use ( $entry ) {
curl_setopt( $handle, CURLOPT_RESOLVE, array( $entry ) );
};
Comment thread
Soare-Robert-Daniel marked this conversation as resolved.
add_action( 'http_api_curl', $pin );

return $pin;
}

/**
* Validates URL syntax and every address returned by DNS.
*
* @param string $url Remote URL.
* @param string[] $ips Filled with the validated addresses; stays empty when the host is exempt from the check.
* @return string|WP_Error
*/
private static function validate_url( $url, &$ips = array() ) {
$ips = array();
$validated_url = wp_http_validate_url( $url );
if ( false === $validated_url ) {
return new WP_Error( 'visualizer_invalid_remote_url', 'The remote URL is not allowed.' );
}

$host = strtolower( rtrim( (string) wp_parse_url( $validated_url, PHP_URL_HOST ), '.' ) );
Comment thread
Soare-Robert-Daniel marked this conversation as resolved.
Comment thread
Soare-Robert-Daniel marked this conversation as resolved.

// Mirror core's same-host exemption so media library URLs import on hosts that resolve internally.
$home_host = strtolower( rtrim( (string) wp_parse_url( get_option( 'home' ), PHP_URL_HOST ), '.' ) );
if ( $host === $home_host ) {
return $validated_url;
}

$ips = self::resolve_host( $host );
if ( empty( $ips ) ) {
return new WP_Error( 'visualizer_remote_dns', 'The remote host could not be resolved.' );
}

foreach ( $ips as $ip ) {
if ( ! self::is_global_ip( $ip ) ) {
return new WP_Error( 'visualizer_unsafe_remote_url', 'The remote URL resolves to a non-public address.' );
}
}

return $validated_url;
}

/**
* Resolves every IPv4 and IPv6 address for a host.
*
* @param string $host Host name or IP literal.
* @return string[]
*/
private static function resolve_host( $host ) {
if ( filter_var( $host, FILTER_VALIDATE_IP ) ) {
return array( $host );
}

$ips = array();
if ( function_exists( 'dns_get_record' ) ) {
// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- DNS failures are handled below.
$records = @dns_get_record( $host, DNS_A | DNS_AAAA );
foreach ( is_array( $records ) ? $records : array() as $record ) {
if ( ! empty( $record['ip'] ) ) {
$ips[] = $record['ip'];
} elseif ( ! empty( $record['ipv6'] ) ) {
$ips[] = $record['ipv6'];
}
}
}

if ( empty( $ips ) ) {
// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- DNS failures are handled by returning no addresses.
$ipv4 = @gethostbynamel( $host );
$ips = is_array( $ipv4 ) ? $ipv4 : array();
}

return array_values( array_unique( $ips ) );
}

/**
* Whether an address is globally routable.
*
* @param string $ip IP address.
* @return bool
*/
private static function is_global_ip( $ip ) {
if ( self::ip_in_range( $ip, '::ffff:0:0/96' ) ) {
$packed = inet_pton( $ip );
$ip = inet_ntop( substr( $packed, 12 ) );
}

if ( false === filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ) ) {
return false;
}

$ranges = false !== strpos( $ip, ':' )
? array( 'fc00::/7', 'fe80::/10', 'ff00::/8' )
: 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' );

foreach ( $ranges as $range ) {
if ( self::ip_in_range( $ip, $range ) ) {
return false;
}
}

return true;
}

/**
* Checks whether an IP belongs to a CIDR range.
*
* @param string $ip IP address.
* @param string $cidr CIDR range.
* @return bool
*/
private static function ip_in_range( $ip, $cidr ) {
list( $network, $prefix ) = explode( '/', $cidr, 2 );
$address = inet_pton( $ip );
$network = inet_pton( $network );
if ( false === $address || false === $network || strlen( $address ) !== strlen( $network ) ) {
return false;
}

$bytes = intdiv( (int) $prefix, 8 );
$bits = (int) $prefix % 8;
if ( substr( $address, 0, $bytes ) !== substr( $network, 0, $bytes ) ) {
return false;
}

return 0 === $bits || ( ord( $address[ $bytes ] ) & ( 0xff << ( 8 - $bits ) ) ) === ( ord( $network[ $bytes ] ) & ( 0xff << ( 8 - $bits ) ) );
}

/**
* Whether two URLs share scheme, host, and port.
*
* @param string $first First URL.
* @param string $second Second URL.
* @return bool
*/
private static function same_origin( $first, $second ) {
$first = wp_parse_url( $first );
$second = wp_parse_url( $second );
if ( ! is_array( $first ) || ! is_array( $second ) || empty( $first['scheme'] ) || empty( $first['host'] ) || empty( $second['scheme'] ) || empty( $second['host'] ) ) {
return false;
}

return strtolower( $first['scheme'] ) === strtolower( $second['scheme'] )
&& strtolower( $first['host'] ) === strtolower( $second['host'] )
&& self::url_port( $first ) === self::url_port( $second );
}

/**
* Gets an explicit or scheme-default URL port.
*
* @param array<string, mixed> $url Parsed URL.
* @return int
*/
private static function url_port( $url ) {
return isset( $url['port'] ) ? (int) $url['port'] : ( 'https' === strtolower( $url['scheme'] ) ? 443 : 80 );
}

/**
* Retains only non-sensitive headers across an origin change.
*
* @param array<string, mixed> $headers Request headers.
* @return array<string, mixed>
*/
private static function headers_for_cross_origin_redirect( $headers ) {
$allowed = array( 'accept', 'accept-encoding', 'range', 'user-agent' );
return array_filter(
$headers,
function ( $value, $name ) use ( $allowed ) {
return in_array( strtolower( (string) $name ), $allowed, true );
},
ARRAY_FILTER_USE_BOTH
);
}
}
Loading
Loading