* * 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; use Symfony\Component\HttpClient\Response\AsyncContext; use Symfony\Component\HttpClient\Response\AsyncResponse; use Symfony\Component\HttpFoundation\IpUtils; use Symfony\Contracts\HttpClient\ChunkInterface; 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 * @author Nicolas Grekas */ final class NoPrivateNetworkHttpClient implements HttpClientInterface, LoggerAwareInterface, ResetInterface { use HttpClientTrait; use AsyncDecoratorTrait; 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', ]; private $defaultOptions = self::OPTIONS_DEFAULTS; private $client; private $subnets; private $ipFlags; private $dnsCache; /** * @param string|array|null $subnets String or array of subnets using CIDR notation that should be considered private. * 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__)); } 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; } $this->client = $client; $this->subnets = null !== $subnets ? (array) $subnets : null; $this->ipFlags = $ipFlags; $this->dnsCache = new \ArrayObject(); } /** * {@inheritdoc} */ public function request(string $method, string $url, array $options = []): ResponseInterface { [$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); $onProgress = $options['on_progress'] ?? null; $subnets = $this->subnets; $ipFlags = $this->ipFlags; $lastPrimaryIp = ''; $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']; } null !== $onProgress && $onProgress($dlNow, $dlSize, $info); }; 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; } $statusCode = $context->getStatusCode(); if ($statusCode < 300 || 400 <= $statusCode || null === $url = $context->getInfo('redirect_url')) { $context->passthru(); yield $chunk; return; } $host = parse_url($url, \PHP_URL_HOST); $ip = self::dnsResolve($dnsCache, $host, $ipFlags, $options); self::ipCheck($ip, $subnets, $ipFlags, $host, $url); // 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']); 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(); } }); } /** * {@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); $clone->defaultOptions = self::mergeDefaultOptions($options, $this->defaultOptions); return $clone; } public function reset() { $this->dnsCache->exchangeArray([]); if ($this->client instanceof ResetInterface) { $this->client->reset(); } } 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)); } }