2024-11-19 08:02:04 +01:00
< ? php
/*
* This file is part of the Symfony package .
*
* ( c ) Fabien Potencier < fabien @ symfony . com >
*
* For the full copyright and license information , please view the LICENSE
* file that was distributed with this source code .
*/
namespace Symfony\Component\HttpClient ;
use Psr\Log\LoggerAwareInterface ;
use Psr\Log\LoggerInterface ;
use Symfony\Component\HttpClient\Exception\TransportException ;
2025-02-11 21:30:02 +01:00
use Symfony\Component\HttpClient\Response\AsyncContext ;
use Symfony\Component\HttpClient\Response\AsyncResponse ;
2024-11-19 08:02:04 +01:00
use Symfony\Component\HttpFoundation\IpUtils ;
2025-02-11 21:30:02 +01:00
use Symfony\Contracts\HttpClient\ChunkInterface ;
2024-11-19 08:02:04 +01:00
use Symfony\Contracts\HttpClient\HttpClientInterface ;
use Symfony\Contracts\HttpClient\ResponseInterface ;
use Symfony\Contracts\Service\ResetInterface ;
/**
* Decorator that blocks requests to private networks by default .
*
* @ author Hallison Boaventura < hallisonboaventura @ gmail . com >
2025-02-11 21:30:02 +01:00
* @ author Nicolas Grekas < p @ tchwork . com >
2024-11-19 08:02:04 +01:00
*/
final class NoPrivateNetworkHttpClient implements HttpClientInterface , LoggerAwareInterface , ResetInterface
{
use HttpClientTrait ;
2025-02-11 21:30:02 +01:00
use AsyncDecoratorTrait ;
2024-11-19 08:02:04 +01:00
private const PRIVATE_SUBNETS = [
'127.0.0.0/8' ,
'10.0.0.0/8' ,
'192.168.0.0/16' ,
'172.16.0.0/12' ,
'169.254.0.0/16' ,
'0.0.0.0/8' ,
'240.0.0.0/4' ,
'::1/128' ,
'fc00::/7' ,
'fe80::/10' ,
'::ffff:0:0/96' ,
'::/128' ,
];
2025-02-11 21:30:02 +01:00
private $defaultOptions = self :: OPTIONS_DEFAULTS ;
2024-11-19 08:02:04 +01:00
private $client ;
private $subnets ;
2025-02-11 21:30:02 +01:00
private $ipFlags ;
private $dnsCache ;
2024-11-19 08:02:04 +01:00
/**
2025-02-11 21:30:02 +01:00
* @ param string | array | null $subnets String or array of subnets using CIDR notation that should be considered private .
2024-11-19 08:02:04 +01:00
* If null is passed , the standard private subnets will be used .
*/
public function __construct ( HttpClientInterface $client , $subnets = null )
{
if ( ! ( \is_array ( $subnets ) || \is_string ( $subnets ) || null === $subnets )) {
throw new \TypeError ( sprintf ( 'Argument 2 passed to "%s()" must be of the type array, string or null. "%s" given.' , __METHOD__ , get_debug_type ( $subnets )));
}
if ( ! class_exists ( IpUtils :: class )) {
throw new \LogicException ( sprintf ( 'You cannot use "%s" if the HttpFoundation component is not installed. Try running "composer require symfony/http-foundation".' , __CLASS__ ));
}
2025-02-11 21:30:02 +01:00
if ( null === $subnets ) {
$ipFlags = \FILTER_FLAG_IPV4 | \FILTER_FLAG_IPV6 ;
} else {
$ipFlags = 0 ;
foreach (( array ) $subnets as $subnet ) {
$ipFlags |= str_contains ( $subnet , ':' ) ? \FILTER_FLAG_IPV6 : \FILTER_FLAG_IPV4 ;
}
}
if ( ! \defined ( 'STREAM_PF_INET6' )) {
$ipFlags &= ~ \FILTER_FLAG_IPV6 ;
}
2024-11-19 08:02:04 +01:00
$this -> client = $client ;
2025-02-11 21:30:02 +01:00
$this -> subnets = null !== $subnets ? ( array ) $subnets : null ;
$this -> ipFlags = $ipFlags ;
$this -> dnsCache = new \ArrayObject ();
2024-11-19 08:02:04 +01:00
}
/**
* { @ inheritdoc }
*/
public function request ( string $method , string $url , array $options = []) : ResponseInterface
{
2025-02-11 21:30:02 +01:00
[ $url , $options ] = self :: prepareRequest ( $method , $url , $options , $this -> defaultOptions , true );
$redirectHeaders = parse_url ( $url [ 'authority' ]);
$host = $redirectHeaders [ 'host' ];
$url = implode ( '' , $url );
$dnsCache = $this -> dnsCache ;
$ip = self :: dnsResolve ( $dnsCache , $host , $this -> ipFlags , $options );
self :: ipCheck ( $ip , $this -> subnets , $this -> ipFlags , $host , $url );
2024-11-19 08:02:04 +01:00
2025-02-11 21:30:02 +01:00
$onProgress = $options [ 'on_progress' ] ? ? null ;
2024-11-19 08:02:04 +01:00
$subnets = $this -> subnets ;
2025-02-11 21:30:02 +01:00
$ipFlags = $this -> ipFlags ;
2024-11-19 08:02:04 +01:00
$lastPrimaryIp = '' ;
2025-02-11 21:30:02 +01:00
$options [ 'on_progress' ] = static function ( int $dlNow , int $dlSize , array $info ) use ( $onProgress , $subnets , $ipFlags , & $lastPrimaryIp ) : void {
if ( ! \in_array ( $info [ 'primary_ip' ] ? ? '' , [ '' , $lastPrimaryIp ], true )) {
self :: ipCheck ( $info [ 'primary_ip' ], $subnets , $ipFlags , null , $info [ 'url' ]);
$lastPrimaryIp = $info [ 'primary_ip' ];
}
2024-11-19 09:59:00 +01:00
2025-02-11 21:30:02 +01:00
null !== $onProgress && $onProgress ( $dlNow , $dlSize , $info );
};
2024-11-19 09:59:00 +01:00
2025-02-11 21:30:02 +01:00
if ( 0 >= $maxRedirects = $options [ 'max_redirects' ]) {
return new AsyncResponse ( $this -> client , $method , $url , $options );
}
$options [ 'max_redirects' ] = 0 ;
$redirectHeaders [ 'with_auth' ] = $redirectHeaders [ 'no_auth' ] = $options [ 'headers' ];
if ( isset ( $options [ 'normalized_headers' ][ 'host' ]) || isset ( $options [ 'normalized_headers' ][ 'authorization' ]) || isset ( $options [ 'normalized_headers' ][ 'cookie' ])) {
$redirectHeaders [ 'no_auth' ] = array_filter ( $redirectHeaders [ 'no_auth' ], static function ( $h ) {
return 0 !== stripos ( $h , 'Host:' ) && 0 !== stripos ( $h , 'Authorization:' ) && 0 !== stripos ( $h , 'Cookie:' );
});
}
return new AsyncResponse ( $this -> client , $method , $url , $options , static function ( ChunkInterface $chunk , AsyncContext $context ) use ( & $method , & $options , $maxRedirects , & $redirectHeaders , $subnets , $ipFlags , $dnsCache ) : \Generator {
if ( null !== $chunk -> getError () || $chunk -> isTimeout () || ! $chunk -> isFirst ()) {
yield $chunk ;
return ;
2024-11-19 09:59:00 +01:00
}
2025-02-11 21:30:02 +01:00
$statusCode = $context -> getStatusCode ();
2024-11-19 08:02:04 +01:00
2025-02-11 21:30:02 +01:00
if ( $statusCode < 300 || 400 <= $statusCode || null === $url = $context -> getInfo ( 'redirect_url' )) {
$context -> passthru ();
yield $chunk ;
return ;
2024-11-19 08:02:04 +01:00
}
2025-02-11 21:30:02 +01:00
$host = parse_url ( $url , \PHP_URL_HOST );
$ip = self :: dnsResolve ( $dnsCache , $host , $ipFlags , $options );
self :: ipCheck ( $ip , $subnets , $ipFlags , $host , $url );
2024-11-19 08:02:04 +01:00
2025-02-11 21:30:02 +01:00
// Do like curl and browsers: turn POST to GET on 301, 302 and 303
if ( 303 === $statusCode || 'POST' === $method && \in_array ( $statusCode , [ 301 , 302 ], true )) {
$method = 'HEAD' === $method ? 'HEAD' : 'GET' ;
unset ( $options [ 'body' ], $options [ 'json' ]);
2024-11-19 08:02:04 +01:00
2025-02-11 21:30:02 +01:00
if ( isset ( $options [ 'normalized_headers' ][ 'content-length' ]) || isset ( $options [ 'normalized_headers' ][ 'content-type' ]) || isset ( $options [ 'normalized_headers' ][ 'transfer-encoding' ])) {
$filterContentHeaders = static function ( $h ) {
return 0 !== stripos ( $h , 'Content-Length:' ) && 0 !== stripos ( $h , 'Content-Type:' ) && 0 !== stripos ( $h , 'Transfer-Encoding:' );
};
$options [ 'header' ] = array_filter ( $options [ 'header' ], $filterContentHeaders );
$redirectHeaders [ 'no_auth' ] = array_filter ( $redirectHeaders [ 'no_auth' ], $filterContentHeaders );
$redirectHeaders [ 'with_auth' ] = array_filter ( $redirectHeaders [ 'with_auth' ], $filterContentHeaders );
}
}
// Authorization and Cookie headers MUST NOT follow except for the initial host name
$options [ 'headers' ] = $redirectHeaders [ 'host' ] === $host ? $redirectHeaders [ 'with_auth' ] : $redirectHeaders [ 'no_auth' ];
static $redirectCount = 0 ;
$context -> setInfo ( 'redirect_count' , ++ $redirectCount );
$context -> replaceRequest ( $method , $url , $options );
if ( $redirectCount >= $maxRedirects ) {
$context -> passthru ();
}
});
2024-11-19 08:02:04 +01:00
}
/**
* { @ inheritdoc }
*/
public function setLogger ( LoggerInterface $logger ) : void
{
if ( $this -> client instanceof LoggerAwareInterface ) {
$this -> client -> setLogger ( $logger );
}
}
/**
* { @ inheritdoc }
*/
public function withOptions ( array $options ) : self
{
$clone = clone $this ;
$clone -> client = $this -> client -> withOptions ( $options );
2025-02-11 21:30:02 +01:00
$clone -> defaultOptions = self :: mergeDefaultOptions ( $options , $this -> defaultOptions );
2024-11-19 08:02:04 +01:00
return $clone ;
}
public function reset ()
{
2025-02-11 21:30:02 +01:00
$this -> dnsCache -> exchangeArray ([]);
2024-11-19 08:02:04 +01:00
if ( $this -> client instanceof ResetInterface ) {
$this -> client -> reset ();
}
}
2025-02-11 21:30:02 +01:00
private static function dnsResolve ( \ArrayObject $dnsCache , string $host , int $ipFlags , array & $options ) : string
{
if ( $ip = filter_var ( trim ( $host , '[]' ), \FILTER_VALIDATE_IP ) ? : $options [ 'resolve' ][ $host ] ? ? false ) {
return $ip ;
}
if ( $dnsCache -> offsetExists ( $host )) {
return $dnsCache [ $host ];
}
if (( \FILTER_FLAG_IPV4 & $ipFlags ) && $ip = gethostbynamel ( $host )) {
return $options [ 'resolve' ][ $host ] = $dnsCache [ $host ] = $ip [ 0 ];
}
if ( ! ( \FILTER_FLAG_IPV6 & $ipFlags )) {
return $host ;
}
if ( $ip = dns_get_record ( $host , \DNS_AAAA )) {
$ip = $ip [ 0 ][ 'ipv6' ];
} elseif ( extension_loaded ( 'sockets' )) {
if ( ! $info = socket_addrinfo_lookup ( $host , 0 , [ 'ai_socktype' => \SOCK_STREAM , 'ai_family' => \AF_INET6 ])) {
return $host ;
}
$ip = socket_addrinfo_explain ( $info [ 0 ])[ 'ai_addr' ][ 'sin6_addr' ];
} elseif ( 'localhost' === $host || 'localhost.' === $host ) {
$ip = '::1' ;
} else {
return $host ;
}
return $options [ 'resolve' ][ $host ] = $dnsCache [ $host ] = $ip ;
}
private static function ipCheck ( string $ip , ? array $subnets , int $ipFlags , ? string $host , string $url ) : void
{
if ( null === $subnets ) {
// Quick check, but not reliable enough, see https://github.com/php/php-src/issues/16944
$ipFlags |= \FILTER_FLAG_NO_PRIV_RANGE | \FILTER_FLAG_NO_RES_RANGE ;
}
if ( false !== filter_var ( $ip , \FILTER_VALIDATE_IP , $ipFlags ) && ! IpUtils :: checkIp ( $ip , $subnets ? ? self :: PRIVATE_SUBNETS )) {
return ;
}
if ( null !== $host ) {
$type = 'Host' ;
} else {
$host = $ip ;
$type = 'IP' ;
}
throw new TransportException ( $type . \sprintf ( ' "%s" is blocked for "%s".' , $host , $url ));
}
2024-11-19 08:02:04 +01:00
}