13
13
14
14
use Psr \Log \LoggerAwareInterface ;
15
15
use Psr \Log \LoggerInterface ;
16
- use Symfony \Component \HttpClient \Exception \InvalidArgumentException ;
17
16
use Symfony \Component \HttpClient \Exception \TransportException ;
17
+ use Symfony \Component \HttpClient \Response \AsyncContext ;
18
+ use Symfony \Component \HttpClient \Response \AsyncResponse ;
18
19
use Symfony \Component \HttpFoundation \IpUtils ;
20
+ use Symfony \Contracts \HttpClient \ChunkInterface ;
19
21
use Symfony \Contracts \HttpClient \HttpClientInterface ;
20
22
use Symfony \Contracts \HttpClient \ResponseInterface ;
21
23
use Symfony \Contracts \HttpClient \ResponseStreamInterface ;
25
27
* Decorator that blocks requests to private networks by default.
26
28
*
27
29
* @author Hallison Boaventura <[email protected] >
30
+ * @author Nicolas Grekas <[email protected] >
28
31
*/
29
32
final class NoPrivateNetworkHttpClient implements HttpClientInterface, LoggerAwareInterface, ResetInterface
30
33
{
31
34
use HttpClientTrait;
35
+ use AsyncDecoratorTrait;
32
36
33
37
private const PRIVATE_SUBNETS = [
34
38
'127.0.0.0/8 ' ,
@@ -45,11 +49,14 @@ final class NoPrivateNetworkHttpClient implements HttpClientInterface, LoggerAwa
45
49
'::/128 ' ,
46
50
];
47
51
52
+ private $ defaultOptions = self ::OPTIONS_DEFAULTS ;
48
53
private $ client ;
49
54
private $ subnets ;
55
+ private $ ipFlags ;
56
+ private $ dnsCache ;
50
57
51
58
/**
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 .
53
60
* If null is passed, the standard private subnets will be used.
54
61
*/
55
62
public function __construct (HttpClientInterface $ client , $ subnets = null )
@@ -62,56 +69,113 @@ public function __construct(HttpClientInterface $client, $subnets = null)
62
69
throw new \LogicException (sprintf ('You cannot use "%s" if the HttpFoundation component is not installed. Try running "composer require symfony/http-foundation". ' , __CLASS__ ));
63
70
}
64
71
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
+
65
85
$ this ->client = $ client ;
66
- $ this ->subnets = $ subnets ;
86
+ $ this ->subnets = null !== $ subnets ? (array ) $ subnets : null ;
87
+ $ this ->ipFlags = $ ipFlags ;
88
+ $ this ->dnsCache = new \ArrayObject ();
67
89
}
68
90
69
91
/**
70
92
* {@inheritdoc}
71
93
*/
72
94
public function request (string $ method , string $ url , array $ options = []): ResponseInterface
73
95
{
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 );
78
97
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 ;
82
102
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 );
95
105
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 ' ];
99
109
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
+ });
101
114
}
115
+ }
102
116
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 = '' ;
107
121
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 ' ]);
108
125
$ lastPrimaryIp = $ info ['primary_ip ' ];
109
126
}
110
127
111
128
null !== $ onProgress && $ onProgress ($ dlNow , $ dlSize , $ info );
112
129
};
113
130
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
+ });
115
179
}
116
180
117
181
/**
@@ -139,14 +203,73 @@ public function withOptions(array $options): self
139
203
{
140
204
$ clone = clone $ this ;
141
205
$ clone ->client = $ this ->client ->withOptions ($ options );
206
+ $ clone ->defaultOptions = self ::mergeDefaultOptions ($ options , $ this ->defaultOptions );
142
207
143
208
return $ clone ;
144
209
}
145
210
146
211
public function reset ()
147
212
{
213
+ $ this ->dnsCache ->exchangeArray ([]);
214
+
148
215
if ($ this ->client instanceof ResetInterface) {
149
216
$ this ->client ->reset ();
150
217
}
151
218
}
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
+ }
152
275
}
0 commit comments