Skip to content

Commit 5ce5528

Browse files
Merge branch '7.2' into 7.3
* 7.2: [HttpClient] Fix checking for private IPs before connecting
2 parents 6f8f122 + 659cc96 commit 5ce5528

10 files changed

+251
-84
lines changed

src/Symfony/Component/HttpClient/NativeHttpClient.php

Lines 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

Lines 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

Lines 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

Lines 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

Lines 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

Lines 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

Lines 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)