-
-
Notifications
You must be signed in to change notification settings - Fork 50
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
[HttpClient] Fix checking for private IPs before connecting
- Loading branch information
1 parent
5acf07c
commit 63a1278
Showing
9 changed files
with
246 additions
and
76 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -13,9 +13,11 @@ | |
|
||
use Psr\Log\LoggerAwareInterface; | ||
use Psr\Log\LoggerInterface; | ||
use Symfony\Component\HttpClient\Exception\InvalidArgumentException; | ||
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\HttpClient\ResponseStreamInterface; | ||
|
@@ -25,10 +27,12 @@ | |
* Decorator that blocks requests to private networks by default. | ||
* | ||
* @author Hallison Boaventura <[email protected]> | ||
* @author Nicolas Grekas <[email protected]> | ||
*/ | ||
final class NoPrivateNetworkHttpClient implements HttpClientInterface, LoggerAwareInterface, ResetInterface | ||
{ | ||
use HttpClientTrait; | ||
use AsyncDecoratorTrait; | ||
|
||
private const PRIVATE_SUBNETS = [ | ||
'127.0.0.0/8', | ||
|
@@ -45,11 +49,14 @@ final class NoPrivateNetworkHttpClient implements HttpClientInterface, LoggerAwa | |
'::/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 will be used by IpUtils. | ||
* @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) | ||
|
@@ -62,56 +69,113 @@ public function __construct(HttpClientInterface $client, $subnets = null) | |
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 = $subnets; | ||
$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 | ||
{ | ||
$onProgress = $options['on_progress'] ?? null; | ||
if (null !== $onProgress && !\is_callable($onProgress)) { | ||
throw new InvalidArgumentException(sprintf('Option "on_progress" must be callable, "%s" given.', get_debug_type($onProgress))); | ||
} | ||
[$url, $options] = self::prepareRequest($method, $url, $options, $this->defaultOptions, true); | ||
|
||
$subnets = $this->subnets; | ||
$lastUrl = ''; | ||
$lastPrimaryIp = ''; | ||
$redirectHeaders = parse_url($url['authority']); | ||
$host = $redirectHeaders['host']; | ||
$url = implode('', $url); | ||
$dnsCache = $this->dnsCache; | ||
|
||
$options['on_progress'] = function (int $dlNow, int $dlSize, array $info) use ($onProgress, $subnets, &$lastUrl, &$lastPrimaryIp): void { | ||
if ($info['url'] !== $lastUrl) { | ||
$host = parse_url($info['url'], PHP_URL_HOST) ?: ''; | ||
$resolve = $info['resolve'] ?? static function () { return null; }; | ||
|
||
if (($ip = trim($host, '[]')) | ||
&& !filter_var($ip, \FILTER_VALIDATE_IP) | ||
&& !($ip = $resolve($host)) | ||
&& $ip = @(gethostbynamel($host)[0] ?? dns_get_record($host, \DNS_AAAA)[0]['ipv6'] ?? null) | ||
) { | ||
$resolve($host, $ip); | ||
} | ||
$ip = self::dnsResolve($dnsCache, $host, $this->ipFlags, $options); | ||
self::ipCheck($ip, $this->subnets, $this->ipFlags, $host, $url); | ||
|
||
if ($ip && IpUtils::checkIp($ip, $subnets ?? self::PRIVATE_SUBNETS)) { | ||
throw new TransportException(sprintf('Host "%s" is blocked for "%s".', $host, $info['url'])); | ||
} | ||
if (0 < $maxRedirects = $options['max_redirects']) { | ||
$options['max_redirects'] = 0; | ||
$redirectHeaders['with_auth'] = $redirectHeaders['no_auth'] = $options['headers']; | ||
|
||
$lastUrl = $info['url']; | ||
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:'); | ||
}); | ||
} | ||
} | ||
|
||
if ($info['primary_ip'] !== $lastPrimaryIp) { | ||
if ($info['primary_ip'] && IpUtils::checkIp($info['primary_ip'], $subnets ?? self::PRIVATE_SUBNETS)) { | ||
throw new TransportException(sprintf('IP "%s" is blocked for "%s".', $info['primary_ip'], $info['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 (($info['primary_ip'] ?? '') !== $lastPrimaryIp) { | ||
self::ipCheck($info['primary_ip'], $subnets, $ipFlags, null, $info['url']); | ||
$lastPrimaryIp = $info['primary_ip']; | ||
} | ||
|
||
null !== $onProgress && $onProgress($dlNow, $dlSize, $info); | ||
}; | ||
|
||
return $this->client->request($method, $url, $options); | ||
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(); | ||
} | ||
}); | ||
} | ||
|
||
/** | ||
|
@@ -139,14 +203,73 @@ 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)); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.