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 <hallisonboaventura@gmail.com>
30
+ * @author Nicolas Grekas <p@tchwork.com>
28
31
*/
29
32
final class NoPrivateNetworkHttpClient implements HttpClientInterface, LoggerAwareInterface, ResetInterface
30
33
{
31
34
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 ;
32
42
33
43
/**
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 .
35
45
* If null is passed, the standard private subnets will be used.
36
46
*/
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
+ {
41
49
if (!class_exists (IpUtils::class)) {
42
50
throw new \LogicException (\sprintf ('You cannot use "%s" if the HttpFoundation component is not installed. Try running "composer require symfony/http-foundation". ' , __CLASS__ ));
43
51
}
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 ();
44
70
}
45
71
46
72
public function request (string $ method , string $ url , array $ options = []): ResponseInterface
47
73
{
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
+ }
51
93
}
52
94
95
+ $ onProgress = $ options ['on_progress ' ] ?? null ;
53
96
$ subnets = $ this ->subnets ;
97
+ $ ipFlags = $ this ->ipFlags ;
54
98
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 {
57
100
static $ lastPrimaryIp = '' ;
58
101
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
+ }
62
106
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
+ };
70
109
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 ;
74
113
75
- $ lastUrl = $ info [ ' url ' ] ;
114
+ return ;
76
115
}
77
116
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 ();
82
118
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 ;
84
125
}
85
126
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
+ }
88
145
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
+ });
90
159
}
91
160
92
161
public function stream (ResponseInterface |iterable $ responses , ?float $ timeout = null ): ResponseStreamInterface
@@ -110,14 +179,73 @@ public function withOptions(array $options): static
110
179
{
111
180
$ clone = clone $ this ;
112
181
$ clone ->client = $ this ->client ->withOptions ($ options );
182
+ $ clone ->defaultOptions = self ::mergeDefaultOptions ($ options , $ this ->defaultOptions );
113
183
114
184
return $ clone ;
115
185
}
116
186
117
187
public function reset (): void
118
188
{
189
+ $ this ->dnsCache ->exchangeArray ([]);
190
+
119
191
if ($ this ->client instanceof ResetInterface) {
120
192
$ this ->client ->reset ();
121
193
}
122
194
}
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
+ }
123
251
}
0 commit comments