From 63fec805f4bbd0d05f9c254f953fe31d3901e494 Mon Sep 17 00:00:00 2001 From: Hallison Boaventura Date: Sun, 12 Jan 2020 02:52:44 -0300 Subject: [PATCH] [HttpClient] adding NoPrivateNetworkHttpClient decorator --- src/Symfony/Component/HttpClient/CHANGELOG.md | 1 + .../HttpClient/NoPrivateNetworkHttpClient.php | 113 ++++++++++++ .../Tests/NoPrivateNetworkHttpClientTest.php | 164 ++++++++++++++++++ 3 files changed, 278 insertions(+) create mode 100644 src/Symfony/Component/HttpClient/NoPrivateNetworkHttpClient.php create mode 100755 src/Symfony/Component/HttpClient/Tests/NoPrivateNetworkHttpClientTest.php diff --git a/src/Symfony/Component/HttpClient/CHANGELOG.md b/src/Symfony/Component/HttpClient/CHANGELOG.md index 65116742061e..97491f1196af 100644 --- a/src/Symfony/Component/HttpClient/CHANGELOG.md +++ b/src/Symfony/Component/HttpClient/CHANGELOG.md @@ -4,6 +4,7 @@ CHANGELOG 5.1.0 ----- +* added `NoPrivateNetworkHttpClient` decorator * added `LoggerAwareInterface` to `ScopingHttpClient` and `TraceableHttpClient` 4.4.0 diff --git a/src/Symfony/Component/HttpClient/NoPrivateNetworkHttpClient.php b/src/Symfony/Component/HttpClient/NoPrivateNetworkHttpClient.php new file mode 100644 index 000000000000..c01b906a0030 --- /dev/null +++ b/src/Symfony/Component/HttpClient/NoPrivateNetworkHttpClient.php @@ -0,0 +1,113 @@ + + * + * 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\InvalidArgumentException; +use Symfony\Component\HttpClient\Exception\TransportException; +use Symfony\Component\HttpFoundation\IpUtils; +use Symfony\Contracts\HttpClient\HttpClientInterface; +use Symfony\Contracts\HttpClient\ResponseInterface; +use Symfony\Contracts\HttpClient\ResponseStreamInterface; + +/** + * Decorator that blocks requests to private networks by default. + * + * @author Hallison Boaventura + */ +final class NoPrivateNetworkHttpClient implements HttpClientInterface, LoggerAwareInterface +{ + use HttpClientTrait; + + 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 $client; + private $subnets; + + /** + * @param string|array|null $subnets String or array of subnets using CIDR notation that will be used by IpUtils. + * 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__, \is_object($subnets) ? \get_class($subnets) : \gettype($subnets))); + } + + if (!class_exists(IpUtils::class)) { + throw new \LogicException(sprintf('You can not use "%s" if the HttpFoundation component is not installed. Try running "composer require symfony/http-foundation".', __CLASS__)); + } + + $this->client = $client; + $this->subnets = $subnets; + } + + /** + * {@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.', \is_object($onProgress) ? \get_class($onProgress) : \gettype($onProgress))); + } + + $subnets = $this->subnets; + $lastPrimaryIp = ''; + + $options['on_progress'] = function (int $dlNow, int $dlSize, array $info) use ($onProgress, $subnets, &$lastPrimaryIp): void { + if ($info['primary_ip'] !== $lastPrimaryIp) { + if (IpUtils::checkIp($info['primary_ip'], $subnets ?? self::PRIVATE_SUBNETS)) { + throw new TransportException(sprintf('IP "%s" is blacklisted for "%s".', $info['primary_ip'], $info['url'])); + } + + $lastPrimaryIp = $info['primary_ip']; + } + + null !== $onProgress && $onProgress($dlNow, $dlSize, $info); + }; + + return $this->client->request($method, $url, $options); + } + + /** + * {@inheritdoc} + */ + public function stream($responses, float $timeout = null): ResponseStreamInterface + { + return $this->client->stream($responses, $timeout); + } + + /** + * {@inheritdoc} + */ + public function setLogger(LoggerInterface $logger): void + { + if ($this->client instanceof LoggerAwareInterface) { + $this->client->setLogger($logger); + } + } +} diff --git a/src/Symfony/Component/HttpClient/Tests/NoPrivateNetworkHttpClientTest.php b/src/Symfony/Component/HttpClient/Tests/NoPrivateNetworkHttpClientTest.php new file mode 100755 index 000000000000..926dead34f6e --- /dev/null +++ b/src/Symfony/Component/HttpClient/Tests/NoPrivateNetworkHttpClientTest.php @@ -0,0 +1,164 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\HttpClient\Tests; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpClient\Exception\InvalidArgumentException; +use Symfony\Component\HttpClient\Exception\TransportException; +use Symfony\Component\HttpClient\MockHttpClient; +use Symfony\Component\HttpClient\NoPrivateNetworkHttpClient; +use Symfony\Component\HttpClient\Response\MockResponse; +use Symfony\Contracts\HttpClient\HttpClientInterface; +use Symfony\Contracts\HttpClient\ResponseInterface; + +class NoPrivateNetworkHttpClientTest extends TestCase +{ + public function getBlacklistData(): array + { + return [ + // private + ['0.0.0.1', null, true], + ['169.254.0.1', null, true], + ['127.0.0.1', null, true], + ['240.0.0.1', null, true], + ['10.0.0.1', null, true], + ['172.16.0.1', null, true], + ['192.168.0.1', null, true], + ['::1', null, true], + ['::ffff:0:1', null, true], + ['fe80::1', null, true], + ['fc00::1', null, true], + ['fd00::1', null, true], + ['10.0.0.1', '10.0.0.0/24', true], + ['10.0.0.1', '10.0.0.1', true], + ['fc00::1', 'fc00::1/120', true], + ['fc00::1', 'fc00::1', true], + + ['172.16.0.1', ['10.0.0.0/8', '192.168.0.0/16'], false], + ['fc00::1', ['fe80::/10', '::ffff:0:0/96'], false], + + // public + ['104.26.14.6', null, false], + ['104.26.14.6', '104.26.14.0/24', true], + ['2606:4700:20::681a:e06', null, false], + ['2606:4700:20::681a:e06', '2606:4700:20::/43', true], + + // no ipv4/ipv6 at all + ['2606:4700:20::681a:e06', '::/0', true], + ['104.26.14.6', '0.0.0.0/0', true], + + // weird scenarios (e.g.: when trying to match ipv4 address on ipv6 subnet) + ['10.0.0.1', 'fc00::/7', false], + ['fc00::1', '10.0.0.0/8', false], + ]; + } + + /** + * @dataProvider getBlacklistData + */ + public function testBlacklist(string $ipAddr, $subnets, bool $mustThrow) + { + $content = 'foo'; + $url = sprintf('http://%s/', 0 < substr_count($ipAddr, ':') ? sprintf('[%s]', $ipAddr) : $ipAddr); + + if ($mustThrow) { + $this->expectException(TransportException::class); + $this->expectExceptionMessage(sprintf('IP "%s" is blacklisted for "%s".', $ipAddr, $url)); + } + + $previousHttpClient = $this->getHttpClientMock($url, $ipAddr, $content); + $client = new NoPrivateNetworkHttpClient($previousHttpClient, $subnets); + $response = $client->request('GET', $url); + + if (!$mustThrow) { + $this->assertEquals($content, $response->getContent()); + $this->assertEquals(200, $response->getStatusCode()); + } + } + + public function testCustomOnProgressCallback() + { + $ipAddr = '104.26.14.6'; + $url = sprintf('http://%s/', $ipAddr); + $content = 'foo'; + + $executionCount = 0; + $customCallback = function (int $dlNow, int $dlSize, array $info) use (&$executionCount): void { + ++$executionCount; + }; + + $previousHttpClient = $this->getHttpClientMock($url, $ipAddr, $content); + $client = new NoPrivateNetworkHttpClient($previousHttpClient); + $response = $client->request('GET', $url, ['on_progress' => $customCallback]); + + $this->assertEquals(1, $executionCount); + $this->assertEquals($content, $response->getContent()); + $this->assertEquals(200, $response->getStatusCode()); + } + + public function testNonCallableOnProgressCallback() + { + $ipAddr = '104.26.14.6'; + $url = sprintf('http://%s/', $ipAddr); + $content = 'bar'; + $customCallback = sprintf('cb_%s', microtime(true)); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Option "on_progress" must be callable, string given.'); + + $client = new NoPrivateNetworkHttpClient(new MockHttpClient()); + $client->request('GET', $url, ['on_progress' => $customCallback]); + } + + public function testConstructor() + { + $this->expectException(\TypeError::class); + $this->expectExceptionMessage('Argument 2 passed to Symfony\Component\HttpClient\NoPrivateNetworkHttpClient::__construct() must be of the type array, string or null. integer given.'); + + new NoPrivateNetworkHttpClient(new MockHttpClient(), 3); + } + + private function getHttpClientMock(string $url, string $ipAddr, string $content) + { + $previousHttpClient = $this + ->getMockBuilder(HttpClientInterface::class) + ->getMock(); + + $previousHttpClient + ->expects($this->once()) + ->method('request') + ->with( + 'GET', + $url, + $this->callback(function ($options) { + $this->assertArrayHasKey('on_progress', $options); + $onProgress = $options['on_progress']; + $this->assertIsCallable($onProgress); + + return true; + }) + ) + ->willReturnCallback(function ($method, $url, $options) use ($ipAddr, $content): ResponseInterface { + $info = [ + 'primary_ip' => $ipAddr, + 'url' => $url, + ]; + + $onProgress = $options['on_progress']; + $onProgress(0, 0, $info); + + return MockResponse::fromRequest($method, $url, [], new MockResponse($content)); + }); + + return $previousHttpClient; + } +}