4
* Very basic HTTPS future.
6
final class HTTPSFuture extends BaseHTTPFuture {
9
private static $results = array();
10
private static $pool = array();
11
private static $globalCABundle;
12
private static $blindTrustDomains = array();
15
private $profilerCallID;
17
private $followLocation = true;
18
private $responseBuffer = '';
19
private $responseBufferPos;
20
private $files = array();
21
private $temporaryFiles = array();
24
* Create a temp file containing an SSL cert, and use it for this session.
26
* This allows us to do host-specific SSL certificates in whatever client
27
* is using libphutil. e.g. in Arcanist, you could add an "ssl_cert" key
28
* to a specific host in ~/.arcrc and use that.
30
* cURL needs this to be a file, it doesn't seem to be able to handle a string
31
* which contains the cert. So we make a temporary file and store it there.
33
* @param string The multi-line, possibly lengthy, SSL certificate to use.
36
public function setCABundleFromString($certificate) {
37
$temp = new TempFile();
38
Filesystem::writeFile($temp, $certificate);
39
$this->cabundle = $temp;
44
* Set the SSL certificate to use for this session, given a path.
46
* @param string The path to a valid SSL certificate for this session
49
public function setCABundleFromPath($path) {
50
$this->cabundle = $path;
55
* Get the path to the SSL certificate for this session.
59
public function getCABundle() {
60
return $this->cabundle;
64
* Set whether Location headers in the response will be respected.
65
* The default is true.
67
* @param boolean true to follow any Location header present in the response,
68
* false to return the request directly
71
public function setFollowLocation($follow) {
72
$this->followLocation = $follow;
77
* Get whether Location headers in the response will be respected.
81
public function getFollowLocation() {
82
return $this->followLocation;
86
* Set the fallback CA certificate if one is not specified
87
* for the session, given a path.
89
* @param string The path to a valid SSL certificate
92
public static function setGlobalCABundleFromPath($path) {
93
self::$globalCABundle = $path;
96
* Set the fallback CA certificate if one is not specified
97
* for the session, given a string.
99
* @param string The certificate
102
public static function setGlobalCABundleFromString($certificate) {
103
$temp = new TempFile();
104
Filesystem::writeFile($temp, $certificate);
105
self::$globalCABundle = $temp;
109
* Get the fallback global CA certificate
113
public static function getGlobalCABundle() {
114
return self::$globalCABundle;
118
* Set a list of domains to blindly trust. Certificates for these domains
119
* will not be validated.
121
* @param list<string> List of domain names to trust blindly.
124
public static function setBlindlyTrustDomains(array $domains) {
125
self::$blindTrustDomains = array_fuse($domains);
129
* Load contents of remote URI. Behaves pretty much like
130
* `@file_get_contents($uri)` but doesn't require `allow_url_fopen`.
134
* @return string|false
136
public static function loadContent($uri, $timeout = null) {
137
$future = new HTTPSFuture($uri);
138
if ($timeout !== null) {
139
$future->setTimeout($timeout);
142
list($body) = $future->resolvex();
144
} catch (HTTPFutureResponseStatus $ex) {
150
* Attach a file to the request.
152
* @param string HTTP parameter name.
153
* @param string File content.
154
* @param string File name.
155
* @param string File mime type.
158
public function attachFileData($key, $data, $name, $mime_type) {
159
if (isset($this->files[$key])) {
162
'HTTPSFuture currently supports only one file attachment for each '.
163
'parameter name. You are trying to attach two different files with '.
164
'the same parameter, "%s".',
168
$this->files[$key] = array(
171
'mime' => $mime_type,
177
public function isReady() {
178
if (isset($this->result)) {
182
$uri = $this->getURI();
183
$domain = id(new PhutilURI($uri))->getDomain();
185
if (!$this->handle) {
186
$profiler = PhutilServiceProfiler::getInstance();
187
$this->profilerCallID = $profiler->beginServiceCall(
194
self::$multi = curl_multi_init();
196
throw new Exception('curl_multi_init() failed!');
200
if (!empty(self::$pool[$domain])) {
201
$curl = array_pop(self::$pool[$domain]);
205
throw new Exception('curl_init() failed!');
209
$this->handle = $curl;
210
curl_multi_add_handle(self::$multi, $curl);
212
curl_setopt($curl, CURLOPT_URL, $uri);
214
if (defined('CURLOPT_PROTOCOLS')) {
215
// cURL supports a lot of protocols, and by default it will honor
216
// redirects across protocols (for instance, from HTTP to POP3). Beyond
217
// being very silly, this also has security implications:
219
// http://blog.volema.com/curl-rce.html
221
// Disable all protocols other than HTTP and HTTPS.
223
$allowed_protocols = CURLPROTO_HTTPS | CURLPROTO_HTTP;
224
curl_setopt($curl, CURLOPT_PROTOCOLS, $allowed_protocols);
225
curl_setopt($curl, CURLOPT_REDIR_PROTOCOLS, $allowed_protocols);
228
$data = $this->formatRequestDataForCURL();
229
curl_setopt($curl, CURLOPT_POSTFIELDS, $data);
231
$headers = $this->getHeaders();
234
for ($ii = 0; $ii < count($headers); $ii++) {
235
list($name, $value) = $headers[$ii];
236
$headers[$ii] = $name.': '.$value;
237
if (!strncasecmp($name, 'Expect', strlen('Expect'))) {
242
// cURL sends an "Expect" header by default for certain requests. While
243
// there is some reasoning behind this, it causes a practical problem
244
// in that lighttpd servers reject these requests with a 417. Both sides
245
// are locked in an eternal struggle (lighttpd has introduced a
246
// 'server.reject-expect-100-with-417' option to deal with this case).
248
// The ostensibly correct way to suppress this behavior on the cURL side
249
// is to add an empty "Expect:" header. If we haven't seen some other
250
// explicit "Expect:" header, do so.
252
// See here, for example, although this issue is fairly widespread:
253
// http://curl.haxx.se/mail/archive-2009-07/0008.html
254
$headers[] = 'Expect:';
256
curl_setopt($curl, CURLOPT_HTTPHEADER, $headers);
258
// Set the requested HTTP method, e.g. GET / POST / PUT.
259
curl_setopt($curl, CURLOPT_CUSTOMREQUEST, $this->getMethod());
261
// Make sure we get the headers and data back.
262
curl_setopt($curl, CURLOPT_HEADER, true);
263
curl_setopt($curl, CURLOPT_WRITEFUNCTION,
264
array($this, 'didReceiveDataCallback'));
266
if ($this->followLocation) {
267
curl_setopt($curl, CURLOPT_FOLLOWLOCATION, true);
268
curl_setopt($curl, CURLOPT_MAXREDIRS, 20);
271
if (defined('CURLOPT_TIMEOUT_MS')) {
272
// If CURLOPT_TIMEOUT_MS is available, use the higher-precision timeout.
273
$timeout = max(1, ceil(1000 * $this->getTimeout()));
274
curl_setopt($curl, CURLOPT_TIMEOUT_MS, $timeout);
276
// Otherwise, fall back to the lower-precision timeout.
277
$timeout = max(1, ceil($this->getTimeout()));
278
curl_setopt($curl, CURLOPT_TIMEOUT, $timeout);
281
// We're going to try to set CAINFO below. This doesn't work at all on
282
// OSX around Yosemite (see T5913). On these systems, we'll use the
283
// system CA and then try to tell the user that their settings were
284
// ignored and how to fix things if we encounter a CA-related error.
285
// Assume we have custom CA settings to start with; we'll clear this
286
// flag if we read the default CA info below.
288
// Try some decent fallbacks here:
289
// - First, check if a bundle is set explicitly for this request, via
290
// `setCABundle()` or similar.
291
// - Then, check if a global bundle is set explicitly for all requests,
292
// via `setGlobalCABundle()` or similar.
293
// - Then, if a local custom.pem exists, use that, because it probably
294
// means that the user wants to override everything (also because the
295
// user might not have access to change the box's php.ini to add
297
// - Otherwise, try using curl.cainfo. If it's set explicitly, it's
298
// probably reasonable to try using it before we fall back to what
299
// libphutil ships with.
300
// - Lastly, try the default that libphutil ships with. If it doesn't
301
// work, give up and yell at the user.
303
if (!$this->getCABundle()) {
304
$caroot = dirname(phutil_get_library_root('phutil')).'/resources/ssl/';
305
$ini_val = ini_get('curl.cainfo');
306
if (self::getGlobalCABundle()) {
307
$this->setCABundleFromPath(self::getGlobalCABundle());
308
} else if (Filesystem::pathExists($caroot.'custom.pem')) {
309
$this->setCABundleFromPath($caroot.'custom.pem');
310
} else if ($ini_val) {
311
// TODO: We can probably do a pathExists() here, even.
312
$this->setCABundleFromPath($ini_val);
314
$this->setCABundleFromPath($caroot.'default.pem');
318
if ($this->canSetCAInfo()) {
319
curl_setopt($curl, CURLOPT_CAINFO, $this->getCABundle());
322
$domain = id(new PhutilURI($uri))->getDomain();
323
if (!empty(self::$blindTrustDomains[$domain])) {
324
// Disable peer verification for domains that we blindly trust.
325
curl_setopt($curl, CURLOPT_SSL_VERIFYPEER, false);
327
curl_setopt($curl, CURLOPT_SSL_VERIFYPEER, true);
330
curl_setopt($curl, CURLOPT_SSLVERSION, 0);
332
$curl = $this->handle;
334
if (!self::$results) {
335
// NOTE: In curl_multi_select(), PHP calls curl_multi_fdset() but does
336
// not check the return value of &maxfd for -1 until recent versions
337
// of PHP (5.4.8 and newer). cURL may return -1 as maxfd in some unusual
338
// situations; if it does, PHP enters select() with nfds=0, which blocks
339
// until the timeout is reached.
341
// We could try to guess whether this will happen or not by examining
342
// the version identifier, but we can also just sleep for only a short
344
curl_multi_select(self::$multi, 0.01);
350
$result = curl_multi_exec(self::$multi, $active);
351
} while ($result == CURLM_CALL_MULTI_PERFORM);
353
while ($info = curl_multi_info_read(self::$multi)) {
354
if ($info['msg'] == CURLMSG_DONE) {
355
self::$results[(int)$info['handle']] = $info;
359
if (!array_key_exists((int)$curl, self::$results)) {
363
// The request is complete, so release any temporary files we wrote
365
$this->temporaryFiles = array();
367
$info = self::$results[(int)$curl];
368
$result = $this->responseBuffer;
369
$err_code = $info['result'];
372
if (($err_code == CURLE_SSL_CACERT) && !$this->canSetCAInfo()) {
373
$status = new HTTPFutureCertificateResponseStatus(
374
HTTPFutureCertificateResponseStatus::ERROR_IMMUTABLE_CERTIFICATES,
377
$status = new HTTPFutureCURLResponseStatus($err_code, $uri);
382
$this->result = array($status, $body, $headers);
384
// cURL returns headers of all redirects, we strip all but the final one.
385
$redirects = curl_getinfo($curl, CURLINFO_REDIRECT_COUNT);
386
$result = preg_replace('/^(.*\r\n\r\n){'.$redirects.'}/sU', '', $result);
387
$this->result = $this->parseRawHTTPResponse($result);
390
curl_multi_remove_handle(self::$multi, $curl);
391
unset(self::$results[(int)$curl]);
393
// NOTE: We want to use keepalive if possible. Return the handle to a
394
// pool for the domain; don't close it.
395
self::$pool[$domain][] = $curl;
397
$profiler = PhutilServiceProfiler::getInstance();
398
$profiler->endServiceCall($this->profilerCallID, array());
405
* Callback invoked by cURL as it reads HTTP data from the response. We save
406
* the data to a buffer.
408
public function didReceiveDataCallback($handle, $data) {
409
$this->responseBuffer .= $data;
410
return strlen($data);
415
* Read data from the response buffer.
417
* NOTE: Like @{class:ExecFuture}, this method advances a read cursor but
418
* does not discard the data. The data will still be buffered, and it will
419
* all be returned when the future resolves. To discard the data after
420
* reading it, call @{method:discardBuffers}.
422
* @return string Response data, if available.
424
public function read() {
425
$result = substr($this->responseBuffer, $this->responseBufferPos);
426
$this->responseBufferPos = strlen($this->responseBuffer);
432
* Discard any buffered data. Normally, you call this after reading the
433
* data with @{method:read}.
437
public function discardBuffers() {
438
$this->responseBuffer = '';
439
$this->responseBufferPos = 0;
445
* Produces a value safe to pass to `CURLOPT_POSTFIELDS`.
447
* @return wild Some value, suitable for use in `CURLOPT_POSTFIELDS`.
449
private function formatRequestDataForCURL() {
450
// We're generating a value to hand to cURL as CURLOPT_POSTFIELDS. The way
451
// cURL handles this value has some tricky caveats.
453
// First, we can return either an array or a query string. If we return
454
// an array, we get a "multipart/form-data" request. If we return a
455
// query string, we get an "application/x-www-form-urlencoded" request.
457
// Second, if we return an array we can't duplicate keys. The user might
458
// want to send the same parameter multiple times.
460
// Third, if we return an array and any of the values start with "@",
461
// cURL includes arbitrary files off disk and sends them to an untrusted
462
// remote server. For example, an array like:
464
// array('name' => '@/usr/local/secret')
466
// ...will attempt to read that file off disk and transmit its contents with
467
// the request. This behavior is pretty surprising, and it can easily
468
// become a relatively severe security vulnerability which allows an
469
// attacker to read any file the HTTP process has access to. Since this
470
// feature is very dangerous and not particularly useful, we prevent its
471
// use. Broadly, this means we must reject some requests because they
472
// contain an "@" in an inconvenient place.
474
// Generally, to avoid the "@" case and because most servers usually
475
// expect "application/x-www-form-urlencoded" data, we try to return a
476
// string unless there are files attached to this request.
478
$data = $this->getData();
479
$files = $this->files;
481
$any_data = ($data || (is_string($data) && strlen($data)));
482
$any_files = (bool)$this->files;
484
if (!$any_data && !$any_files) {
485
// No files or data, so just bail.
490
// If we don't have any files, just encode the data as a query string,
491
// make sure it's not including any files, and we're good to go.
492
if (is_array($data)) {
493
$data = http_build_query($data, '', '&');
496
$this->checkForDangerousCURLMagic($data, $is_query_string = true);
501
// If we've made it this far, we have some files, so we need to return
502
// an array. First, convert the other data into an array if it isn't one
505
if (is_string($data)) {
506
// NOTE: We explicitly don't want fancy array parsing here, so just
507
// do a basic parse and then convert it into a dictionary ourselves.
508
$parser = new PhutilQueryStringParser();
509
$pairs = $parser->parseQueryStringToPairList($data);
512
foreach ($pairs as $pair) {
513
list($key, $value) = $pair;
514
if (array_key_exists($key, $map)) {
517
'Request specifies two values for key "%s", but parameter '.
518
'names must be unique if you are posting file data due to '.
519
'limitations with cURL.'));
527
foreach ($data as $key => $value) {
528
$this->checkForDangerousCURLMagic($value, $is_query_string = false);
531
foreach ($this->files as $name => $info) {
532
if (array_key_exists($name, $data)) {
535
'Request specifies a file with key "%s", but that key is '.
536
'also defined by normal request data. Due to limitations '.
537
'with cURL, requests that post file data must use unique '.
541
$tmp = new TempFile($info['name']);
542
Filesystem::writeFile($tmp, $info['data']);
543
$this->temporaryFiles[] = $tmp;
545
// In 5.5.0 and later, we can use CURLFile. Prior to that, we have to
546
// use this "@" stuff.
548
if (class_exists('CURLFile', false)) {
549
$file_value = new CURLFile((string)$tmp, $info['mime'], $info['name']);
551
$file_value = '@'.(string)$tmp;
554
$data[$name] = $file_value;
562
* Detect strings which will cause cURL to do horrible, insecure things.
564
* @param string Possibly dangerous string.
565
* @param bool True if this string is being used as part of a query string.
568
private function checkForDangerousCURLMagic($string, $is_query_string) {
569
if (empty($string[0]) || ($string[0] != '@')) {
570
// This isn't an "@..." string, so it's fine.
574
if ($is_query_string) {
575
if (version_compare(phpversion(), '5.2.0', '<')) {
578
'Attempting to make an HTTP request, but query string data begins '.
579
'with "@". Prior to PHP 5.2.0 this reads files off disk, which '.
580
'creates a wide attack window for security vulnerabilities. '.
581
'Upgrade PHP or avoid making cURL requests which begin with "@".'));
584
// This is safe if we're on PHP 5.2.0 or newer.
590
'Attempting to make an HTTP request which includes file data, but '.
591
'the value of a query parameter begins with "@". PHP interprets '.
592
'these values to mean that it should read arbitrary files off disk '.
593
'and transmit them to remote servers. Declining to make this '.
599
* Determine whether CURLOPT_CAINFO is usable on this system.
601
private function canSetCAInfo() {
602
// We cannot set CAInfo on OSX after Yosemite.
604
$osx_version = PhutilExecutionEnvironment::getOSXVersion();
606
if (version_compare($osx_version, 14, '>=')) {