1313
1414use Psr \Log \LoggerAwareInterface ;
1515use Psr \Log \LoggerInterface ;
16- use Symfony \Component \HttpClient \Exception \InvalidArgumentException ;
1716use Symfony \Component \HttpClient \Exception \TransportException ;
17+ use Symfony \Component \HttpClient \Response \AsyncContext ;
18+ use Symfony \Component \HttpClient \Response \AsyncResponse ;
1819use Symfony \Component \HttpFoundation \IpUtils ;
20+ use Symfony \Contracts \HttpClient \ChunkInterface ;
1921use Symfony \Contracts \HttpClient \HttpClientInterface ;
2022use Symfony \Contracts \HttpClient \ResponseInterface ;
2123use Symfony \Contracts \HttpClient \ResponseStreamInterface ;
2527 * Decorator that blocks requests to private networks by default.
2628 *
2729 * @author Hallison Boaventura <[email protected] > 30+ * @author Nicolas Grekas <[email protected] > 2831 */
2932final class NoPrivateNetworkHttpClient implements HttpClientInterface, LoggerAwareInterface, ResetInterface
3033{
3134 use HttpClientTrait;
35+ use AsyncDecoratorTrait;
3236
3337 private const PRIVATE_SUBNETS = [
3438 '127.0.0.0/8 ' ,
@@ -45,11 +49,14 @@ final class NoPrivateNetworkHttpClient implements HttpClientInterface, LoggerAwa
4549 '::/128 ' ,
4650 ];
4751
52+ private $ defaultOptions = self ::OPTIONS_DEFAULTS ;
4853 private $ client ;
4954 private $ subnets ;
55+ private $ ipFlags ;
56+ private $ dnsCache ;
5057
5158 /**
52- * @param string|array|null $subnets String or array of subnets using CIDR notation that will be used by IpUtils .
59+ * @param string|array|null $subnets String or array of subnets using CIDR notation that should be considered private .
5360 * If null is passed, the standard private subnets will be used.
5461 */
5562 public function __construct (HttpClientInterface $ client , $ subnets = null )
@@ -62,56 +69,113 @@ public function __construct(HttpClientInterface $client, $subnets = null)
6269 throw new \LogicException (sprintf ('You cannot use "%s" if the HttpFoundation component is not installed. Try running "composer require symfony/http-foundation". ' , __CLASS__ ));
6370 }
6471
72+ if (null === $ subnets ) {
73+ $ ipFlags = \FILTER_FLAG_IPV4 | \FILTER_FLAG_IPV6 ;
74+ } else {
75+ $ ipFlags = 0 ;
76+ foreach ((array ) $ subnets as $ subnet ) {
77+ $ ipFlags |= str_contains ($ subnet , ': ' ) ? \FILTER_FLAG_IPV6 : \FILTER_FLAG_IPV4 ;
78+ }
79+ }
80+
81+ if (!\defined ('STREAM_PF_INET6 ' )) {
82+ $ ipFlags &= ~\FILTER_FLAG_IPV6 ;
83+ }
84+
6585 $ this ->client = $ client ;
66- $ this ->subnets = $ subnets ;
86+ $ this ->subnets = null !== $ subnets ? (array ) $ subnets : null ;
87+ $ this ->ipFlags = $ ipFlags ;
88+ $ this ->dnsCache = new \ArrayObject ();
6789 }
6890
6991 /**
7092 * {@inheritdoc}
7193 */
7294 public function request (string $ method , string $ url , array $ options = []): ResponseInterface
7395 {
74- $ onProgress = $ options ['on_progress ' ] ?? null ;
75- if (null !== $ onProgress && !\is_callable ($ onProgress )) {
76- throw new InvalidArgumentException (sprintf ('Option "on_progress" must be callable, "%s" given. ' , get_debug_type ($ onProgress )));
77- }
96+ [$ url , $ options ] = self ::prepareRequest ($ method , $ url , $ options , $ this ->defaultOptions , true );
7897
79- $ subnets = $ this ->subnets ;
80- $ lastUrl = '' ;
81- $ lastPrimaryIp = '' ;
98+ $ redirectHeaders = parse_url ($ url ['authority ' ]);
99+ $ host = $ redirectHeaders ['host ' ];
100+ $ url = implode ('' , $ url );
101+ $ dnsCache = $ this ->dnsCache ;
82102
83- $ options ['on_progress ' ] = function (int $ dlNow , int $ dlSize , array $ info ) use ($ onProgress , $ subnets , &$ lastUrl , &$ lastPrimaryIp ): void {
84- if ($ info ['url ' ] !== $ lastUrl ) {
85- $ host = parse_url ($ info ['url ' ], PHP_URL_HOST ) ?: '' ;
86- $ resolve = $ info ['resolve ' ] ?? static function () { return null ; };
87-
88- if (($ ip = trim ($ host , '[] ' ))
89- && !filter_var ($ ip , \FILTER_VALIDATE_IP )
90- && !($ ip = $ resolve ($ host ))
91- && $ ip = @(gethostbynamel ($ host )[0 ] ?? dns_get_record ($ host , \DNS_AAAA )[0 ]['ipv6 ' ] ?? null )
92- ) {
93- $ resolve ($ host , $ ip );
94- }
103+ $ ip = self ::dnsResolve ($ dnsCache , $ host , $ this ->ipFlags , $ options );
104+ self ::ipCheck ($ ip , $ this ->subnets , $ this ->ipFlags , $ host , $ url );
95105
96- if ($ ip && IpUtils:: checkIp ( $ ip , $ subnets ?? self :: PRIVATE_SUBNETS ) ) {
97- throw new TransportException ( sprintf ( ' Host "%s" is blocked for "%s". ' , $ host , $ info [ ' url ' ])) ;
98- }
106+ if (0 < $ maxRedirects = $ options [ ' max_redirects ' ] ) {
107+ $ options [ ' max_redirects ' ] = 0 ;
108+ $ redirectHeaders [ ' with_auth ' ] = $ redirectHeaders [ ' no_auth ' ] = $ options [ ' headers ' ];
99109
100- $ lastUrl = $ info ['url ' ];
110+ if (isset ($ options ['normalized_headers ' ]['host ' ]) || isset ($ options ['normalized_headers ' ]['authorization ' ]) || isset ($ options ['normalized_headers ' ]['cookie ' ])) {
111+ $ redirectHeaders ['no_auth ' ] = array_filter ($ redirectHeaders ['no_auth ' ], static function ($ h ) {
112+ return 0 !== stripos ($ h , 'Host: ' ) && 0 !== stripos ($ h , 'Authorization: ' ) && 0 !== stripos ($ h , 'Cookie: ' );
113+ });
101114 }
115+ }
102116
103- if ( $ info [ ' primary_ip ' ] !== $ lastPrimaryIp ) {
104- if ( $ info [ ' primary_ip ' ] && IpUtils:: checkIp ( $ info [ ' primary_ip ' ], $ subnets ?? self :: PRIVATE_SUBNETS )) {
105- throw new TransportException ( sprintf ( ' IP "%s" is blocked for "%s". ' , $ info [ ' primary_ip ' ], $ info [ ' url ' ])) ;
106- }
117+ $ onProgress = $ options [ ' on_progress ' ] ?? null ;
118+ $ subnets = $ this -> subnets ;
119+ $ ipFlags = $ this -> ipFlags ;
120+ $ lastPrimaryIp = '' ;
107121
122+ $ options ['on_progress ' ] = static function (int $ dlNow , int $ dlSize , array $ info ) use ($ onProgress , $ subnets , $ ipFlags , &$ lastPrimaryIp ): void {
123+ if (($ info ['primary_ip ' ] ?? '' ) !== $ lastPrimaryIp ) {
124+ self ::ipCheck ($ info ['primary_ip ' ], $ subnets , $ ipFlags , null , $ info ['url ' ]);
108125 $ lastPrimaryIp = $ info ['primary_ip ' ];
109126 }
110127
111128 null !== $ onProgress && $ onProgress ($ dlNow , $ dlSize , $ info );
112129 };
113130
114- return $ this ->client ->request ($ method , $ url , $ options );
131+ return new AsyncResponse ($ this ->client , $ method , $ url , $ options , static function (ChunkInterface $ chunk , AsyncContext $ context ) use (&$ method , &$ options , $ maxRedirects , &$ redirectHeaders , $ subnets , $ ipFlags , $ dnsCache ): \Generator {
132+ if (null !== $ chunk ->getError () || $ chunk ->isTimeout () || !$ chunk ->isFirst ()) {
133+ yield $ chunk ;
134+
135+ return ;
136+ }
137+
138+ $ statusCode = $ context ->getStatusCode ();
139+
140+ if ($ statusCode < 300 || 400 <= $ statusCode || null === $ url = $ context ->getInfo ('redirect_url ' )) {
141+ $ context ->passthru ();
142+
143+ yield $ chunk ;
144+
145+ return ;
146+ }
147+
148+ $ host = parse_url ($ url , \PHP_URL_HOST );
149+ $ ip = self ::dnsResolve ($ dnsCache , $ host , $ ipFlags , $ options );
150+ self ::ipCheck ($ ip , $ subnets , $ ipFlags , $ host , $ url );
151+
152+ // Do like curl and browsers: turn POST to GET on 301, 302 and 303
153+ if (303 === $ statusCode || 'POST ' === $ method && \in_array ($ statusCode , [301 , 302 ], true )) {
154+ $ method = 'HEAD ' === $ method ? 'HEAD ' : 'GET ' ;
155+ unset($ options ['body ' ], $ options ['json ' ]);
156+
157+ if (isset ($ options ['normalized_headers ' ]['content-length ' ]) || isset ($ options ['normalized_headers ' ]['content-type ' ]) || isset ($ options ['normalized_headers ' ]['transfer-encoding ' ])) {
158+ $ filterContentHeaders = static function ($ h ) {
159+ return 0 !== stripos ($ h , 'Content-Length: ' ) && 0 !== stripos ($ h , 'Content-Type: ' ) && 0 !== stripos ($ h , 'Transfer-Encoding: ' );
160+ };
161+ $ options ['header ' ] = array_filter ($ options ['header ' ], $ filterContentHeaders );
162+ $ redirectHeaders ['no_auth ' ] = array_filter ($ redirectHeaders ['no_auth ' ], $ filterContentHeaders );
163+ $ redirectHeaders ['with_auth ' ] = array_filter ($ redirectHeaders ['with_auth ' ], $ filterContentHeaders );
164+ }
165+ }
166+
167+ // Authorization and Cookie headers MUST NOT follow except for the initial host name
168+ $ options ['headers ' ] = $ redirectHeaders ['host ' ] === $ host ? $ redirectHeaders ['with_auth ' ] : $ redirectHeaders ['no_auth ' ];
169+
170+ static $ redirectCount = 0 ;
171+ $ context ->setInfo ('redirect_count ' , ++$ redirectCount );
172+
173+ $ context ->replaceRequest ($ method , $ url , $ options );
174+
175+ if ($ redirectCount >= $ maxRedirects ) {
176+ $ context ->passthru ();
177+ }
178+ });
115179 }
116180
117181 /**
@@ -139,14 +203,73 @@ public function withOptions(array $options): self
139203 {
140204 $ clone = clone $ this ;
141205 $ clone ->client = $ this ->client ->withOptions ($ options );
206+ $ clone ->defaultOptions = self ::mergeDefaultOptions ($ options , $ this ->defaultOptions );
142207
143208 return $ clone ;
144209 }
145210
146211 public function reset ()
147212 {
213+ $ this ->dnsCache ->exchangeArray ([]);
214+
148215 if ($ this ->client instanceof ResetInterface) {
149216 $ this ->client ->reset ();
150217 }
151218 }
219+
220+ private static function dnsResolve (\ArrayObject $ dnsCache , string $ host , int $ ipFlags , array &$ options ): string
221+ {
222+ if ($ ip = filter_var (trim ($ host , '[] ' ), \FILTER_VALIDATE_IP ) ?: $ options ['resolve ' ][$ host ] ?? false ) {
223+ return $ ip ;
224+ }
225+
226+ if ($ dnsCache ->offsetExists ($ host )) {
227+ return $ dnsCache [$ host ];
228+ }
229+
230+ if ((\FILTER_FLAG_IPV4 & $ ipFlags ) && $ ip = gethostbynamel ($ host )) {
231+ return $ options ['resolve ' ][$ host ] = $ dnsCache [$ host ] = $ ip [0 ];
232+ }
233+
234+ if (!(\FILTER_FLAG_IPV6 & $ ipFlags )) {
235+ return $ host ;
236+ }
237+
238+ if ($ ip = dns_get_record ($ host , \DNS_AAAA )) {
239+ $ ip = $ ip [0 ]['ipv6 ' ];
240+ } elseif (extension_loaded ('sockets ' )) {
241+ if (!$ info = socket_addrinfo_lookup ($ host , 0 , ['ai_socktype ' => \SOCK_STREAM , 'ai_family ' => \AF_INET6 ])) {
242+ return $ host ;
243+ }
244+
245+ $ ip = socket_addrinfo_explain ($ info [0 ])['ai_addr ' ]['sin6_addr ' ];
246+ } elseif ('localhost ' === $ host || 'localhost. ' === $ host ) {
247+ $ ip = '::1 ' ;
248+ } else {
249+ return $ host ;
250+ }
251+
252+ return $ options ['resolve ' ][$ host ] = $ dnsCache [$ host ] = $ ip ;
253+ }
254+
255+ private static function ipCheck (string $ ip , ?array $ subnets , int $ ipFlags , ?string $ host , string $ url ): void
256+ {
257+ if (null === $ subnets ) {
258+ // Quick check, but not reliable enough, see https://github.com/php/php-src/issues/16944
259+ $ ipFlags |= \FILTER_FLAG_NO_PRIV_RANGE | \FILTER_FLAG_NO_RES_RANGE ;
260+ }
261+
262+ if (false !== filter_var ($ ip , \FILTER_VALIDATE_IP , $ ipFlags ) && !IpUtils::checkIp ($ ip , $ subnets ?? self ::PRIVATE_SUBNETS )) {
263+ return ;
264+ }
265+
266+ if (null !== $ host ) {
267+ $ type = 'Host ' ;
268+ } else {
269+ $ host = $ ip ;
270+ $ type = 'IP ' ;
271+ }
272+
273+ throw new TransportException ($ type .\sprintf (' "%s" is blocked for "%s". ' , $ host , $ url ));
274+ }
152275}
0 commit comments