Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Appearance settings

Commit 659cc96

Browse filesBrowse files
Merge branch '7.1' into 7.2
* 7.1: [HttpClient] Fix checking for private IPs before connecting
2 parents bffd7b8 + 4c69288 commit 659cc96
Copy full SHA for 659cc96
Expand file treeCollapse file tree

10 files changed

+251
-84
lines changed

‎src/Symfony/Component/HttpClient/NativeHttpClient.php

Copy file name to clipboardExpand all lines: src/Symfony/Component/HttpClient/NativeHttpClient.php
+1-10Lines changed: 1 addition & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -140,22 +140,13 @@ public function request(string $method, string $url, array $options = []): Respo
140140

141141
if ($onProgress = $options['on_progress']) {
142142
$maxDuration = 0 < $options['max_duration'] ? $options['max_duration'] : \INF;
143-
$multi = $this->multi;
144-
$resolve = static function (string $host, ?string $ip = null) use ($multi): ?string {
145-
if (null !== $ip) {
146-
$multi->dnsCache[$host] = $ip;
147-
}
148-
149-
return $multi->dnsCache[$host] ?? null;
150-
};
151-
$onProgress = static function (...$progress) use ($onProgress, &$info, $maxDuration, $resolve) {
143+
$onProgress = static function (...$progress) use ($onProgress, &$info, $maxDuration) {
152144
if ($info['total_time'] >= $maxDuration) {
153145
throw new TransportException(\sprintf('Max duration was reached for "%s".', implode('', $info['url'])));
154146
}
155147

156148
$progressInfo = $info;
157149
$progressInfo['url'] = implode('', $info['url']);
158-
$progressInfo['resolve'] = $resolve;
159150
unset($progressInfo['size_body']);
160151

161152
// Memoize the last progress to ease calling the callback periodically when no network transfer happens

‎src/Symfony/Component/HttpClient/NoPrivateNetworkHttpClient.php

Copy file name to clipboardExpand all lines: src/Symfony/Component/HttpClient/NoPrivateNetworkHttpClient.php
+161-33Lines changed: 161 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,11 @@
1313

1414
use Psr\Log\LoggerAwareInterface;
1515
use Psr\Log\LoggerInterface;
16-
use Symfony\Component\HttpClient\Exception\InvalidArgumentException;
1716
use Symfony\Component\HttpClient\Exception\TransportException;
17+
use Symfony\Component\HttpClient\Response\AsyncContext;
18+
use Symfony\Component\HttpClient\Response\AsyncResponse;
1819
use Symfony\Component\HttpFoundation\IpUtils;
20+
use Symfony\Contracts\HttpClient\ChunkInterface;
1921
use Symfony\Contracts\HttpClient\HttpClientInterface;
2022
use Symfony\Contracts\HttpClient\ResponseInterface;
2123
use Symfony\Contracts\HttpClient\ResponseStreamInterface;
@@ -25,68 +27,135 @@
2527
* Decorator that blocks requests to private networks by default.
2628
*
2729
* @author Hallison Boaventura <hallisonboaventura@gmail.com>
30+
* @author Nicolas Grekas <p@tchwork.com>
2831
*/
2932
final class NoPrivateNetworkHttpClient implements HttpClientInterface, LoggerAwareInterface, ResetInterface
3033
{
3134
use HttpClientTrait;
35+
use AsyncDecoratorTrait;
36+
37+
private array $defaultOptions = self::OPTIONS_DEFAULTS;
38+
private HttpClientInterface $client;
39+
private array|null $subnets;
40+
private int $ipFlags;
41+
private \ArrayObject $dnsCache;
3242

3343
/**
34-
* @param string|array|null $subnets String or array of subnets using CIDR notation that will be used by IpUtils.
44+
* @param string|array|null $subnets String or array of subnets using CIDR notation that should be considered private.
3545
* If null is passed, the standard private subnets will be used.
3646
*/
37-
public function __construct(
38-
private HttpClientInterface $client,
39-
private string|array|null $subnets = null,
40-
) {
47+
public function __construct(HttpClientInterface $client, string|array|null $subnets = null)
48+
{
4149
if (!class_exists(IpUtils::class)) {
4250
throw new \LogicException(\sprintf('You cannot use "%s" if the HttpFoundation component is not installed. Try running "composer require symfony/http-foundation".', __CLASS__));
4351
}
52+
53+
if (null === $subnets) {
54+
$ipFlags = \FILTER_FLAG_IPV4 | \FILTER_FLAG_IPV6;
55+
} else {
56+
$ipFlags = 0;
57+
foreach ((array) $subnets as $subnet) {
58+
$ipFlags |= str_contains($subnet, ':') ? \FILTER_FLAG_IPV6 : \FILTER_FLAG_IPV4;
59+
}
60+
}
61+
62+
if (!\defined('STREAM_PF_INET6')) {
63+
$ipFlags &= ~\FILTER_FLAG_IPV6;
64+
}
65+
66+
$this->client = $client;
67+
$this->subnets = null !== $subnets ? (array) $subnets : null;
68+
$this->ipFlags = $ipFlags;
69+
$this->dnsCache = new \ArrayObject();
4470
}
4571

4672
public function request(string $method, string $url, array $options = []): ResponseInterface
4773
{
48-
$onProgress = $options['on_progress'] ?? null;
49-
if (null !== $onProgress && !\is_callable($onProgress)) {
50-
throw new InvalidArgumentException(\sprintf('Option "on_progress" must be callable, "%s" given.', get_debug_type($onProgress)));
74+
[$url, $options] = self::prepareRequest($method, $url, $options, $this->defaultOptions, true);
75+
76+
$redirectHeaders = parse_url($url['authority']);
77+
$host = $redirectHeaders['host'];
78+
$url = implode('', $url);
79+
$dnsCache = $this->dnsCache;
80+
81+
$ip = self::dnsResolve($dnsCache, $host, $this->ipFlags, $options);
82+
self::ipCheck($ip, $this->subnets, $this->ipFlags, $host, $url);
83+
84+
if (0 < $maxRedirects = $options['max_redirects']) {
85+
$options['max_redirects'] = 0;
86+
$redirectHeaders['with_auth'] = $redirectHeaders['no_auth'] = $options['headers'];
87+
88+
if (isset($options['normalized_headers']['host']) || isset($options['normalized_headers']['authorization']) || isset($options['normalized_headers']['cookie'])) {
89+
$redirectHeaders['no_auth'] = array_filter($redirectHeaders['no_auth'], static function ($h) {
90+
return 0 !== stripos($h, 'Host:') && 0 !== stripos($h, 'Authorization:') && 0 !== stripos($h, 'Cookie:');
91+
});
92+
}
5193
}
5294

95+
$onProgress = $options['on_progress'] ?? null;
5396
$subnets = $this->subnets;
97+
$ipFlags = $this->ipFlags;
5498

55-
$options['on_progress'] = static function (int $dlNow, int $dlSize, array $info) use ($onProgress, $subnets): void {
56-
static $lastUrl = '';
99+
$options['on_progress'] = static function (int $dlNow, int $dlSize, array $info) use ($onProgress, $subnets, $ipFlags): void {
57100
static $lastPrimaryIp = '';
58101

59-
if ($info['url'] !== $lastUrl) {
60-
$host = parse_url($info['url'], PHP_URL_HOST) ?: '';
61-
$resolve = $info['resolve'] ?? static function () { return null; };
102+
if (($info['primary_ip'] ?? '') !== $lastPrimaryIp) {
103+
self::ipCheck($info['primary_ip'], $subnets, $ipFlags, null, $info['url']);
104+
$lastPrimaryIp = $info['primary_ip'];
105+
}
62106

63-
if (($ip = trim($host, '[]'))
64-
&& !filter_var($ip, \FILTER_VALIDATE_IP)
65-
&& !($ip = $resolve($host))
66-
&& $ip = @(gethostbynamel($host)[0] ?? dns_get_record($host, \DNS_AAAA)[0]['ipv6'] ?? null)
67-
) {
68-
$resolve($host, $ip);
69-
}
107+
null !== $onProgress && $onProgress($dlNow, $dlSize, $info);
108+
};
70109

71-
if ($ip && IpUtils::checkIp($ip, $subnets ?? IpUtils::PRIVATE_SUBNETS)) {
72-
throw new TransportException(sprintf('Host "%s" is blocked for "%s".', $host, $info['url']));
73-
}
110+
return new AsyncResponse($this->client, $method, $url, $options, static function (ChunkInterface $chunk, AsyncContext $context) use (&$method, &$options, $maxRedirects, &$redirectHeaders, $subnets, $ipFlags, $dnsCache): \Generator {
111+
if (null !== $chunk->getError() || $chunk->isTimeout() || !$chunk->isFirst()) {
112+
yield $chunk;
74113

75-
$lastUrl = $info['url'];
114+
return;
76115
}
77116

78-
if ($info['primary_ip'] !== $lastPrimaryIp) {
79-
if ($info['primary_ip'] && IpUtils::checkIp($info['primary_ip'], $subnets ?? IpUtils::PRIVATE_SUBNETS)) {
80-
throw new TransportException(\sprintf('IP "%s" is blocked for "%s".', $info['primary_ip'], $info['url']));
81-
}
117+
$statusCode = $context->getStatusCode();
82118

83-
$lastPrimaryIp = $info['primary_ip'];
119+
if ($statusCode < 300 || 400 <= $statusCode || null === $url = $context->getInfo('redirect_url')) {
120+
$context->passthru();
121+
122+
yield $chunk;
123+
124+
return;
84125
}
85126

86-
null !== $onProgress && $onProgress($dlNow, $dlSize, $info);
87-
};
127+
$host = parse_url($url, \PHP_URL_HOST);
128+
$ip = self::dnsResolve($dnsCache, $host, $ipFlags, $options);
129+
self::ipCheck($ip, $subnets, $ipFlags, $host, $url);
130+
131+
// Do like curl and browsers: turn POST to GET on 301, 302 and 303
132+
if (303 === $statusCode || 'POST' === $method && \in_array($statusCode, [301, 302], true)) {
133+
$method = 'HEAD' === $method ? 'HEAD' : 'GET';
134+
unset($options['body'], $options['json']);
135+
136+
if (isset($options['normalized_headers']['content-length']) || isset($options['normalized_headers']['content-type']) || isset($options['normalized_headers']['transfer-encoding'])) {
137+
$filterContentHeaders = static function ($h) {
138+
return 0 !== stripos($h, 'Content-Length:') && 0 !== stripos($h, 'Content-Type:') && 0 !== stripos($h, 'Transfer-Encoding:');
139+
};
140+
$options['header'] = array_filter($options['header'], $filterContentHeaders);
141+
$redirectHeaders['no_auth'] = array_filter($redirectHeaders['no_auth'], $filterContentHeaders);
142+
$redirectHeaders['with_auth'] = array_filter($redirectHeaders['with_auth'], $filterContentHeaders);
143+
}
144+
}
88145

89-
return $this->client->request($method, $url, $options);
146+
// Authorization and Cookie headers MUST NOT follow except for the initial host name
147+
$port = parse_url($url, \PHP_URL_PORT);
148+
$options['headers'] = $redirectHeaders['host'] === $host && ($redirectHeaders['port'] ?? null) === $port ? $redirectHeaders['with_auth'] : $redirectHeaders['no_auth'];
149+
150+
static $redirectCount = 0;
151+
$context->setInfo('redirect_count', ++$redirectCount);
152+
153+
$context->replaceRequest($method, $url, $options);
154+
155+
if ($redirectCount >= $maxRedirects) {
156+
$context->passthru();
157+
}
158+
});
90159
}
91160

92161
public function stream(ResponseInterface|iterable $responses, ?float $timeout = null): ResponseStreamInterface
@@ -110,14 +179,73 @@ public function withOptions(array $options): static
110179
{
111180
$clone = clone $this;
112181
$clone->client = $this->client->withOptions($options);
182+
$clone->defaultOptions = self::mergeDefaultOptions($options, $this->defaultOptions);
113183

114184
return $clone;
115185
}
116186

117187
public function reset(): void
118188
{
189+
$this->dnsCache->exchangeArray([]);
190+
119191
if ($this->client instanceof ResetInterface) {
120192
$this->client->reset();
121193
}
122194
}
195+
196+
private static function dnsResolve(\ArrayObject $dnsCache, string $host, int $ipFlags, array &$options): string
197+
{
198+
if ($ip = filter_var(trim($host, '[]'), \FILTER_VALIDATE_IP) ?: $options['resolve'][$host] ?? false) {
199+
return $ip;
200+
}
201+
202+
if ($dnsCache->offsetExists($host)) {
203+
return $dnsCache[$host];
204+
}
205+
206+
if ((\FILTER_FLAG_IPV4 & $ipFlags) && $ip = gethostbynamel($host)) {
207+
return $options['resolve'][$host] = $dnsCache[$host] = $ip[0];
208+
}
209+
210+
if (!(\FILTER_FLAG_IPV6 & $ipFlags)) {
211+
return $host;
212+
}
213+
214+
if ($ip = dns_get_record($host, \DNS_AAAA)) {
215+
$ip = $ip[0]['ipv6'];
216+
} elseif (extension_loaded('sockets')) {
217+
if (!$info = socket_addrinfo_lookup($host, 0, ['ai_socktype' => \SOCK_STREAM, 'ai_family' => \AF_INET6])) {
218+
return $host;
219+
}
220+
221+
$ip = socket_addrinfo_explain($info[0])['ai_addr']['sin6_addr'];
222+
} elseif ('localhost' === $host || 'localhost.' === $host) {
223+
$ip = '::1';
224+
} else {
225+
return $host;
226+
}
227+
228+
return $options['resolve'][$host] = $dnsCache[$host] = $ip;
229+
}
230+
231+
private static function ipCheck(string $ip, ?array $subnets, int $ipFlags, ?string $host, string $url): void
232+
{
233+
if (null === $subnets) {
234+
// Quick check, but not reliable enough, see https://github.com/php/php-src/issues/16944
235+
$ipFlags |= \FILTER_FLAG_NO_PRIV_RANGE | \FILTER_FLAG_NO_RES_RANGE;
236+
}
237+
238+
if (false !== filter_var($ip, \FILTER_VALIDATE_IP, $ipFlags) && !IpUtils::checkIp($ip, $subnets ?? IpUtils::PRIVATE_SUBNETS)) {
239+
return;
240+
}
241+
242+
if (null !== $host) {
243+
$type = 'Host';
244+
} else {
245+
$host = $ip;
246+
$type = 'IP';
247+
}
248+
249+
throw new TransportException($type.\sprintf(' "%s" is blocked for "%s".', $host, $url));
250+
}
123251
}

‎src/Symfony/Component/HttpClient/Response/AmpResponseV4.php

Copy file name to clipboardExpand all lines: src/Symfony/Component/HttpClient/Response/AmpResponseV4.php
+1-9Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -90,17 +90,9 @@ public function __construct(
9090
$info['max_duration'] = $options['max_duration'];
9191
$info['debug'] = '';
9292

93-
$resolve = static function (string $host, ?string $ip = null) use ($multi): ?string {
94-
if (null !== $ip) {
95-
$multi->dnsCache[$host] = $ip;
96-
}
97-
98-
return $multi->dnsCache[$host] ?? null;
99-
};
10093
$onProgress = $options['on_progress'] ?? static function () {};
101-
$onProgress = $this->onProgress = static function () use (&$info, $onProgress, $resolve) {
94+
$onProgress = $this->onProgress = static function () use (&$info, $onProgress) {
10295
$info['total_time'] = microtime(true) - $info['start_time'];
103-
$info['resolve'] = $resolve;
10496
$onProgress((int) $info['size_download'], ((int) (1 + $info['download_content_length']) ?: 1) - 1, (array) $info);
10597
};
10698

‎src/Symfony/Component/HttpClient/Response/AmpResponseV5.php

Copy file name to clipboardExpand all lines: src/Symfony/Component/HttpClient/Response/AmpResponseV5.php
+1-9Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -89,17 +89,9 @@ public function __construct(
8989
$info['max_duration'] = $options['max_duration'];
9090
$info['debug'] = '';
9191

92-
$resolve = static function (string $host, ?string $ip = null) use ($multi): ?string {
93-
if (null !== $ip) {
94-
$multi->dnsCache[$host] = $ip;
95-
}
96-
97-
return $multi->dnsCache[$host] ?? null;
98-
};
9992
$onProgress = $options['on_progress'] ?? static function () {};
100-
$onProgress = $this->onProgress = static function () use (&$info, $onProgress, $resolve) {
93+
$onProgress = $this->onProgress = static function () use (&$info, $onProgress) {
10194
$info['total_time'] = microtime(true) - $info['start_time'];
102-
$info['resolve'] = $resolve;
10395
$onProgress((int) $info['size_download'], ((int) (1 + $info['download_content_length']) ?: 1) - 1, (array) $info);
10496
};
10597

‎src/Symfony/Component/HttpClient/Response/CurlResponse.php

Copy file name to clipboardExpand all lines: src/Symfony/Component/HttpClient/Response/CurlResponse.php
+2-9Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -122,20 +122,13 @@ public function __construct(
122122
curl_pause($ch, \CURLPAUSE_CONT);
123123

124124
if ($onProgress = $options['on_progress']) {
125-
$resolve = static function (string $host, ?string $ip = null) use ($multi): ?string {
126-
if (null !== $ip) {
127-
$multi->dnsCache->hostnames[$host] = $ip;
128-
}
129-
130-
return $multi->dnsCache->hostnames[$host] ?? null;
131-
};
132125
$url = isset($info['url']) ? ['url' => $info['url']] : [];
133126
curl_setopt($ch, \CURLOPT_NOPROGRESS, false);
134-
curl_setopt($ch, \CURLOPT_PROGRESSFUNCTION, static function ($ch, $dlSize, $dlNow) use ($onProgress, &$info, $url, $multi, $debugBuffer, $resolve) {
127+
curl_setopt($ch, \CURLOPT_PROGRESSFUNCTION, static function ($ch, $dlSize, $dlNow) use ($onProgress, &$info, $url, $multi, $debugBuffer) {
135128
try {
136129
rewind($debugBuffer);
137130
$debug = ['debug' => stream_get_contents($debugBuffer)];
138-
$onProgress($dlNow, $dlSize, $url + curl_getinfo($ch) + $info + $debug + ['resolve' => $resolve]);
131+
$onProgress($dlNow, $dlSize, $url + curl_getinfo($ch) + $info + $debug);
139132
} catch (\Throwable $e) {
140133
$multi->handlesActivity[(int) $ch][] = null;
141134
$multi->handlesActivity[(int) $ch][] = $e;

‎src/Symfony/Component/HttpClient/Tests/AmpHttpClientTest.php

Copy file name to clipboardExpand all lines: src/Symfony/Component/HttpClient/Tests/AmpHttpClientTest.php
+3Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@
1414
use Symfony\Component\HttpClient\AmpHttpClient;
1515
use Symfony\Contracts\HttpClient\HttpClientInterface;
1616

17+
/**
18+
* @group dns-sensitive
19+
*/
1720
class AmpHttpClientTest extends HttpClientTestCase
1821
{
1922
protected function getHttpClient(string $testCase): HttpClientInterface

‎src/Symfony/Component/HttpClient/Tests/CurlHttpClientTest.php

Copy file name to clipboardExpand all lines: src/Symfony/Component/HttpClient/Tests/CurlHttpClientTest.php
+1Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717

1818
/**
1919
* @requires extension curl
20+
* @group dns-sensitive
2021
*/
2122
class CurlHttpClientTest extends HttpClientTestCase
2223
{

0 commit comments

Comments
0 (0)
Morty Proxy This is a proxified and sanitized view of the page, visit original site.