8000 [HttpClient] adding NoPrivateNetworkHttpClient decorator · symfony/symfony@e48b6d5 · GitHub
[go: up one dir, main page]

Skip to content

Commit e48b6d5

Browse files
committed
[HttpClient] adding NoPrivateNetworkHttpClient decorator
1 parent 5da9cf3 commit e48b6d5

File tree

3 files changed

+278
-0
lines changed

3 files changed

+278
-0
lines changed

src/Symfony/Component/HttpClient/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ CHANGELOG
44
5.1.0
55
-----
66

7+
* added `NoPrivateNetworkHttpClient` decorator
78
* added `LoggerAwareInterface` to `ScopingHttpClient` and `TraceableHttpClient`
89

910
4.4.0
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\HttpClient;
13+
14+
use Psr\Log\LoggerAwareInterface;
15+
use Psr\Log\LoggerInterface;
16+
use Symfony\Component\HttpClient\Exception\InvalidArgumentException;
17+
use Symfony\Component\HttpClient\Exception\TransportException;
18+
use Symfony\Component\HttpFoundation\IpUtils;
19+
use Symfony\Contracts\HttpClient\HttpClientInterface;
20+
use Symfony\Contracts\HttpClient\ResponseInterface;
21+
use Symfony\Contracts\HttpClient\ResponseStreamInterface;
22+
23+
/**
24+
* Decorator that blocks requests to private networks by default.
25+
*
26+
* @author Hallison Boaventura <hallisonboaventura@gmail.com>
27+
*/
28+
final class NoPrivateNetworkHttpClient implements HttpClientInterface, LoggerAwareInterface
29+
{
30+
use HttpClientTrait;
31+
32+
const PRIVATE_SUBNETS = [
33+
'127.0.0.0/8',
34+
'10.0.0.0/8',
35+
'192.168.0.0/16',
36+
'172.16.0.0/12',
37+
'169.254.0.0/16',
38+
'0.0.0.0/8',
39+
'240.0.0.0/4',
40+
'::1/128',
41+
'fc00::/7',
42+
'fe80::/10',
43+
'::ffff:0:0/96',
44+
'::/128',
45+
];
46+
47+
private $client;
48+
private $subnets;
49+
50+
/**
51+
* @param string|array|null $subnets String or array of subnets using CIDR notation that will be used by IpUtils.
52+
* If null is passed, the standard private subnets will be used.
53+
*/
54+
public function __construct(HttpClientInterface $client, $subnets = null)
55+
{
56+
if (!(\is_array($subnets) || \is_string($subnets) || null === $subnets)) {
57+
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)));
58+
}
59+
60+
if (!class_exists(IpUtils::class)) {
61+
throw new \LogicException(sprintf('You can not use "%s" if the HttpFoundation component is not installed. Try running "composer require symfony/http-foundation".', __CLASS__));
62+
}
63+
64+
$this->client = $client;
65+
$this->subnets = $subnets;
66+
}
67+
68+
/**
69+
* {@inheritdoc}
70+
*/
71+
public function request(string $method, string $url, array $options = []): ResponseInterface
72+
{
73+
$onProgress = $options['on_progress'] ?? null;
74+
if (null !== $onProgress && !\is_callable($onProgress)) {
75+
throw new InvalidArgumentException(sprintf('Option "on_progress" must be callable, %s given.', \is_object($onProgress) ? \get_class($onProgress) : \gettype($onProgress)));
76+
}
77+
78+
$subnets = $this->subnets;
79+
$lastPrimaryIp = '';
80+
81+
$options['on_progress'] = function (int $dlNow, int $dlSize, array $info) use ($onProgress, $subnets, &$lastPrimaryIp): void {
82+
if ($info['primary_ip'] !== $lastPrimaryIp) {
83+
if (IpUtils::checkIp($info['primary_ip'], $subnets ?? self::PRIVATE_SUBNETS)) {
84+
throw new TransportException(sprintf('IP "%s" is blacklisted for "%s".', $info['primary_ip'], $info['url']));
85+
}
86+
87+
$lastPrimaryIp = $info['primary_ip'];
88+
}
89+
90+
null !== $onProgress && $onProgress($dlNow, $dlSize, $info);
91+
};
92+
93+
return $this->client->request($method, $url, $options);
94+
}
95+
96+
/**
97+
* {@inheritdoc}
98+
*/
99+
public function stream($responses, float $timeout = null): ResponseStreamInterface
100+
{
101+
return $this->client->stream($responses, $timeout);
102+
}
103+
104+
/**
105+
* {@inheritdoc}
106+
*/
107+
public function setLogger(LoggerInterface $logger): void
108+
{
109+
if ($this->client instanceof LoggerAwareInterface) {
110+
$this->client->setLogger($logger);
111+
}
112+
}
113+
}
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\HttpClient\Tests;
13+
14+
use PHPUnit\Framework\TestCase;
15+
use Symfony\Component\HttpClient\Exception\InvalidArgumentException;
16+
use Symfony\Component\HttpClient\Exception\TransportException;
17+
use Symfony\Component\HttpClient\MockHttpClient;
18+
use Symfony\Component\HttpClient\NoPrivateNetworkHttpClient;
19+
use Symfony\Component\HttpClient\Response\MockResponse;
20+
use Symfony\Contracts\HttpClient\HttpClientInterface;
21+
use Symfony\Contracts\HttpClient\ResponseInterface;
22+
23+
class NoPrivateNetworkHttpClientTest extends TestCase
24+
{
25+
public function getBlacklistData(): array
26+
{
27+
return [
28+
// private
29+
['0.0.0.1', null, true],
30+
['169.254.0.1', null, true],
31+
['127.0.0.1', null, true],
32+
['240.0.0.1', null, true],
33+
['10.0.0.1', null, true],
34+
['172.16.0.1', null, true],
35+
['192.168.0.1', null, true],
36+
['::1', null, true],
37+
['::ffff:0:1', null, true],
38+
['fe80::1', null, true],
39+
['fc00::1', null, true],
40+
['fd00::1', null, true],
41+
['10.0.0.1', '10.0.0.0/24', true],
42+
['10.0.0.1', '10.0.0.1', true],
43+
['fc00::1', 'fc00::1/120', true],
44+
['fc00::1', 'fc00::1', true],
45+
46+
['172.16.0.1', ['10.0.0.0/8', '192.168.0.0/16'], false],
47+
['fc00::1', ['fe80::/10', '::ffff:0:0/96'], false],
48+
49+
// public
50+
['104.26.14.6', null, false],
51+
['104.26.14.6', '104.26.14.0/24', true],
52+
['2606:4700:20::681a:e06', null, false],
53+
['2606:4700:20::681a:e06', '2606:4700:20::/43', true],
54+
55+
// no ipv4/ipv6 at all
56+
['2606:4700:20::681a:e06', '::/0', true],
57+
['104.26.14.6', '0.0.0.0/0', true],
58+
59+
// weird scenarios (e.g.: when trying to match ipv4 address on ipv6 subnet)
60+
['10.0.0.1', 'fc00::/7', false],
61+
['fc00::1', '10.0.0.0/8', false],
62+
];
63+
}
64+
65+
/**
66+
* @dataProvider getBlacklistData
67+
*/
68+
public function testBlacklist(string $ipAddr, $subnets, bool $mustThrow): void
69+
{
70+
$content = 'foo';
71+
$url = sprintf('http://%s/', 0 < substr_count($ipAddr, ':') ? sprintf('[%s]', $ipAddr) : $ipAddr);
72+
73+
if ($mustThrow) {
74+
$this->expectException(TransportException::class);
75+
$this->expectExceptionMessage(sprintf('IP "%s" is blacklisted for "%s".', $ipAddr, $url));
76+
}
77+
78+
$previousHttpClient = $this->getHttpClientMock($url, $ipAddr, $content);
79+
$client = new NoPrivateNetworkHttpClient($previousHttpClient, $subnets);
80+
$response = $client->request('GET', $url);
81+
82+
if (!$mustThrow) {
83+
$this->assertEquals($content, $response->getContent());
84+
$this->assertEquals(200, $response->getStatusCode());
85+
}
86+
}
87+
88+
public function testCustomOnProgressCallback()
89+
{
90+
$ipAddr = '104.26.14.6';
91+
$url = sprintf('http://%s/', $ipAddr);
92+
$content = 'foo';
93+
94+
$executionCount = 0;
95+
$customCallback = function (int $dlNow, int $dlSize, array $info) use (&$executionCount): void {
96+
++$executionCount;
97+
};
98+
99+
$previousHttpClient = $this->getHttpClientMock($url, $ipAddr, $content);
100+
$client = new NoPrivateNetworkHttpClient($previousHttpClient);
101+
$response = $client->request('GET', $url, ['on_progress' => $customCallback]);
102+
103+
$this->assertEquals(1, $executionCount);
104+
$this->assertEquals($content, $response->getContent());
105+
$this->assertEquals(200, $response->getStatusCode());
106+
}
107+
108+
public function testNonCallableOnProgressCallback()
109+
{
110+
$ipAddr = '104.26.14.6';
111+
$url = sprintf('http://%s/', $ipAddr);
112+
$content = 'bar';
113+
$customCallback = sprintf('cb_%s', microtime(true));
114+
115+
$this->expectException(InvalidArgumentException::class);
116+
$this->expectExceptionMessage('Option "on_progress" must be callable, string given.');
117+
118+
$client = new NoPrivateNetworkHttpClient(new MockHttpClient());
119+
$client->request('GET', $url, ['on_progress' => $customCallback]);
120+
}
121+
122+
public function testConstructor()
123+
{
124+
$this->expectException(\TypeError::class);
125+
$this->expectExceptionMessage('Argument 2 passed to Symfony\Component\HttpClient\NoPrivateNetworkHttpClient::__construct() must be of the type array, string or null. integer given.');
126+
127+
new NoPrivateNetworkHttpClient(new MockHttpClient(), 3);
128+
}
129+
130+
private function getHttpClientMock(string $url, string $ipAddr, string $content)
131+
{
132+
$previousHttpClient = $this
133+
->getMockBuilder(HttpClientInterface::class)
134+
->getMock();
135+
136+
$previousHttpClient
137+
->expects($this->once())
138+
->method('request')
139+
->with(
140+
'GET',
141+
$url,
142+
$this->callback(function ($options) {
143+
$this->assertArrayHasKey('on_progress', $options);
144+
$onProgress = $options['on_progress'];
145+
$this->assertIsCallable($onProgress);
146+
147+
return true;
148+
})
149+
)
150+
->willReturnCallback(function ($method, $url, $options) use ($ipAddr, $content): ResponseInterface {
151+
$info = [
152+
'primary_ip' => $ipAddr,
153+
'url' => $url,
154+
];
155+
156+
$onProgress = $options['on_progress'];
157+
$onProgress(0, 0, $info);
158+
159+
return MockResponse::fromRequest($method, $url, [], new MockResponse($content));
160+
});
161+
162+
return $previousHttpClient;
163+
}
164+
}

0 commit comments

Comments
 (0)
0