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
37
+ private array $ defaultOptions = self ::OPTIONS_DEFAULTS ;
33
38
private HttpClientInterface $ client ;
34
- private string |array |null $ subnets ;
39
+ private array |null $ subnets ;
40
+ private int $ ipFlags ;
41
+ private \ArrayObject $ dnsCache ;
35
42
36
43
/**
37
- * @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 .
38
45
* If null is passed, the standard private subnets will be used.
39
46
*/
40
47
public function __construct (HttpClientInterface $ client , string |array |null $ subnets = null )
@@ -43,54 +50,112 @@ public function __construct(HttpClientInterface $client, string|array|null $subn
43
50
throw new \LogicException (sprintf ('You cannot use "%s" if the HttpFoundation component is not installed. Try running "composer require symfony/http-foundation". ' , __CLASS__ ));
44
51
}
45
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
+
46
66
$ this ->client = $ client ;
47
- $ this ->subnets = $ subnets ;
67
+ $ this ->subnets = null !== $ subnets ? (array ) $ subnets : null ;
68
+ $ this ->ipFlags = $ ipFlags ;
69
+ $ this ->dnsCache = new \ArrayObject ();
48
70
}
49
71
50
72
public function request (string $ method , string $ url , array $ options = []): ResponseInterface
51
73
{
52
- $ onProgress = $ options ['on_progress ' ] ?? null ;
53
- if (null !== $ onProgress && !\is_callable ($ onProgress )) {
54
- 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
+ }
55
93
}
56
94
95
+ $ onProgress = $ options ['on_progress ' ] ?? null ;
57
96
$ subnets = $ this ->subnets ;
97
+ $ ipFlags = $ this ->ipFlags ;
58
98
59
- $ options ['on_progress ' ] = static function (int $ dlNow , int $ dlSize , array $ info ) use ($ onProgress , $ subnets ): void {
60
- static $ lastUrl = '' ;
99
+ $ options ['on_progress ' ] = static function (int $ dlNow , int $ dlSize , array $ info ) use ($ onProgress , $ subnets , $ ipFlags ): void {
61
100
static $ lastPrimaryIp = '' ;
62
101
63
- if ($ info ['url ' ] !== $ lastUrl ) {
64
- $ host = parse_url ($ info ['url ' ], PHP_URL_HOST ) ?: '' ;
65
- $ 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
+ }
106
+
107
+ null !== $ onProgress && $ onProgress ($ dlNow , $ dlSize , $ info );
108
+ };
66
109
67
- if (($ ip = trim ($ host , '[] ' ))
68
- && !filter_var ($ ip , \FILTER_VALIDATE_IP )
69
- && !($ ip = $ resolve ($ host ))
70
- && $ ip = @(gethostbynamel ($ host )[0 ] ?? dns_get_record ($ host , \DNS_AAAA )[0 ]['ipv6 ' ] ?? null )
71
- ) {
72
- $ resolve ($ host , $ ip );
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
- if ($ ip && IpUtils::checkIp ($ ip , $ subnets ?? IpUtils::PRIVATE_SUBNETS )) {
76
- throw new TransportException (sprintf ('Host "%s" is blocked for "%s". ' , $ host , $ info ['url ' ]));
77
- }
114
+ return ;
115
+ }
116
+
117
+ $ statusCode = $ context ->getStatusCode ();
78
118
79
- $ lastUrl = $ info ['url ' ];
119
+ if ($ statusCode < 300 || 400 <= $ statusCode || null === $ url = $ context ->getInfo ('redirect_url ' )) {
120
+ $ context ->passthru ();
121
+
122
+ yield $ chunk ;
123
+
124
+ return ;
80
125
}
81
126
82
- if ($ info ['primary_ip ' ] !== $ lastPrimaryIp ) {
83
- if ($ info ['primary_ip ' ] && IpUtils::checkIp ($ info ['primary_ip ' ], $ subnets ?? IpUtils::PRIVATE_SUBNETS )) {
84
- throw new TransportException (sprintf ('IP "%s" is blocked for "%s". ' , $ info ['primary_ip ' ], $ info ['url ' ]));
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 );
85
143
}
86
-
87
- $ lastPrimaryIp = $ info ['primary_ip ' ];
88
144
}
89
145
90
- null !== $ onProgress && $ onProgress ($ dlNow , $ dlSize , $ info );
91
- };
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 );
92
152
93
- return $ this ->client ->request ($ method , $ url , $ options );
153
+ $ context ->replaceRequest ($ method , $ url , $ options );
154
+
155
+ if ($ redirectCount >= $ maxRedirects ) {
156
+ $ context ->passthru ();
157
+ }
158
+ });
94
159
}
95
160
96
161
public function stream (ResponseInterface |iterable $ responses , ?float $ timeout = null ): ResponseStreamInterface
@@ -109,14 +174,73 @@ public function withOptions(array $options): static
109
174
{
110
175
$ clone = clone $ this ;
111
176
$ clone ->client = $ this ->client ->withOptions ($ options );
177
+ $ clone ->defaultOptions = self ::mergeDefaultOptions ($ options , $ this ->defaultOptions );
112
178
113
179
return $ clone ;
114
180
}
115
181
116
182
public function reset (): void
117
183
{
184
+ $ this ->dnsCache ->exchangeArray ([]);
185
+
118
186
if ($ this ->client instanceof ResetInterface) {
119
187
$ this ->client ->reset ();
120
188
}
121
189
}
190
+
191
+ private static function dnsResolve (\ArrayObject $ dnsCache , string $ host , int $ ipFlags , array &$ options ): string
192
+ {
193
+ if ($ ip = filter_var (trim ($ host , '[] ' ), \FILTER_VALIDATE_IP ) ?: $ options ['resolve ' ][$ host ] ?? false ) {
194
+ return $ ip ;
195
+ }
196
+
197
+ if ($ dnsCache ->offsetExists ($ host )) {
198
+ return $ dnsCache [$ host ];
199
+ }
200
+
201
+ if ((\FILTER_FLAG_IPV4 & $ ipFlags ) && $ ip = gethostbynamel ($ host )) {
202
+ return $ options ['resolve ' ][$ host ] = $ dnsCache [$ host ] = $ ip [0 ];
203
+ }
204
+
205
+ if (!(\FILTER_FLAG_IPV6 & $ ipFlags )) {
206
+ return $ host ;
207
+ }
208
+
209
+ if ($ ip = dns_get_record ($ host , \DNS_AAAA )) {
210
+ $ ip = $ ip [0 ]['ipv6 ' ];
211
+ } elseif (extension_loaded ('sockets ' )) {
212
+ if (!$ info = socket_addrinfo_lookup ($ host , 0 , ['ai_socktype ' => \SOCK_STREAM , 'ai_family ' => \AF_INET6 ])) {
213
+ return $ host ;
214
+ }
215
+
216
+ $ ip = socket_addrinfo_explain ($ info [0 ])['ai_addr ' ]['sin6_addr ' ];
217
+ } elseif ('localhost ' === $ host || 'localhost. ' === $ host ) {
218
+ $ ip = '::1 ' ;
219
+ } else {
220
+ return $ host ;
221
+ }
222
+
223
+ return $ options ['resolve ' ][$ host ] = $ dnsCache [$ host ] = $ ip ;
224
+ }
225
+
226
+ private static function ipCheck (string $ ip , ?array $ subnets , int $ ipFlags , ?string $ host , string $ url ): void
227
+ {
228
+ if (null === $ subnets ) {
229
+ // Quick check, but not reliable enough, see https://github.com/php/php-src/issues/16944
230
+ $ ipFlags |= \FILTER_FLAG_NO_PRIV_RANGE | \FILTER_FLAG_NO_RES_RANGE ;
231
+ }
232
+
233
+ if (false !== filter_var ($ ip , \FILTER_VALIDATE_IP , $ ipFlags ) && !IpUtils::checkIp ($ ip , $ subnets ?? IpUtils::PRIVATE_SUBNETS )) {
234
+ return ;
235
+ }
236
+
237
+ if (null !== $ host ) {
238
+ $ type = 'Host ' ;
239
+ } else {
240
+ $ host = $ ip ;
241
+ $ type = 'IP ' ;
242
+ }
243
+
244
+ throw new TransportException ($ type .\sprintf (' "%s" is blocked for "%s". ' , $ host , $ url ));
245
+ }
122
246
}
0 commit comments