Skip to content

Commit b9159af

Browse files
committed
[HttpClient] adding NoPrivateNetworkHttpClient decorator
1 parent fa358e6 commit b9159af

File tree

3 files changed

+276
-0
lines changed

3 files changed

+276
-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: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
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 block requests to private network by default.
25+
*
26+
* @author Hallison Boaventura <[email protected]>
27+
*/
28+
final class NoPrivateNetworkHttpClient implements HttpClientInterface, LoggerAwareInterface
29+
{
30+
use HttpClientTrait;
31+
32+
const IPV4_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+
];
41+
42+
const IPV6_PRIVATE_SUBNETS = [
43+
'::1/128',
44+
'fc00::/7',
45+
'fe80::/10',
46+
'::ffff:0:0/96',
47+
'::/128',
48+
];
49+
50+
/**
51+
* @var HttpClientInterface
52+
*/
53+
private $client;
54+
55+
/**
56+
* Subnets in CIDR notation that IpUtils will make use of.
57+
*
58+
* @var string|array|null
59+
*/
60+
private $subnets;
61+
62+
/**
63+
* Constructor.
64+
*
65+
* @param HttpClientInterface $client A HttpClientInterface instance.
66+
* @param string|array|null $subnets String or array of subnets using CIDR notation that will be used by IpUtils.
67+
* If its value is null, then default private subnets will be used.
68+
*/
69+
public function __construct(HttpClientInterface $client, $subnets = null)
70+
{
71+
$this->client = $client;
72+
73+
if (!(\is_string($subnets) || \is_array($subnets) || null === $subnets)) {
74+
throw new InvalidArgumentException('$subnets argument must be string, array or null.');
75+
}
76+
77+
$this->subnets = $subnets;
78+
}
79+
80+
/**
81+
* {@inheritdoc}
82+
*/
83+
public function request(string $method, string $url, array $options = []): ResponseInterface
84+
{
85+
$subnets = $this->subnets;
86+
87+
$lastPrimaryIp = '';
88+
$onProgress = $options['on_progress'] ?? null;
89+
90+
$options['on_progress'] = function (int $dlNow, int $dlSize, array $info) use ($onProgress, $subnets, &$lastPrimaryIp): void {
91+
if ($info['primary_ip'] !== $lastPrimaryIp) {
92+
if (
93+
(null !== $subnets && IpUtils::checkIp($info['primary_ip'], $subnets)) ||
94+
(null === $subnets && (
95+
(0 < substr_count($info['primary_ip'], '.') && IpUtils::checkIp($info['primary_ip'], self::IPV4_PRIVATE_SUBNETS)) ||
96+
(0 < substr_count($info['primary_ip'], ':') && IpUtils::checkIp($info['primary_ip'], self::IPV6_PRIVATE_SUBNETS))
97+
))
98+
) {
99+
throw new TransportException(sprintf('IP "%s" is blacklisted.', $info['primary_ip']));
100+
}
101+
102+
$lastPrimaryIp = $info['primary_ip'];
103+
}
104+
105+
\is_callable($onProgress) && $onProgress($dlNow, $dlSize, $info);
106+
};
107+
108+
return $this->client->request($method, $url, $options);
109+
}
110+
111+
/**
112+
* {@inheritdoc}
113+
*/
114+
public function stream($responses, float $timeout = null): ResponseStreamInterface
115+
{
116+
return $this->client->stream($responses, $timeout);
117+
}
118+
119+
/**
120+
* {@inheritdoc}
121+
*/
122+
public function setLogger(LoggerInterface $logger): void
123+
{
124+
if ($this->client instanceof LoggerAwareInterface) {
125+
$this->client->setLogger($logger);
126+
}
127+
}
128+
}
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
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\NoPrivateNetworkHttpClient;
18+
use Symfony\Component\HttpClient\Response\MockResponse;
19+
use Symfony\Contracts\HttpClient\HttpClientInterface;
20+
use Symfony\Contracts\HttpClient\ResponseInterface;
21+
22+
class NoPrivateNetworkHttpClientTest extends TestCase
23+
{
24+
public function getBlacklistData(): array
25+
{
26+
return [
27+
// private
28+
['0.0.0.1', null, true],
29+
['169.254.0.1', null, true],
30+
['127.0.0.1', null, true],
31+
['240.0.0.1', null, true],
32+
['10.0.0.1', null, true],
33+
['172.16.0.1', null, true],
34+
['192.168.0.1', null, true],
35+
['::1', null, true],
36+
['::ffff:0:1', null, true],
37+
['fe80::1', null, true],
38+
['fc00::1', null, true],
39+
['fd00::1', null, true],
40+
['10.0.0.1', '10.0.0.0/24', true],
41+
['10.0.0.1', '10.0.0.1', true],
42+
['fc00::1', 'fc00::1/120', true],
43+
['fc00::1', 'fc00::1', true],
44+
45+
['172.16.0.1', ['10.0.0.0/8', '192.168.0.0/16'], false],
46+
['fc00::1', ['fe80::/10', '::ffff:0:0/96'], false],
47+
48+
// public
49+
['104.26.14.6', null, false],
50+
['104.26.14.6', '104.26.14.0/24', true],
51+
['2606:4700:20::681a:e06', null, false],
52+
['2606:4700:20::681a:e06', '2606:4700:20::/43', true],
53+
54+
// no ipv4/ipv6 at all
55+
['2606:4700:20::681a:e06', '::/0', true],
56+
['104.26.14.6', '0.0.0.0/0', true],
57+
58+
// weird scenarios (e.g.: when trying to match ipv4 address on ipv6 subnet)
59+
['10.0.0.1', 'fc00::/7', false],
60+
['fc00::1', '10.0.0.0/8', false],
61+
];
62+
}
63+
64+
/**
65+
* @dataProvider getBlacklistData
66+
*/
67+
public function testBlacklist(string $ipAddr, $subnets, bool $mustThrow): void
68+
{
69+
if ($mustThrow) {
70+
$this->expectException(TransportException::class);
71+
$this->expectExceptionMessage(sprintf('IP "%s" is blacklisted.', $ipAddr));
72+
}
73+
74+
$content = 'foo';
75+
$url = sprintf('http://%s/', 0 < substr_count($ipAddr, ':') ? sprintf('[%s]', $ipAddr) : $ipAddr);
76+
77+
$previousHttpClient = $this->getHttpClientMock($url, $ipAddr, $content);
78+
$client = new NoPrivateNetworkHttpClient($previousHttpClient, $subnets);
79+
$response = $client->request('GET', $url);
80+
81+
if (!$mustThrow) {
82+
$this->assertEquals($content, $response->getContent());
83+
$this->assertEquals(200, $response->getStatusCode());
84+
}
85+
}
86+
87+
public function testCustomOnProgressCallback()
88+
{
89+
$ipAddr = '104.26.14.6';
90+
$url = sprintf('http://%s/', $ipAddr);
91+
$content = 'foo';
92+
$executionCount = 0;
93+
$customCallback = function (int $dlNow, int $dlSize, array $info) use (&$executionCount): void {
94+
++$executionCount;
95+
};
96+
97+
$previousHttpClient = $this->getHttpClientMock($url, $ipAddr, $content);
98+
$client = new NoPrivateNetworkHttpClient($previousHttpClient);
99+
$response = $client->request('GET', $url, ['on_progress' => $customCallback]);
100+
101+
$this->assertEquals(1, $executionCount);
102+
$this->assertEquals($content, $response->getContent());
103+
$this->assertEquals(200, $response->getStatusCode());
104+
}
105+
106+
public function testConstructor()
107+
{
108+
$this->expectException(InvalidArgumentException::class);
109+
$this->expectExceptionMessage('$subnets argument must be string, array or null.');
110+
111+
$previousHttpClient = $this->getMockBuilder(HttpClientInterface::class)
112+
->getMock();
113+
114+
new NoPrivateNetworkHttpClient($previousHttpClient, 3);
115+
}
116+
117+
private function getHttpClientMock(string $url, string $ipAddr, string $content)
118+
{
119+
$previousHttpClient = $this
120+
->getMockBuilder(HttpClientInterface::class)
121+
->getMock();
122+
123+
$previousHttpClient
124+
->expects($this->once())
125+
->method('request')
126+
->with(
127+
'GET',
128+
$url,
129+
$this->callback(function ($options) {
130+
$this->assertArrayHasKey('on_progress', $options);
131+
$onProgress = $options['on_progress'];
132+
$this->assertIsCallable($onProgress);
133+
134+
return true;
135+
})
136+
)
137+
->willReturnCallback(function ($method, $url, $options) use ($ipAddr, $content): ResponseInterface {
138+
$onProgress = $options['on_progress'];
139+
$info = ['primary_ip' => $ipAddr];
140+
$onProgress(0, 0, $info);
141+
142+
return MockResponse::fromRequest($method, $url, [], new MockResponse($content));
143+
});
144+
145+
return $previousHttpClient;
146+
}
147+
}

0 commit comments

Comments
 (0)