Completed
Branch master (62f6c6)
by
unknown
21:31
created

PhpHttpRequest::execute()   F

Complexity

Conditions 31
Paths > 20000

Size

Total Lines 162
Code Lines 95

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 31
eloc 95
nc 82944
nop 0
dl 0
loc 162
rs 2
c 0
b 0
f 0

How to fix   Long Method    Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

1
<?php
2
/**
3
 * Various HTTP related functions.
4
 *
5
 * This program is free software; you can redistribute it and/or modify
6
 * it under the terms of the GNU General Public License as published by
7
 * the Free Software Foundation; either version 2 of the License, or
8
 * (at your option) any later version.
9
 *
10
 * This program is distributed in the hope that it will be useful,
11
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13
 * GNU General Public License for more details.
14
 *
15
 * You should have received a copy of the GNU General Public License along
16
 * with this program; if not, write to the Free Software Foundation, Inc.,
17
 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
18
 * http://www.gnu.org/copyleft/gpl.html
19
 *
20
 * @file
21
 * @ingroup HTTP
22
 */
23
24
/**
25
 * @defgroup HTTP HTTP
26
 */
27
28
use MediaWiki\Logger\LoggerFactory;
29
30
/**
31
 * Various HTTP related functions
32
 * @ingroup HTTP
33
 */
34
class Http {
35
	static public $httpEngine = false;
36
37
	/**
38
	 * Perform an HTTP request
39
	 *
40
	 * @param string $method HTTP method. Usually GET/POST
41
	 * @param string $url Full URL to act on. If protocol-relative, will be expanded to an http:// URL
42
	 * @param array $options Options to pass to MWHttpRequest object.
43
	 *	Possible keys for the array:
44
	 *    - timeout             Timeout length in seconds
45
	 *    - connectTimeout      Timeout for connection, in seconds (curl only)
46
	 *    - postData            An array of key-value pairs or a url-encoded form data
47
	 *    - proxy               The proxy to use.
48
	 *                          Otherwise it will use $wgHTTPProxy (if set)
49
	 *                          Otherwise it will use the environment variable "http_proxy" (if set)
50
	 *    - noProxy             Don't use any proxy at all. Takes precedence over proxy value(s).
51
	 *    - sslVerifyHost       Verify hostname against certificate
52
	 *    - sslVerifyCert       Verify SSL certificate
53
	 *    - caInfo              Provide CA information
54
	 *    - maxRedirects        Maximum number of redirects to follow (defaults to 5)
55
	 *    - followRedirects     Whether to follow redirects (defaults to false).
56
	 *		                    Note: this should only be used when the target URL is trusted,
57
	 *		                    to avoid attacks on intranet services accessible by HTTP.
58
	 *    - userAgent           A user agent, if you want to override the default
59
	 *                          MediaWiki/$wgVersion
60
	 * @param string $caller The method making this request, for profiling
61
	 * @return string|bool (bool)false on failure or a string on success
62
	 */
63
	public static function request( $method, $url, $options = [], $caller = __METHOD__ ) {
64
		wfDebug( "HTTP: $method: $url\n" );
65
66
		$options['method'] = strtoupper( $method );
67
68
		if ( !isset( $options['timeout'] ) ) {
69
			$options['timeout'] = 'default';
70
		}
71
		if ( !isset( $options['connectTimeout'] ) ) {
72
			$options['connectTimeout'] = 'default';
73
		}
74
75
		$req = MWHttpRequest::factory( $url, $options, $caller );
76
		$status = $req->execute();
77
78
		if ( $status->isOK() ) {
79
			return $req->getContent();
80
		} else {
81
			$errors = $status->getErrorsByType( 'error' );
82
			$logger = LoggerFactory::getInstance( 'http' );
83
			$logger->warning( $status->getWikiText( false, false, 'en' ),
84
				[ 'error' => $errors, 'caller' => $caller, 'content' => $req->getContent() ] );
85
			return false;
86
		}
87
	}
88
89
	/**
90
	 * Simple wrapper for Http::request( 'GET' )
91
	 * @see Http::request()
92
	 * @since 1.25 Second parameter $timeout removed. Second parameter
93
	 * is now $options which can be given a 'timeout'
94
	 *
95
	 * @param string $url
96
	 * @param array $options
97
	 * @param string $caller The method making this request, for profiling
98
	 * @return string|bool false on error
99
	 */
100
	public static function get( $url, $options = [], $caller = __METHOD__ ) {
101
		$args = func_get_args();
102
		if ( isset( $args[1] ) && ( is_string( $args[1] ) || is_numeric( $args[1] ) ) ) {
103
			// Second was used to be the timeout
104
			// And third parameter used to be $options
105
			wfWarn( "Second parameter should not be a timeout.", 2 );
106
			$options = isset( $args[2] ) && is_array( $args[2] ) ?
107
				$args[2] : [];
108
			$options['timeout'] = $args[1];
109
			$caller = __METHOD__;
110
		}
111
		return Http::request( 'GET', $url, $options, $caller );
112
	}
113
114
	/**
115
	 * Simple wrapper for Http::request( 'POST' )
116
	 * @see Http::request()
117
	 *
118
	 * @param string $url
119
	 * @param array $options
120
	 * @param string $caller The method making this request, for profiling
121
	 * @return string|bool false on error
122
	 */
123
	public static function post( $url, $options = [], $caller = __METHOD__ ) {
124
		return Http::request( 'POST', $url, $options, $caller );
125
	}
126
127
	/**
128
	 * Check if the URL can be served by localhost
129
	 *
130
	 * @param string $url Full url to check
131
	 * @return bool
132
	 */
133
	public static function isLocalURL( $url ) {
134
		global $wgCommandLineMode, $wgLocalVirtualHosts;
135
136
		if ( $wgCommandLineMode ) {
137
			return false;
138
		}
139
140
		// Extract host part
141
		$matches = [];
142
		if ( preg_match( '!^http://([\w.-]+)[/:].*$!', $url, $matches ) ) {
143
			$host = $matches[1];
144
			// Split up dotwise
145
			$domainParts = explode( '.', $host );
146
			// Check if this domain or any superdomain is listed as a local virtual host
147
			$domainParts = array_reverse( $domainParts );
148
149
			$domain = '';
150
			$countParts = count( $domainParts );
151
			for ( $i = 0; $i < $countParts; $i++ ) {
152
				$domainPart = $domainParts[$i];
153
				if ( $i == 0 ) {
154
					$domain = $domainPart;
155
				} else {
156
					$domain = $domainPart . '.' . $domain;
157
				}
158
159
				if ( in_array( $domain, $wgLocalVirtualHosts ) ) {
160
					return true;
161
				}
162
			}
163
		}
164
165
		return false;
166
	}
167
168
	/**
169
	 * A standard user-agent we can use for external requests.
170
	 * @return string
171
	 */
172
	public static function userAgent() {
173
		global $wgVersion;
174
		return "MediaWiki/$wgVersion";
175
	}
176
177
	/**
178
	 * Checks that the given URI is a valid one. Hardcoding the
179
	 * protocols, because we only want protocols that both cURL
180
	 * and php support.
181
	 *
182
	 * file:// should not be allowed here for security purpose (r67684)
183
	 *
184
	 * @todo FIXME this is wildly inaccurate and fails to actually check most stuff
185
	 *
186
	 * @param string $uri URI to check for validity
187
	 * @return bool
188
	 */
189
	public static function isValidURI( $uri ) {
190
		return preg_match(
191
			'/^https?:\/\/[^\/\s]\S*$/D',
192
			$uri
193
		);
194
	}
195
196
	/**
197
	 * Gets the relevant proxy from $wgHTTPProxy/http_proxy (when set).
198
	 *
199
	 * @return mixed The proxy address or an empty string if not set.
200
	 */
201
	public static function getProxy() {
202
		global $wgHTTPProxy;
203
204
		if ( $wgHTTPProxy ) {
205
			return $wgHTTPProxy;
206
		}
207
208
		$envHttpProxy = getenv( "http_proxy" );
209
		if ( $envHttpProxy ) {
210
			return $envHttpProxy;
211
		}
212
213
		return "";
214
	}
215
}
216
217
/**
218
 * This wrapper class will call out to curl (if available) or fallback
219
 * to regular PHP if necessary for handling internal HTTP requests.
220
 *
221
 * Renamed from HttpRequest to MWHttpRequest to avoid conflict with
222
 * PHP's HTTP extension.
223
 */
224
class MWHttpRequest {
225
	const SUPPORTS_FILE_POSTS = false;
226
227
	protected $content;
228
	protected $timeout = 'default';
229
	protected $headersOnly = null;
230
	protected $postData = null;
231
	protected $proxy = null;
232
	protected $noProxy = false;
233
	protected $sslVerifyHost = true;
234
	protected $sslVerifyCert = true;
235
	protected $caInfo = null;
236
	protected $method = "GET";
237
	protected $reqHeaders = [];
238
	protected $url;
239
	protected $parsedUrl;
240
	protected $callback;
241
	protected $maxRedirects = 5;
242
	protected $followRedirects = false;
243
244
	/**
245
	 * @var CookieJar
246
	 */
247
	protected $cookieJar;
248
249
	protected $headerList = [];
250
	protected $respVersion = "0.9";
251
	protected $respStatus = "200 Ok";
252
	protected $respHeaders = [];
253
254
	public $status;
255
256
	/**
257
	 * @var Profiler
258
	 */
259
	protected $profiler;
260
261
	/**
262
	 * @var string
263
	 */
264
	protected $profileName;
265
266
	/**
267
	 * @param string $url Url to use. If protocol-relative, will be expanded to an http:// URL
268
	 * @param array $options (optional) extra params to pass (see Http::request())
269
	 * @param string $caller The method making this request, for profiling
270
	 * @param Profiler $profiler An instance of the profiler for profiling, or null
271
	 */
272
	protected function __construct(
273
		$url, $options = [], $caller = __METHOD__, $profiler = null
274
	) {
275
		global $wgHTTPTimeout, $wgHTTPConnectTimeout;
276
277
		$this->url = wfExpandUrl( $url, PROTO_HTTP );
278
		$this->parsedUrl = wfParseUrl( $this->url );
0 ignored issues
show
Security Bug introduced by
It seems like $this->url can also be of type false; however, wfParseUrl() does only seem to accept string, did you maybe forget to handle an error condition?
Loading history...
279
280
		if ( !$this->parsedUrl || !Http::isValidURI( $this->url ) ) {
0 ignored issues
show
Security Bug introduced by
It seems like $this->url can also be of type false; however, Http::isValidURI() does only seem to accept string, did you maybe forget to handle an error condition?
Loading history...
281
			$this->status = Status::newFatal( 'http-invalid-url', $url );
282
		} else {
283
			$this->status = Status::newGood( 100 ); // continue
284
		}
285
286 View Code Duplication
		if ( isset( $options['timeout'] ) && $options['timeout'] != 'default' ) {
287
			$this->timeout = $options['timeout'];
288
		} else {
289
			$this->timeout = $wgHTTPTimeout;
290
		}
291 View Code Duplication
		if ( isset( $options['connectTimeout'] ) && $options['connectTimeout'] != 'default' ) {
292
			$this->connectTimeout = $options['connectTimeout'];
0 ignored issues
show
Bug introduced by
The property connectTimeout does not seem to exist. Did you mean timeout?

An attempt at access to an undefined property has been detected. This may either be a typographical error or the property has been renamed but there are still references to its old name.

If you really want to allow access to undefined properties, you can define magic methods to allow access. See the php core documentation on Overloading.

Loading history...
293
		} else {
294
			$this->connectTimeout = $wgHTTPConnectTimeout;
0 ignored issues
show
Bug introduced by
The property connectTimeout does not seem to exist. Did you mean timeout?

An attempt at access to an undefined property has been detected. This may either be a typographical error or the property has been renamed but there are still references to its old name.

If you really want to allow access to undefined properties, you can define magic methods to allow access. See the php core documentation on Overloading.

Loading history...
295
		}
296
		if ( isset( $options['userAgent'] ) ) {
297
			$this->setUserAgent( $options['userAgent'] );
298
		}
299
300
		$members = [ "postData", "proxy", "noProxy", "sslVerifyHost", "caInfo",
301
				"method", "followRedirects", "maxRedirects", "sslVerifyCert", "callback" ];
302
303
		foreach ( $members as $o ) {
304
			if ( isset( $options[$o] ) ) {
305
				// ensure that MWHttpRequest::method is always
306
				// uppercased. Bug 36137
307
				if ( $o == 'method' ) {
308
					$options[$o] = strtoupper( $options[$o] );
309
				}
310
				$this->$o = $options[$o];
311
			}
312
		}
313
314
		if ( $this->noProxy ) {
315
			$this->proxy = ''; // noProxy takes precedence
316
		}
317
318
		// Profile based on what's calling us
319
		$this->profiler = $profiler;
320
		$this->profileName = $caller;
321
	}
322
323
	/**
324
	 * Simple function to test if we can make any sort of requests at all, using
325
	 * cURL or fopen()
326
	 * @return bool
327
	 */
328
	public static function canMakeRequests() {
329
		return function_exists( 'curl_init' ) || wfIniGetBool( 'allow_url_fopen' );
330
	}
331
332
	/**
333
	 * Generate a new request object
334
	 * @param string $url Url to use
335
	 * @param array $options (optional) extra params to pass (see Http::request())
336
	 * @param string $caller The method making this request, for profiling
337
	 * @throws MWException
338
	 * @return CurlHttpRequest|PhpHttpRequest
339
	 * @see MWHttpRequest::__construct
340
	 */
341
	public static function factory( $url, $options = null, $caller = __METHOD__ ) {
342
		if ( !Http::$httpEngine ) {
343
			Http::$httpEngine = function_exists( 'curl_init' ) ? 'curl' : 'php';
0 ignored issues
show
Documentation Bug introduced by
The property $httpEngine was declared of type boolean, but function_exists('curl_init') ? 'curl' : 'php' is of type string. Maybe add a type cast?

This check looks for assignments to scalar types that may be of the wrong type.

To ensure the code behaves as expected, it may be a good idea to add an explicit type cast.

$answer = 42;

$correct = false;

$correct = (bool) $answer;
Loading history...
344
		} elseif ( Http::$httpEngine == 'curl' && !function_exists( 'curl_init' ) ) {
345
			throw new MWException( __METHOD__ . ': curl (http://php.net/curl) is not installed, but' .
346
				' Http::$httpEngine is set to "curl"' );
347
		}
348
349
		switch ( Http::$httpEngine ) {
350
			case 'curl':
351
				return new CurlHttpRequest( $url, $options, $caller, Profiler::instance() );
0 ignored issues
show
Bug introduced by
It seems like $options defined by parameter $options on line 341 can also be of type null; however, MWHttpRequest::__construct() does only seem to accept array, maybe add an additional type check?

This check looks at variables that have been passed in as parameters and are passed out again to other methods.

If the outgoing method call has stricter type requirements than the method itself, an issue is raised.

An additional type check may prevent trouble.

Loading history...
352
			case 'php':
353
				if ( !wfIniGetBool( 'allow_url_fopen' ) ) {
354
					throw new MWException( __METHOD__ . ': allow_url_fopen ' .
355
						'needs to be enabled for pure PHP http requests to ' .
356
						'work. If possible, curl should be used instead. See ' .
357
						'http://php.net/curl.'
358
					);
359
				}
360
				return new PhpHttpRequest( $url, $options, $caller, Profiler::instance() );
0 ignored issues
show
Bug introduced by
It seems like $options defined by parameter $options on line 341 can also be of type null; however, MWHttpRequest::__construct() does only seem to accept array, maybe add an additional type check?

This check looks at variables that have been passed in as parameters and are passed out again to other methods.

If the outgoing method call has stricter type requirements than the method itself, an issue is raised.

An additional type check may prevent trouble.

Loading history...
361
			default:
362
				throw new MWException( __METHOD__ . ': The setting of Http::$httpEngine is not valid.' );
363
		}
364
	}
365
366
	/**
367
	 * Get the body, or content, of the response to the request
368
	 *
369
	 * @return string
370
	 */
371
	public function getContent() {
372
		return $this->content;
373
	}
374
375
	/**
376
	 * Set the parameters of the request
377
	 *
378
	 * @param array $args
379
	 * @todo overload the args param
380
	 */
381
	public function setData( $args ) {
382
		$this->postData = $args;
383
	}
384
385
	/**
386
	 * Take care of setting up the proxy (do nothing if "noProxy" is set)
387
	 *
388
	 * @return void
389
	 */
390
	public function proxySetup() {
391
		// If there is an explicit proxy set and proxies are not disabled, then use it
392
		if ( $this->proxy && !$this->noProxy ) {
393
			return;
394
		}
395
396
		// Otherwise, fallback to $wgHTTPProxy/http_proxy (when set) if this is not a machine
397
		// local URL and proxies are not disabled
398
		if ( Http::isLocalURL( $this->url ) || $this->noProxy ) {
0 ignored issues
show
Security Bug introduced by
It seems like $this->url can also be of type false; however, Http::isLocalURL() does only seem to accept string, did you maybe forget to handle an error condition?
Loading history...
399
			$this->proxy = '';
400
		} else {
401
			$this->proxy = Http::getProxy();
402
		}
403
	}
404
405
	/**
406
	 * Set the user agent
407
	 * @param string $UA
408
	 */
409
	public function setUserAgent( $UA ) {
410
		$this->setHeader( 'User-Agent', $UA );
411
	}
412
413
	/**
414
	 * Set an arbitrary header
415
	 * @param string $name
416
	 * @param string $value
417
	 */
418
	public function setHeader( $name, $value ) {
419
		// I feel like I should normalize the case here...
420
		$this->reqHeaders[$name] = $value;
421
	}
422
423
	/**
424
	 * Get an array of the headers
425
	 * @return array
426
	 */
427
	public function getHeaderList() {
428
		$list = [];
429
430
		if ( $this->cookieJar ) {
431
			$this->reqHeaders['Cookie'] =
432
				$this->cookieJar->serializeToHttpRequest(
433
					$this->parsedUrl['path'],
434
					$this->parsedUrl['host']
435
				);
436
		}
437
438
		foreach ( $this->reqHeaders as $name => $value ) {
439
			$list[] = "$name: $value";
440
		}
441
442
		return $list;
443
	}
444
445
	/**
446
	 * Set a read callback to accept data read from the HTTP request.
447
	 * By default, data is appended to an internal buffer which can be
448
	 * retrieved through $req->getContent().
449
	 *
450
	 * To handle data as it comes in -- especially for large files that
451
	 * would not fit in memory -- you can instead set your own callback,
452
	 * in the form function($resource, $buffer) where the first parameter
453
	 * is the low-level resource being read (implementation specific),
454
	 * and the second parameter is the data buffer.
455
	 *
456
	 * You MUST return the number of bytes handled in the buffer; if fewer
457
	 * bytes are reported handled than were passed to you, the HTTP fetch
458
	 * will be aborted.
459
	 *
460
	 * @param callable $callback
461
	 * @throws MWException
462
	 */
463
	public function setCallback( $callback ) {
464
		if ( !is_callable( $callback ) ) {
465
			throw new MWException( 'Invalid MwHttpRequest callback' );
466
		}
467
		$this->callback = $callback;
468
	}
469
470
	/**
471
	 * A generic callback to read the body of the response from a remote
472
	 * server.
473
	 *
474
	 * @param resource $fh
475
	 * @param string $content
476
	 * @return int
477
	 */
478
	public function read( $fh, $content ) {
479
		$this->content .= $content;
480
		return strlen( $content );
481
	}
482
483
	/**
484
	 * Take care of whatever is necessary to perform the URI request.
485
	 *
486
	 * @return Status
487
	 */
488
	public function execute() {
489
490
		$this->content = "";
491
492
		if ( strtoupper( $this->method ) == "HEAD" ) {
493
			$this->headersOnly = true;
494
		}
495
496
		$this->proxySetup(); // set up any proxy as needed
497
498
		if ( !$this->callback ) {
499
			$this->setCallback( [ $this, 'read' ] );
500
		}
501
502
		if ( !isset( $this->reqHeaders['User-Agent'] ) ) {
503
			$this->setUserAgent( Http::userAgent() );
504
		}
505
506
	}
507
508
	/**
509
	 * Parses the headers, including the HTTP status code and any
510
	 * Set-Cookie headers.  This function expects the headers to be
511
	 * found in an array in the member variable headerList.
512
	 */
513
	protected function parseHeader() {
514
515
		$lastname = "";
516
517
		foreach ( $this->headerList as $header ) {
518
			if ( preg_match( "#^HTTP/([0-9.]+) (.*)#", $header, $match ) ) {
519
				$this->respVersion = $match[1];
520
				$this->respStatus = $match[2];
521
			} elseif ( preg_match( "#^[ \t]#", $header ) ) {
522
				$last = count( $this->respHeaders[$lastname] ) - 1;
523
				$this->respHeaders[$lastname][$last] .= "\r\n$header";
524
			} elseif ( preg_match( "#^([^:]*):[\t ]*(.*)#", $header, $match ) ) {
525
				$this->respHeaders[strtolower( $match[1] )][] = $match[2];
526
				$lastname = strtolower( $match[1] );
527
			}
528
		}
529
530
		$this->parseCookies();
531
532
	}
533
534
	/**
535
	 * Sets HTTPRequest status member to a fatal value with the error
536
	 * message if the returned integer value of the status code was
537
	 * not successful (< 300) or a redirect (>=300 and < 400).  (see
538
	 * RFC2616, section 10,
539
	 * http://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html for a
540
	 * list of status codes.)
541
	 */
542
	protected function setStatus() {
543
		if ( !$this->respHeaders ) {
544
			$this->parseHeader();
545
		}
546
547
		if ( (int)$this->respStatus > 399 ) {
548
			list( $code, $message ) = explode( " ", $this->respStatus, 2 );
549
			$this->status->fatal( "http-bad-status", $code, $message );
550
		}
551
	}
552
553
	/**
554
	 * Get the integer value of the HTTP status code (e.g. 200 for "200 Ok")
555
	 * (see RFC2616, section 10, http://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html
556
	 * for a list of status codes.)
557
	 *
558
	 * @return int
559
	 */
560
	public function getStatus() {
561
		if ( !$this->respHeaders ) {
562
			$this->parseHeader();
563
		}
564
565
		return (int)$this->respStatus;
566
	}
567
568
	/**
569
	 * Returns true if the last status code was a redirect.
570
	 *
571
	 * @return bool
572
	 */
573
	public function isRedirect() {
574
		if ( !$this->respHeaders ) {
575
			$this->parseHeader();
576
		}
577
578
		$status = (int)$this->respStatus;
579
580
		if ( $status >= 300 && $status <= 303 ) {
581
			return true;
582
		}
583
584
		return false;
585
	}
586
587
	/**
588
	 * Returns an associative array of response headers after the
589
	 * request has been executed.  Because some headers
590
	 * (e.g. Set-Cookie) can appear more than once the, each value of
591
	 * the associative array is an array of the values given.
592
	 *
593
	 * @return array
594
	 */
595
	public function getResponseHeaders() {
596
		if ( !$this->respHeaders ) {
597
			$this->parseHeader();
598
		}
599
600
		return $this->respHeaders;
601
	}
602
603
	/**
604
	 * Returns the value of the given response header.
605
	 *
606
	 * @param string $header
607
	 * @return string
608
	 */
609
	public function getResponseHeader( $header ) {
610
		if ( !$this->respHeaders ) {
611
			$this->parseHeader();
612
		}
613
614
		if ( isset( $this->respHeaders[strtolower( $header )] ) ) {
615
			$v = $this->respHeaders[strtolower( $header )];
616
			return $v[count( $v ) - 1];
617
		}
618
619
		return null;
620
	}
621
622
	/**
623
	 * Tells the MWHttpRequest object to use this pre-loaded CookieJar.
624
	 *
625
	 * @param CookieJar $jar
626
	 */
627
	public function setCookieJar( $jar ) {
628
		$this->cookieJar = $jar;
629
	}
630
631
	/**
632
	 * Returns the cookie jar in use.
633
	 *
634
	 * @return CookieJar
635
	 */
636
	public function getCookieJar() {
637
		if ( !$this->respHeaders ) {
638
			$this->parseHeader();
639
		}
640
641
		return $this->cookieJar;
642
	}
643
644
	/**
645
	 * Sets a cookie. Used before a request to set up any individual
646
	 * cookies. Used internally after a request to parse the
647
	 * Set-Cookie headers.
648
	 * @see Cookie::set
649
	 * @param string $name
650
	 * @param mixed $value
651
	 * @param array $attr
652
	 */
653
	public function setCookie( $name, $value = null, $attr = null ) {
654
		if ( !$this->cookieJar ) {
655
			$this->cookieJar = new CookieJar;
656
		}
657
658
		$this->cookieJar->setCookie( $name, $value, $attr );
0 ignored issues
show
Bug introduced by
It seems like $attr defined by parameter $attr on line 653 can also be of type null; however, CookieJar::setCookie() does only seem to accept array, maybe add an additional type check?

This check looks at variables that have been passed in as parameters and are passed out again to other methods.

If the outgoing method call has stricter type requirements than the method itself, an issue is raised.

An additional type check may prevent trouble.

Loading history...
659
	}
660
661
	/**
662
	 * Parse the cookies in the response headers and store them in the cookie jar.
663
	 */
664
	protected function parseCookies() {
665
666
		if ( !$this->cookieJar ) {
667
			$this->cookieJar = new CookieJar;
668
		}
669
670
		if ( isset( $this->respHeaders['set-cookie'] ) ) {
671
			$url = parse_url( $this->getFinalUrl() );
672
			foreach ( $this->respHeaders['set-cookie'] as $cookie ) {
673
				$this->cookieJar->parseCookieResponseHeader( $cookie, $url['host'] );
674
			}
675
		}
676
677
	}
678
679
	/**
680
	 * Returns the final URL after all redirections.
681
	 *
682
	 * Relative values of the "Location" header are incorrect as
683
	 * stated in RFC, however they do happen and modern browsers
684
	 * support them.  This function loops backwards through all
685
	 * locations in order to build the proper absolute URI - Marooned
686
	 * at wikia-inc.com
687
	 *
688
	 * Note that the multiple Location: headers are an artifact of
689
	 * CURL -- they shouldn't actually get returned this way. Rewrite
690
	 * this when bug 29232 is taken care of (high-level redirect
691
	 * handling rewrite).
692
	 *
693
	 * @return string
694
	 */
695
	public function getFinalUrl() {
696
		$headers = $this->getResponseHeaders();
697
698
		// return full url (fix for incorrect but handled relative location)
699
		if ( isset( $headers['location'] ) ) {
700
			$locations = $headers['location'];
701
			$domain = '';
702
			$foundRelativeURI = false;
703
			$countLocations = count( $locations );
704
705
			for ( $i = $countLocations - 1; $i >= 0; $i-- ) {
706
				$url = parse_url( $locations[$i] );
707
708
				if ( isset( $url['host'] ) ) {
709
					$domain = $url['scheme'] . '://' . $url['host'];
710
					break; // found correct URI (with host)
711
				} else {
712
					$foundRelativeURI = true;
713
				}
714
			}
715
716
			if ( $foundRelativeURI ) {
717
				if ( $domain ) {
718
					return $domain . $locations[$countLocations - 1];
719
				} else {
720
					$url = parse_url( $this->url );
721
					if ( isset( $url['host'] ) ) {
722
						return $url['scheme'] . '://' . $url['host'] .
723
							$locations[$countLocations - 1];
724
					}
725
				}
726
			} else {
727
				return $locations[$countLocations - 1];
728
			}
729
		}
730
731
		return $this->url;
732
	}
733
734
	/**
735
	 * Returns true if the backend can follow redirects. Overridden by the
736
	 * child classes.
737
	 * @return bool
738
	 */
739
	public function canFollowRedirects() {
740
		return true;
741
	}
742
}
743
744
/**
745
 * MWHttpRequest implemented using internal curl compiled into PHP
746
 */
747
class CurlHttpRequest extends MWHttpRequest {
748
	const SUPPORTS_FILE_POSTS = true;
749
750
	protected $curlOptions = [];
751
	protected $headerText = "";
752
753
	/**
754
	 * @param resource $fh
755
	 * @param string $content
756
	 * @return int
757
	 */
758
	protected function readHeader( $fh, $content ) {
759
		$this->headerText .= $content;
760
		return strlen( $content );
761
	}
762
763
	public function execute() {
764
765
		parent::execute();
766
767
		if ( !$this->status->isOK() ) {
768
			return $this->status;
769
		}
770
771
		$this->curlOptions[CURLOPT_PROXY] = $this->proxy;
772
		$this->curlOptions[CURLOPT_TIMEOUT] = $this->timeout;
773
774
		// Only supported in curl >= 7.16.2
775
		if ( defined( 'CURLOPT_CONNECTTIMEOUT_MS' ) ) {
776
			$this->curlOptions[CURLOPT_CONNECTTIMEOUT_MS] = $this->connectTimeout * 1000;
0 ignored issues
show
Bug introduced by
The property connectTimeout does not seem to exist. Did you mean timeout?

An attempt at access to an undefined property has been detected. This may either be a typographical error or the property has been renamed but there are still references to its old name.

If you really want to allow access to undefined properties, you can define magic methods to allow access. See the php core documentation on Overloading.

Loading history...
777
		}
778
779
		$this->curlOptions[CURLOPT_HTTP_VERSION] = CURL_HTTP_VERSION_1_0;
780
		$this->curlOptions[CURLOPT_WRITEFUNCTION] = $this->callback;
781
		$this->curlOptions[CURLOPT_HEADERFUNCTION] = [ $this, "readHeader" ];
782
		$this->curlOptions[CURLOPT_MAXREDIRS] = $this->maxRedirects;
783
		$this->curlOptions[CURLOPT_ENCODING] = ""; # Enable compression
784
785
		$this->curlOptions[CURLOPT_USERAGENT] = $this->reqHeaders['User-Agent'];
786
787
		$this->curlOptions[CURLOPT_SSL_VERIFYHOST] = $this->sslVerifyHost ? 2 : 0;
788
		$this->curlOptions[CURLOPT_SSL_VERIFYPEER] = $this->sslVerifyCert;
789
790
		if ( $this->caInfo ) {
791
			$this->curlOptions[CURLOPT_CAINFO] = $this->caInfo;
792
		}
793
794
		if ( $this->headersOnly ) {
795
			$this->curlOptions[CURLOPT_NOBODY] = true;
796
			$this->curlOptions[CURLOPT_HEADER] = true;
797
		} elseif ( $this->method == 'POST' ) {
798
			$this->curlOptions[CURLOPT_POST] = true;
799
			$postData = $this->postData;
800
			// Don't interpret POST parameters starting with '@' as file uploads, because this
801
			// makes it impossible to POST plain values starting with '@' (and causes security
802
			// issues potentially exposing the contents of local files).
803
			// The PHP manual says this option was introduced in PHP 5.5 defaults to true in PHP 5.6,
804
			// but we support lower versions, and the option doesn't exist in HHVM 5.6.99.
805
			if ( defined( 'CURLOPT_SAFE_UPLOAD' ) ) {
806
				$this->curlOptions[CURLOPT_SAFE_UPLOAD] = true;
807
			} elseif ( is_array( $postData ) ) {
808
				// In PHP 5.2 and later, '@' is interpreted as a file upload if POSTFIELDS
809
				// is an array, but not if it's a string. So convert $req['body'] to a string
810
				// for safety.
811
				$postData = wfArrayToCgi( $postData );
812
			}
813
			$this->curlOptions[CURLOPT_POSTFIELDS] = $postData;
814
815
			// Suppress 'Expect: 100-continue' header, as some servers
816
			// will reject it with a 417 and Curl won't auto retry
817
			// with HTTP 1.0 fallback
818
			$this->reqHeaders['Expect'] = '';
819
		} else {
820
			$this->curlOptions[CURLOPT_CUSTOMREQUEST] = $this->method;
821
		}
822
823
		$this->curlOptions[CURLOPT_HTTPHEADER] = $this->getHeaderList();
824
825
		$curlHandle = curl_init( $this->url );
826
827
		if ( !curl_setopt_array( $curlHandle, $this->curlOptions ) ) {
828
			throw new MWException( "Error setting curl options." );
829
		}
830
831
		if ( $this->followRedirects && $this->canFollowRedirects() ) {
832
			MediaWiki\suppressWarnings();
833
			if ( !curl_setopt( $curlHandle, CURLOPT_FOLLOWLOCATION, true ) ) {
834
				wfDebug( __METHOD__ . ": Couldn't set CURLOPT_FOLLOWLOCATION. " .
835
					"Probably open_basedir is set.\n" );
836
				// Continue the processing. If it were in curl_setopt_array,
837
				// processing would have halted on its entry
838
			}
839
			MediaWiki\restoreWarnings();
840
		}
841
842
		if ( $this->profiler ) {
843
			$profileSection = $this->profiler->scopedProfileIn(
844
				__METHOD__ . '-' . $this->profileName
845
			);
846
		}
847
848
		$curlRes = curl_exec( $curlHandle );
849
		if ( curl_errno( $curlHandle ) == CURLE_OPERATION_TIMEOUTED ) {
850
			$this->status->fatal( 'http-timed-out', $this->url );
851
		} elseif ( $curlRes === false ) {
852
			$this->status->fatal( 'http-curl-error', curl_error( $curlHandle ) );
853
		} else {
854
			$this->headerList = explode( "\r\n", $this->headerText );
855
		}
856
857
		curl_close( $curlHandle );
858
859
		if ( $this->profiler ) {
860
			$this->profiler->scopedProfileOut( $profileSection );
861
		}
862
863
		$this->parseHeader();
864
		$this->setStatus();
865
866
		return $this->status;
867
	}
868
869
	/**
870
	 * @return bool
871
	 */
872
	public function canFollowRedirects() {
873
		$curlVersionInfo = curl_version();
874
		if ( $curlVersionInfo['version_number'] < 0x071304 ) {
875
			wfDebug( "Cannot follow redirects with libcurl < 7.19.4 due to CVE-2009-0037\n" );
876
			return false;
877
		}
878
879
		if ( version_compare( PHP_VERSION, '5.6.0', '<' ) ) {
880
			if ( strval( ini_get( 'open_basedir' ) ) !== '' ) {
881
				wfDebug( "Cannot follow redirects when open_basedir is set\n" );
882
				return false;
883
			}
884
		}
885
886
		return true;
887
	}
888
}
889
890
class PhpHttpRequest extends MWHttpRequest {
891
892
	private $fopenErrors = [];
893
894
	/**
895
	 * @param string $url
896
	 * @return string
897
	 */
898
	protected function urlToTcp( $url ) {
899
		$parsedUrl = parse_url( $url );
900
901
		return 'tcp://' . $parsedUrl['host'] . ':' . $parsedUrl['port'];
902
	}
903
904
	/**
905
	 * Returns an array with a 'capath' or 'cafile' key
906
	 * that is suitable to be merged into the 'ssl' sub-array of
907
	 * a stream context options array.
908
	 * Uses the 'caInfo' option of the class if it is provided, otherwise uses the system
909
	 * default CA bundle if PHP supports that, or searches a few standard locations.
910
	 * @return array
911
	 * @throws DomainException
912
	 */
913
	protected function getCertOptions() {
914
		$certOptions = [];
915
		$certLocations = [];
916
		if ( $this->caInfo ) {
917
			$certLocations = [ 'manual' => $this->caInfo ];
918
		} elseif ( version_compare( PHP_VERSION, '5.6.0', '<' ) ) {
919
			// @codingStandardsIgnoreStart Generic.Files.LineLength
920
			// Default locations, based on
921
			// https://www.happyassassin.net/2015/01/12/a-note-about-ssltls-trusted-certificate-stores-and-platforms/
922
			// PHP 5.5 and older doesn't have any defaults, so we try to guess ourselves.
923
			// PHP 5.6+ gets the CA location from OpenSSL as long as it is not set manually,
924
			// so we should leave capath/cafile empty there.
925
			// @codingStandardsIgnoreEnd
926
			$certLocations = array_filter( [
927
				getenv( 'SSL_CERT_DIR' ),
928
				getenv( 'SSL_CERT_PATH' ),
929
				'/etc/pki/tls/certs/ca-bundle.crt', # Fedora et al
930
				'/etc/ssl/certs',  # Debian et al
931
				'/etc/pki/tls/certs/ca-bundle.trust.crt',
932
				'/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem',
933
				'/System/Library/OpenSSL', # OSX
934
			] );
935
		}
936
937
		foreach ( $certLocations as $key => $cert ) {
938
			if ( is_dir( $cert ) ) {
939
				$certOptions['capath'] = $cert;
940
				break;
941
			} elseif ( is_file( $cert ) ) {
942
				$certOptions['cafile'] = $cert;
943
				break;
944
			} elseif ( $key === 'manual' ) {
945
				// fail more loudly if a cert path was manually configured and it is not valid
946
				throw new DomainException( "Invalid CA info passed: $cert" );
947
			}
948
		}
949
950
		return $certOptions;
951
	}
952
953
	/**
954
	 * Custom error handler for dealing with fopen() errors.
955
	 * fopen() tends to fire multiple errors in succession, and the last one
956
	 * is completely useless (something like "fopen: failed to open stream")
957
	 * so normal methods of handling errors programmatically
958
	 * like get_last_error() don't work.
959
	 */
960
	public function errorHandler( $errno, $errstr ) {
961
		$n = count( $this->fopenErrors ) + 1;
962
		$this->fopenErrors += [ "errno$n" => $errno, "errstr$n" => $errstr ];
963
	}
964
965
	public function execute() {
966
967
		parent::execute();
968
969
		if ( is_array( $this->postData ) ) {
970
			$this->postData = wfArrayToCgi( $this->postData );
971
		}
972
973
		if ( $this->parsedUrl['scheme'] != 'http'
974
			&& $this->parsedUrl['scheme'] != 'https' ) {
975
			$this->status->fatal( 'http-invalid-scheme', $this->parsedUrl['scheme'] );
976
		}
977
978
		$this->reqHeaders['Accept'] = "*/*";
979
		$this->reqHeaders['Connection'] = 'Close';
980
		if ( $this->method == 'POST' ) {
981
			// Required for HTTP 1.0 POSTs
982
			$this->reqHeaders['Content-Length'] = strlen( $this->postData );
983
			if ( !isset( $this->reqHeaders['Content-Type'] ) ) {
984
				$this->reqHeaders['Content-Type'] = "application/x-www-form-urlencoded";
985
			}
986
		}
987
988
		// Set up PHP stream context
989
		$options = [
990
			'http' => [
991
				'method' => $this->method,
992
				'header' => implode( "\r\n", $this->getHeaderList() ),
993
				'protocol_version' => '1.1',
994
				'max_redirects' => $this->followRedirects ? $this->maxRedirects : 0,
995
				'ignore_errors' => true,
996
				'timeout' => $this->timeout,
997
				// Curl options in case curlwrappers are installed
998
				'curl_verify_ssl_host' => $this->sslVerifyHost ? 2 : 0,
999
				'curl_verify_ssl_peer' => $this->sslVerifyCert,
1000
			],
1001
			'ssl' => [
1002
				'verify_peer' => $this->sslVerifyCert,
1003
				'SNI_enabled' => true,
1004
				'ciphers' => 'HIGH:!SSLv2:!SSLv3:-ADH:-kDH:-kECDH:-DSS',
1005
				'disable_compression' => true,
1006
			],
1007
		];
1008
1009
		if ( $this->proxy ) {
1010
			$options['http']['proxy'] = $this->urlToTcp( $this->proxy );
1011
			$options['http']['request_fulluri'] = true;
1012
		}
1013
1014
		if ( $this->postData ) {
1015
			$options['http']['content'] = $this->postData;
1016
		}
1017
1018
		if ( $this->sslVerifyHost ) {
1019
			// PHP 5.6.0 deprecates CN_match, in favour of peer_name which
1020
			// actually checks SubjectAltName properly.
1021
			if ( version_compare( PHP_VERSION, '5.6.0', '>=' ) ) {
1022
				$options['ssl']['peer_name'] = $this->parsedUrl['host'];
1023
			} else {
1024
				$options['ssl']['CN_match'] = $this->parsedUrl['host'];
1025
			}
1026
		}
1027
1028
		$options['ssl'] += $this->getCertOptions();
1029
1030
		$context = stream_context_create( $options );
1031
1032
		$this->headerList = [];
1033
		$reqCount = 0;
1034
		$url = $this->url;
1035
1036
		$result = [];
1037
1038
		if ( $this->profiler ) {
1039
			$profileSection = $this->profiler->scopedProfileIn(
1040
				__METHOD__ . '-' . $this->profileName
1041
			);
1042
		}
1043
		do {
1044
			$reqCount++;
1045
			$this->fopenErrors = [];
1046
			set_error_handler( [ $this, 'errorHandler' ] );
1047
			$fh = fopen( $url, "r", false, $context );
1048
			restore_error_handler();
1049
1050
			if ( !$fh ) {
1051
				// HACK for instant commons.
1052
				// If we are contacting (commons|upload).wikimedia.org
1053
				// try again with CN_match for en.wikipedia.org
1054
				// as php does not handle SubjectAltName properly
1055
				// prior to "peer_name" option in php 5.6
1056
				if ( isset( $options['ssl']['CN_match'] )
1057
					&& ( $options['ssl']['CN_match'] === 'commons.wikimedia.org'
1058
						|| $options['ssl']['CN_match'] === 'upload.wikimedia.org' )
1059
				) {
1060
					$options['ssl']['CN_match'] = 'en.wikipedia.org';
1061
					$context = stream_context_create( $options );
1062
					continue;
1063
				}
1064
				break;
1065
			}
1066
1067
			$result = stream_get_meta_data( $fh );
1068
			$this->headerList = $result['wrapper_data'];
1069
			$this->parseHeader();
1070
1071
			if ( !$this->followRedirects ) {
1072
				break;
1073
			}
1074
1075
			# Handle manual redirection
1076
			if ( !$this->isRedirect() || $reqCount > $this->maxRedirects ) {
1077
				break;
1078
			}
1079
			# Check security of URL
1080
			$url = $this->getResponseHeader( "Location" );
1081
1082
			if ( !Http::isValidURI( $url ) ) {
1083
				wfDebug( __METHOD__ . ": insecure redirection\n" );
1084
				break;
1085
			}
1086
		} while ( true );
1087
		if ( $this->profiler ) {
1088
			$this->profiler->scopedProfileOut( $profileSection );
1089
		}
1090
1091
		$this->setStatus();
1092
1093
		if ( $fh === false ) {
1094
			if ( $this->fopenErrors ) {
1095
				LoggerFactory::getInstance( 'http' )->warning( __CLASS__
1096
					. ': error opening connection: {errstr1}', $this->fopenErrors );
1097
			}
1098
			$this->status->fatal( 'http-request-error' );
1099
			return $this->status;
1100
		}
1101
1102
		if ( $result['timed_out'] ) {
1103
			$this->status->fatal( 'http-timed-out', $this->url );
1104
			return $this->status;
1105
		}
1106
1107
		// If everything went OK, or we received some error code
1108
		// get the response body content.
1109
		if ( $this->status->isOK() || (int)$this->respStatus >= 300 ) {
1110
			while ( !feof( $fh ) ) {
1111
				$buf = fread( $fh, 8192 );
1112
1113
				if ( $buf === false ) {
1114
					$this->status->fatal( 'http-read-error' );
1115
					break;
1116
				}
1117
1118
				if ( strlen( $buf ) ) {
1119
					call_user_func( $this->callback, $fh, $buf );
1120
				}
1121
			}
1122
		}
1123
		fclose( $fh );
1124
1125
		return $this->status;
1126
	}
1127
}
1128