Completed
Branch master (5998bb)
by
unknown
29:17
created

Http::isLocalURL()   B

Complexity

Conditions 6
Paths 5

Size

Total Lines 34
Code Lines 20

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 6
eloc 20
nc 5
nop 1
dl 0
loc 34
rs 8.439
c 0
b 0
f 0

1 Method

Rating   Name   Duplication   Size   Complexity  
A Http::isValidURI() 0 6 1
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
	 * A standard user-agent we can use for external requests.
129
	 * @return string
130
	 */
131
	public static function userAgent() {
132
		global $wgVersion;
133
		return "MediaWiki/$wgVersion";
134
	}
135
136
	/**
137
	 * Checks that the given URI is a valid one. Hardcoding the
138
	 * protocols, because we only want protocols that both cURL
139
	 * and php support.
140
	 *
141
	 * file:// should not be allowed here for security purpose (r67684)
142
	 *
143
	 * @todo FIXME this is wildly inaccurate and fails to actually check most stuff
144
	 *
145
	 * @param string $uri URI to check for validity
146
	 * @return bool
147
	 */
148
	public static function isValidURI( $uri ) {
149
		return preg_match(
150
			'/^https?:\/\/[^\/\s]\S*$/D',
151
			$uri
152
		);
153
	}
154
155
	/**
156
	 * Gets the relevant proxy from $wgHTTPProxy
157
	 *
158
	 * @return mixed The proxy address or an empty string if not set.
159
	 */
160
	public static function getProxy() {
161
		global $wgHTTPProxy;
162
163
		if ( $wgHTTPProxy ) {
164
			return $wgHTTPProxy;
165
		}
166
167
		return "";
168
	}
169
}
170
171
/**
172
 * This wrapper class will call out to curl (if available) or fallback
173
 * to regular PHP if necessary for handling internal HTTP requests.
174
 *
175
 * Renamed from HttpRequest to MWHttpRequest to avoid conflict with
176
 * PHP's HTTP extension.
177
 */
178
class MWHttpRequest {
179
	const SUPPORTS_FILE_POSTS = false;
180
181
	protected $content;
182
	protected $timeout = 'default';
183
	protected $headersOnly = null;
184
	protected $postData = null;
185
	protected $proxy = null;
186
	protected $noProxy = false;
187
	protected $sslVerifyHost = true;
188
	protected $sslVerifyCert = true;
189
	protected $caInfo = null;
190
	protected $method = "GET";
191
	protected $reqHeaders = [];
192
	protected $url;
193
	protected $parsedUrl;
194
	protected $callback;
195
	protected $maxRedirects = 5;
196
	protected $followRedirects = false;
197
198
	/**
199
	 * @var CookieJar
200
	 */
201
	protected $cookieJar;
202
203
	protected $headerList = [];
204
	protected $respVersion = "0.9";
205
	protected $respStatus = "200 Ok";
206
	protected $respHeaders = [];
207
208
	public $status;
209
210
	/**
211
	 * @var Profiler
212
	 */
213
	protected $profiler;
214
215
	/**
216
	 * @var string
217
	 */
218
	protected $profileName;
219
220
	/**
221
	 * @param string $url Url to use. If protocol-relative, will be expanded to an http:// URL
222
	 * @param array $options (optional) extra params to pass (see Http::request())
223
	 * @param string $caller The method making this request, for profiling
224
	 * @param Profiler $profiler An instance of the profiler for profiling, or null
225
	 */
226
	protected function __construct(
227
		$url, $options = [], $caller = __METHOD__, $profiler = null
228
	) {
229
		global $wgHTTPTimeout, $wgHTTPConnectTimeout;
230
231
		$this->url = wfExpandUrl( $url, PROTO_HTTP );
232
		$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...
233
234
		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...
235
			$this->status = Status::newFatal( 'http-invalid-url', $url );
236
		} else {
237
			$this->status = Status::newGood( 100 ); // continue
238
		}
239
240 View Code Duplication
		if ( isset( $options['timeout'] ) && $options['timeout'] != 'default' ) {
241
			$this->timeout = $options['timeout'];
242
		} else {
243
			$this->timeout = $wgHTTPTimeout;
244
		}
245 View Code Duplication
		if ( isset( $options['connectTimeout'] ) && $options['connectTimeout'] != 'default' ) {
246
			$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...
247
		} else {
248
			$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...
249
		}
250
		if ( isset( $options['userAgent'] ) ) {
251
			$this->setUserAgent( $options['userAgent'] );
252
		}
253
254
		$members = [ "postData", "proxy", "noProxy", "sslVerifyHost", "caInfo",
255
				"method", "followRedirects", "maxRedirects", "sslVerifyCert", "callback" ];
256
257
		foreach ( $members as $o ) {
258
			if ( isset( $options[$o] ) ) {
259
				// ensure that MWHttpRequest::method is always
260
				// uppercased. Bug 36137
261
				if ( $o == 'method' ) {
262
					$options[$o] = strtoupper( $options[$o] );
263
				}
264
				$this->$o = $options[$o];
265
			}
266
		}
267
268
		if ( $this->noProxy ) {
269
			$this->proxy = ''; // noProxy takes precedence
270
		}
271
272
		// Profile based on what's calling us
273
		$this->profiler = $profiler;
274
		$this->profileName = $caller;
275
	}
276
277
	/**
278
	 * Simple function to test if we can make any sort of requests at all, using
279
	 * cURL or fopen()
280
	 * @return bool
281
	 */
282
	public static function canMakeRequests() {
283
		return function_exists( 'curl_init' ) || wfIniGetBool( 'allow_url_fopen' );
284
	}
285
286
	/**
287
	 * Generate a new request object
288
	 * @param string $url Url to use
289
	 * @param array $options (optional) extra params to pass (see Http::request())
290
	 * @param string $caller The method making this request, for profiling
291
	 * @throws MWException
292
	 * @return CurlHttpRequest|PhpHttpRequest
293
	 * @see MWHttpRequest::__construct
294
	 */
295
	public static function factory( $url, $options = null, $caller = __METHOD__ ) {
296
		if ( !Http::$httpEngine ) {
297
			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...
298
		} elseif ( Http::$httpEngine == 'curl' && !function_exists( 'curl_init' ) ) {
299
			throw new MWException( __METHOD__ . ': curl (http://php.net/curl) is not installed, but' .
300
				' Http::$httpEngine is set to "curl"' );
301
		}
302
303
		switch ( Http::$httpEngine ) {
304
			case 'curl':
305
				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 295 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...
306
			case 'php':
307
				if ( !wfIniGetBool( 'allow_url_fopen' ) ) {
308
					throw new MWException( __METHOD__ . ': allow_url_fopen ' .
309
						'needs to be enabled for pure PHP http requests to ' .
310
						'work. If possible, curl should be used instead. See ' .
311
						'http://php.net/curl.'
312
					);
313
				}
314
				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 295 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...
315
			default:
316
				throw new MWException( __METHOD__ . ': The setting of Http::$httpEngine is not valid.' );
317
		}
318
	}
319
320
	/**
321
	 * Get the body, or content, of the response to the request
322
	 *
323
	 * @return string
324
	 */
325
	public function getContent() {
326
		return $this->content;
327
	}
328
329
	/**
330
	 * Set the parameters of the request
331
	 *
332
	 * @param array $args
333
	 * @todo overload the args param
334
	 */
335
	public function setData( $args ) {
336
		$this->postData = $args;
337
	}
338
339
	/**
340
	 * Take care of setting up the proxy (do nothing if "noProxy" is set)
341
	 *
342
	 * @return void
343
	 */
344
	public function proxySetup() {
345
		// If there is an explicit proxy set and proxies are not disabled, then use it
346
		if ( $this->proxy && !$this->noProxy ) {
347
			return;
348
		}
349
350
		// Otherwise, fallback to $wgHTTPProxy if this is not a machine
351
		// local URL and proxies are not disabled
352
		if ( self::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, MWHttpRequest::isLocalURL() does only seem to accept string, did you maybe forget to handle an error condition?
Loading history...
353
			$this->proxy = '';
354
		} else {
355
			$this->proxy = Http::getProxy();
356
		}
357
	}
358
359
	/**
360
	 * Check if the URL can be served by localhost
361
	 *
362
	 * @param string $url Full url to check
363
	 * @return bool
364
	 */
365
	private static function isLocalURL( $url ) {
366
		global $wgCommandLineMode, $wgLocalVirtualHosts;
367
368
		if ( $wgCommandLineMode ) {
369
			return false;
370
		}
371
372
		// Extract host part
373
		$matches = [];
374
		if ( preg_match( '!^https?://([\w.-]+)[/:].*$!', $url, $matches ) ) {
375
			$host = $matches[1];
376
			// Split up dotwise
377
			$domainParts = explode( '.', $host );
378
			// Check if this domain or any superdomain is listed as a local virtual host
379
			$domainParts = array_reverse( $domainParts );
380
381
			$domain = '';
382
			$countParts = count( $domainParts );
383
			for ( $i = 0; $i < $countParts; $i++ ) {
384
				$domainPart = $domainParts[$i];
385
				if ( $i == 0 ) {
386
					$domain = $domainPart;
387
				} else {
388
					$domain = $domainPart . '.' . $domain;
389
				}
390
391
				if ( in_array( $domain, $wgLocalVirtualHosts ) ) {
392
					return true;
393
				}
394
			}
395
		}
396
397
		return false;
398
	}
399
400
	/**
401
	 * Set the user agent
402
	 * @param string $UA
403
	 */
404
	public function setUserAgent( $UA ) {
405
		$this->setHeader( 'User-Agent', $UA );
406
	}
407
408
	/**
409
	 * Set an arbitrary header
410
	 * @param string $name
411
	 * @param string $value
412
	 */
413
	public function setHeader( $name, $value ) {
414
		// I feel like I should normalize the case here...
415
		$this->reqHeaders[$name] = $value;
416
	}
417
418
	/**
419
	 * Get an array of the headers
420
	 * @return array
421
	 */
422
	public function getHeaderList() {
423
		$list = [];
424
425
		if ( $this->cookieJar ) {
426
			$this->reqHeaders['Cookie'] =
427
				$this->cookieJar->serializeToHttpRequest(
428
					$this->parsedUrl['path'],
429
					$this->parsedUrl['host']
430
				);
431
		}
432
433
		foreach ( $this->reqHeaders as $name => $value ) {
434
			$list[] = "$name: $value";
435
		}
436
437
		return $list;
438
	}
439
440
	/**
441
	 * Set a read callback to accept data read from the HTTP request.
442
	 * By default, data is appended to an internal buffer which can be
443
	 * retrieved through $req->getContent().
444
	 *
445
	 * To handle data as it comes in -- especially for large files that
446
	 * would not fit in memory -- you can instead set your own callback,
447
	 * in the form function($resource, $buffer) where the first parameter
448
	 * is the low-level resource being read (implementation specific),
449
	 * and the second parameter is the data buffer.
450
	 *
451
	 * You MUST return the number of bytes handled in the buffer; if fewer
452
	 * bytes are reported handled than were passed to you, the HTTP fetch
453
	 * will be aborted.
454
	 *
455
	 * @param callable $callback
456
	 * @throws MWException
457
	 */
458
	public function setCallback( $callback ) {
459
		if ( !is_callable( $callback ) ) {
460
			throw new MWException( 'Invalid MwHttpRequest callback' );
461
		}
462
		$this->callback = $callback;
463
	}
464
465
	/**
466
	 * A generic callback to read the body of the response from a remote
467
	 * server.
468
	 *
469
	 * @param resource $fh
470
	 * @param string $content
471
	 * @return int
472
	 */
473
	public function read( $fh, $content ) {
474
		$this->content .= $content;
475
		return strlen( $content );
476
	}
477
478
	/**
479
	 * Take care of whatever is necessary to perform the URI request.
480
	 *
481
	 * @return Status
482
	 */
483
	public function execute() {
484
485
		$this->content = "";
486
487
		if ( strtoupper( $this->method ) == "HEAD" ) {
488
			$this->headersOnly = true;
489
		}
490
491
		$this->proxySetup(); // set up any proxy as needed
492
493
		if ( !$this->callback ) {
494
			$this->setCallback( [ $this, 'read' ] );
495
		}
496
497
		if ( !isset( $this->reqHeaders['User-Agent'] ) ) {
498
			$this->setUserAgent( Http::userAgent() );
499
		}
500
501
	}
502
503
	/**
504
	 * Parses the headers, including the HTTP status code and any
505
	 * Set-Cookie headers.  This function expects the headers to be
506
	 * found in an array in the member variable headerList.
507
	 */
508
	protected function parseHeader() {
509
510
		$lastname = "";
511
512
		foreach ( $this->headerList as $header ) {
513
			if ( preg_match( "#^HTTP/([0-9.]+) (.*)#", $header, $match ) ) {
514
				$this->respVersion = $match[1];
515
				$this->respStatus = $match[2];
516
			} elseif ( preg_match( "#^[ \t]#", $header ) ) {
517
				$last = count( $this->respHeaders[$lastname] ) - 1;
518
				$this->respHeaders[$lastname][$last] .= "\r\n$header";
519
			} elseif ( preg_match( "#^([^:]*):[\t ]*(.*)#", $header, $match ) ) {
520
				$this->respHeaders[strtolower( $match[1] )][] = $match[2];
521
				$lastname = strtolower( $match[1] );
522
			}
523
		}
524
525
		$this->parseCookies();
526
527
	}
528
529
	/**
530
	 * Sets HTTPRequest status member to a fatal value with the error
531
	 * message if the returned integer value of the status code was
532
	 * not successful (< 300) or a redirect (>=300 and < 400).  (see
533
	 * RFC2616, section 10,
534
	 * http://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html for a
535
	 * list of status codes.)
536
	 */
537
	protected function setStatus() {
538
		if ( !$this->respHeaders ) {
539
			$this->parseHeader();
540
		}
541
542
		if ( (int)$this->respStatus > 399 ) {
543
			list( $code, $message ) = explode( " ", $this->respStatus, 2 );
544
			$this->status->fatal( "http-bad-status", $code, $message );
545
		}
546
	}
547
548
	/**
549
	 * Get the integer value of the HTTP status code (e.g. 200 for "200 Ok")
550
	 * (see RFC2616, section 10, http://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html
551
	 * for a list of status codes.)
552
	 *
553
	 * @return int
554
	 */
555
	public function getStatus() {
556
		if ( !$this->respHeaders ) {
557
			$this->parseHeader();
558
		}
559
560
		return (int)$this->respStatus;
561
	}
562
563
	/**
564
	 * Returns true if the last status code was a redirect.
565
	 *
566
	 * @return bool
567
	 */
568
	public function isRedirect() {
569
		if ( !$this->respHeaders ) {
570
			$this->parseHeader();
571
		}
572
573
		$status = (int)$this->respStatus;
574
575
		if ( $status >= 300 && $status <= 303 ) {
576
			return true;
577
		}
578
579
		return false;
580
	}
581
582
	/**
583
	 * Returns an associative array of response headers after the
584
	 * request has been executed.  Because some headers
585
	 * (e.g. Set-Cookie) can appear more than once the, each value of
586
	 * the associative array is an array of the values given.
587
	 *
588
	 * @return array
589
	 */
590
	public function getResponseHeaders() {
591
		if ( !$this->respHeaders ) {
592
			$this->parseHeader();
593
		}
594
595
		return $this->respHeaders;
596
	}
597
598
	/**
599
	 * Returns the value of the given response header.
600
	 *
601
	 * @param string $header
602
	 * @return string
603
	 */
604
	public function getResponseHeader( $header ) {
605
		if ( !$this->respHeaders ) {
606
			$this->parseHeader();
607
		}
608
609
		if ( isset( $this->respHeaders[strtolower( $header )] ) ) {
610
			$v = $this->respHeaders[strtolower( $header )];
611
			return $v[count( $v ) - 1];
612
		}
613
614
		return null;
615
	}
616
617
	/**
618
	 * Tells the MWHttpRequest object to use this pre-loaded CookieJar.
619
	 *
620
	 * @param CookieJar $jar
621
	 */
622
	public function setCookieJar( $jar ) {
623
		$this->cookieJar = $jar;
624
	}
625
626
	/**
627
	 * Returns the cookie jar in use.
628
	 *
629
	 * @return CookieJar
630
	 */
631
	public function getCookieJar() {
632
		if ( !$this->respHeaders ) {
633
			$this->parseHeader();
634
		}
635
636
		return $this->cookieJar;
637
	}
638
639
	/**
640
	 * Sets a cookie. Used before a request to set up any individual
641
	 * cookies. Used internally after a request to parse the
642
	 * Set-Cookie headers.
643
	 * @see Cookie::set
644
	 * @param string $name
645
	 * @param mixed $value
646
	 * @param array $attr
647
	 */
648
	public function setCookie( $name, $value = null, $attr = null ) {
649
		if ( !$this->cookieJar ) {
650
			$this->cookieJar = new CookieJar;
651
		}
652
653
		$this->cookieJar->setCookie( $name, $value, $attr );
0 ignored issues
show
Bug introduced by
It seems like $attr defined by parameter $attr on line 648 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...
654
	}
655
656
	/**
657
	 * Parse the cookies in the response headers and store them in the cookie jar.
658
	 */
659
	protected function parseCookies() {
660
661
		if ( !$this->cookieJar ) {
662
			$this->cookieJar = new CookieJar;
663
		}
664
665
		if ( isset( $this->respHeaders['set-cookie'] ) ) {
666
			$url = parse_url( $this->getFinalUrl() );
667
			foreach ( $this->respHeaders['set-cookie'] as $cookie ) {
668
				$this->cookieJar->parseCookieResponseHeader( $cookie, $url['host'] );
669
			}
670
		}
671
672
	}
673
674
	/**
675
	 * Returns the final URL after all redirections.
676
	 *
677
	 * Relative values of the "Location" header are incorrect as
678
	 * stated in RFC, however they do happen and modern browsers
679
	 * support them.  This function loops backwards through all
680
	 * locations in order to build the proper absolute URI - Marooned
681
	 * at wikia-inc.com
682
	 *
683
	 * Note that the multiple Location: headers are an artifact of
684
	 * CURL -- they shouldn't actually get returned this way. Rewrite
685
	 * this when bug 29232 is taken care of (high-level redirect
686
	 * handling rewrite).
687
	 *
688
	 * @return string
689
	 */
690
	public function getFinalUrl() {
691
		$headers = $this->getResponseHeaders();
692
693
		// return full url (fix for incorrect but handled relative location)
694
		if ( isset( $headers['location'] ) ) {
695
			$locations = $headers['location'];
696
			$domain = '';
697
			$foundRelativeURI = false;
698
			$countLocations = count( $locations );
699
700
			for ( $i = $countLocations - 1; $i >= 0; $i-- ) {
701
				$url = parse_url( $locations[$i] );
702
703
				if ( isset( $url['host'] ) ) {
704
					$domain = $url['scheme'] . '://' . $url['host'];
705
					break; // found correct URI (with host)
706
				} else {
707
					$foundRelativeURI = true;
708
				}
709
			}
710
711
			if ( $foundRelativeURI ) {
712
				if ( $domain ) {
713
					return $domain . $locations[$countLocations - 1];
714
				} else {
715
					$url = parse_url( $this->url );
716
					if ( isset( $url['host'] ) ) {
717
						return $url['scheme'] . '://' . $url['host'] .
718
							$locations[$countLocations - 1];
719
					}
720
				}
721
			} else {
722
				return $locations[$countLocations - 1];
723
			}
724
		}
725
726
		return $this->url;
727
	}
728
729
	/**
730
	 * Returns true if the backend can follow redirects. Overridden by the
731
	 * child classes.
732
	 * @return bool
733
	 */
734
	public function canFollowRedirects() {
735
		return true;
736
	}
737
}
738
739
/**
740
 * MWHttpRequest implemented using internal curl compiled into PHP
741
 */
742
class CurlHttpRequest extends MWHttpRequest {
743
	const SUPPORTS_FILE_POSTS = true;
744
745
	protected $curlOptions = [];
746
	protected $headerText = "";
747
748
	/**
749
	 * @param resource $fh
750
	 * @param string $content
751
	 * @return int
752
	 */
753
	protected function readHeader( $fh, $content ) {
754
		$this->headerText .= $content;
755
		return strlen( $content );
756
	}
757
758
	public function execute() {
759
760
		parent::execute();
761
762
		if ( !$this->status->isOK() ) {
763
			return $this->status;
764
		}
765
766
		$this->curlOptions[CURLOPT_PROXY] = $this->proxy;
767
		$this->curlOptions[CURLOPT_TIMEOUT] = $this->timeout;
768
769
		// Only supported in curl >= 7.16.2
770
		if ( defined( 'CURLOPT_CONNECTTIMEOUT_MS' ) ) {
771
			$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...
772
		}
773
774
		$this->curlOptions[CURLOPT_HTTP_VERSION] = CURL_HTTP_VERSION_1_0;
775
		$this->curlOptions[CURLOPT_WRITEFUNCTION] = $this->callback;
776
		$this->curlOptions[CURLOPT_HEADERFUNCTION] = [ $this, "readHeader" ];
777
		$this->curlOptions[CURLOPT_MAXREDIRS] = $this->maxRedirects;
778
		$this->curlOptions[CURLOPT_ENCODING] = ""; # Enable compression
779
780
		$this->curlOptions[CURLOPT_USERAGENT] = $this->reqHeaders['User-Agent'];
781
782
		$this->curlOptions[CURLOPT_SSL_VERIFYHOST] = $this->sslVerifyHost ? 2 : 0;
783
		$this->curlOptions[CURLOPT_SSL_VERIFYPEER] = $this->sslVerifyCert;
784
785
		if ( $this->caInfo ) {
786
			$this->curlOptions[CURLOPT_CAINFO] = $this->caInfo;
787
		}
788
789
		if ( $this->headersOnly ) {
790
			$this->curlOptions[CURLOPT_NOBODY] = true;
791
			$this->curlOptions[CURLOPT_HEADER] = true;
792
		} elseif ( $this->method == 'POST' ) {
793
			$this->curlOptions[CURLOPT_POST] = true;
794
			$postData = $this->postData;
795
			// Don't interpret POST parameters starting with '@' as file uploads, because this
796
			// makes it impossible to POST plain values starting with '@' (and causes security
797
			// issues potentially exposing the contents of local files).
798
			// The PHP manual says this option was introduced in PHP 5.5 defaults to true in PHP 5.6,
799
			// but we support lower versions, and the option doesn't exist in HHVM 5.6.99.
800
			if ( defined( 'CURLOPT_SAFE_UPLOAD' ) ) {
801
				$this->curlOptions[CURLOPT_SAFE_UPLOAD] = true;
802
			} elseif ( is_array( $postData ) ) {
803
				// In PHP 5.2 and later, '@' is interpreted as a file upload if POSTFIELDS
804
				// is an array, but not if it's a string. So convert $req['body'] to a string
805
				// for safety.
806
				$postData = wfArrayToCgi( $postData );
807
			}
808
			$this->curlOptions[CURLOPT_POSTFIELDS] = $postData;
809
810
			// Suppress 'Expect: 100-continue' header, as some servers
811
			// will reject it with a 417 and Curl won't auto retry
812
			// with HTTP 1.0 fallback
813
			$this->reqHeaders['Expect'] = '';
814
		} else {
815
			$this->curlOptions[CURLOPT_CUSTOMREQUEST] = $this->method;
816
		}
817
818
		$this->curlOptions[CURLOPT_HTTPHEADER] = $this->getHeaderList();
819
820
		$curlHandle = curl_init( $this->url );
821
822
		if ( !curl_setopt_array( $curlHandle, $this->curlOptions ) ) {
823
			throw new MWException( "Error setting curl options." );
824
		}
825
826
		if ( $this->followRedirects && $this->canFollowRedirects() ) {
827
			MediaWiki\suppressWarnings();
828
			if ( !curl_setopt( $curlHandle, CURLOPT_FOLLOWLOCATION, true ) ) {
829
				wfDebug( __METHOD__ . ": Couldn't set CURLOPT_FOLLOWLOCATION. " .
830
					"Probably open_basedir is set.\n" );
831
				// Continue the processing. If it were in curl_setopt_array,
832
				// processing would have halted on its entry
833
			}
834
			MediaWiki\restoreWarnings();
835
		}
836
837
		if ( $this->profiler ) {
838
			$profileSection = $this->profiler->scopedProfileIn(
839
				__METHOD__ . '-' . $this->profileName
840
			);
841
		}
842
843
		$curlRes = curl_exec( $curlHandle );
844
		if ( curl_errno( $curlHandle ) == CURLE_OPERATION_TIMEOUTED ) {
845
			$this->status->fatal( 'http-timed-out', $this->url );
846
		} elseif ( $curlRes === false ) {
847
			$this->status->fatal( 'http-curl-error', curl_error( $curlHandle ) );
848
		} else {
849
			$this->headerList = explode( "\r\n", $this->headerText );
850
		}
851
852
		curl_close( $curlHandle );
853
854
		if ( $this->profiler ) {
855
			$this->profiler->scopedProfileOut( $profileSection );
856
		}
857
858
		$this->parseHeader();
859
		$this->setStatus();
860
861
		return $this->status;
862
	}
863
864
	/**
865
	 * @return bool
866
	 */
867
	public function canFollowRedirects() {
868
		$curlVersionInfo = curl_version();
869
		if ( $curlVersionInfo['version_number'] < 0x071304 ) {
870
			wfDebug( "Cannot follow redirects with libcurl < 7.19.4 due to CVE-2009-0037\n" );
871
			return false;
872
		}
873
874
		if ( version_compare( PHP_VERSION, '5.6.0', '<' ) ) {
875
			if ( strval( ini_get( 'open_basedir' ) ) !== '' ) {
876
				wfDebug( "Cannot follow redirects when open_basedir is set\n" );
877
				return false;
878
			}
879
		}
880
881
		return true;
882
	}
883
}
884
885
class PhpHttpRequest extends MWHttpRequest {
886
887
	private $fopenErrors = [];
888
889
	/**
890
	 * @param string $url
891
	 * @return string
892
	 */
893
	protected function urlToTcp( $url ) {
894
		$parsedUrl = parse_url( $url );
895
896
		return 'tcp://' . $parsedUrl['host'] . ':' . $parsedUrl['port'];
897
	}
898
899
	/**
900
	 * Returns an array with a 'capath' or 'cafile' key
901
	 * that is suitable to be merged into the 'ssl' sub-array of
902
	 * a stream context options array.
903
	 * Uses the 'caInfo' option of the class if it is provided, otherwise uses the system
904
	 * default CA bundle if PHP supports that, or searches a few standard locations.
905
	 * @return array
906
	 * @throws DomainException
907
	 */
908
	protected function getCertOptions() {
909
		$certOptions = [];
910
		$certLocations = [];
911
		if ( $this->caInfo ) {
912
			$certLocations = [ 'manual' => $this->caInfo ];
913
		} elseif ( version_compare( PHP_VERSION, '5.6.0', '<' ) ) {
914
			// @codingStandardsIgnoreStart Generic.Files.LineLength
915
			// Default locations, based on
916
			// https://www.happyassassin.net/2015/01/12/a-note-about-ssltls-trusted-certificate-stores-and-platforms/
917
			// PHP 5.5 and older doesn't have any defaults, so we try to guess ourselves.
918
			// PHP 5.6+ gets the CA location from OpenSSL as long as it is not set manually,
919
			// so we should leave capath/cafile empty there.
920
			// @codingStandardsIgnoreEnd
921
			$certLocations = array_filter( [
922
				getenv( 'SSL_CERT_DIR' ),
923
				getenv( 'SSL_CERT_PATH' ),
924
				'/etc/pki/tls/certs/ca-bundle.crt', # Fedora et al
925
				'/etc/ssl/certs',  # Debian et al
926
				'/etc/pki/tls/certs/ca-bundle.trust.crt',
927
				'/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem',
928
				'/System/Library/OpenSSL', # OSX
929
			] );
930
		}
931
932
		foreach ( $certLocations as $key => $cert ) {
933
			if ( is_dir( $cert ) ) {
934
				$certOptions['capath'] = $cert;
935
				break;
936
			} elseif ( is_file( $cert ) ) {
937
				$certOptions['cafile'] = $cert;
938
				break;
939
			} elseif ( $key === 'manual' ) {
940
				// fail more loudly if a cert path was manually configured and it is not valid
941
				throw new DomainException( "Invalid CA info passed: $cert" );
942
			}
943
		}
944
945
		return $certOptions;
946
	}
947
948
	/**
949
	 * Custom error handler for dealing with fopen() errors.
950
	 * fopen() tends to fire multiple errors in succession, and the last one
951
	 * is completely useless (something like "fopen: failed to open stream")
952
	 * so normal methods of handling errors programmatically
953
	 * like get_last_error() don't work.
954
	 */
955
	public function errorHandler( $errno, $errstr ) {
956
		$n = count( $this->fopenErrors ) + 1;
957
		$this->fopenErrors += [ "errno$n" => $errno, "errstr$n" => $errstr ];
958
	}
959
960
	public function execute() {
961
962
		parent::execute();
963
964
		if ( is_array( $this->postData ) ) {
965
			$this->postData = wfArrayToCgi( $this->postData );
966
		}
967
968
		if ( $this->parsedUrl['scheme'] != 'http'
969
			&& $this->parsedUrl['scheme'] != 'https' ) {
970
			$this->status->fatal( 'http-invalid-scheme', $this->parsedUrl['scheme'] );
971
		}
972
973
		$this->reqHeaders['Accept'] = "*/*";
974
		$this->reqHeaders['Connection'] = 'Close';
975
		if ( $this->method == 'POST' ) {
976
			// Required for HTTP 1.0 POSTs
977
			$this->reqHeaders['Content-Length'] = strlen( $this->postData );
978
			if ( !isset( $this->reqHeaders['Content-Type'] ) ) {
979
				$this->reqHeaders['Content-Type'] = "application/x-www-form-urlencoded";
980
			}
981
		}
982
983
		// Set up PHP stream context
984
		$options = [
985
			'http' => [
986
				'method' => $this->method,
987
				'header' => implode( "\r\n", $this->getHeaderList() ),
988
				'protocol_version' => '1.1',
989
				'max_redirects' => $this->followRedirects ? $this->maxRedirects : 0,
990
				'ignore_errors' => true,
991
				'timeout' => $this->timeout,
992
				// Curl options in case curlwrappers are installed
993
				'curl_verify_ssl_host' => $this->sslVerifyHost ? 2 : 0,
994
				'curl_verify_ssl_peer' => $this->sslVerifyCert,
995
			],
996
			'ssl' => [
997
				'verify_peer' => $this->sslVerifyCert,
998
				'SNI_enabled' => true,
999
				'ciphers' => 'HIGH:!SSLv2:!SSLv3:-ADH:-kDH:-kECDH:-DSS',
1000
				'disable_compression' => true,
1001
			],
1002
		];
1003
1004
		if ( $this->proxy ) {
1005
			$options['http']['proxy'] = $this->urlToTcp( $this->proxy );
1006
			$options['http']['request_fulluri'] = true;
1007
		}
1008
1009
		if ( $this->postData ) {
1010
			$options['http']['content'] = $this->postData;
1011
		}
1012
1013
		if ( $this->sslVerifyHost ) {
1014
			// PHP 5.6.0 deprecates CN_match, in favour of peer_name which
1015
			// actually checks SubjectAltName properly.
1016
			if ( version_compare( PHP_VERSION, '5.6.0', '>=' ) ) {
1017
				$options['ssl']['peer_name'] = $this->parsedUrl['host'];
1018
			} else {
1019
				$options['ssl']['CN_match'] = $this->parsedUrl['host'];
1020
			}
1021
		}
1022
1023
		$options['ssl'] += $this->getCertOptions();
1024
1025
		$context = stream_context_create( $options );
1026
1027
		$this->headerList = [];
1028
		$reqCount = 0;
1029
		$url = $this->url;
1030
1031
		$result = [];
1032
1033
		if ( $this->profiler ) {
1034
			$profileSection = $this->profiler->scopedProfileIn(
1035
				__METHOD__ . '-' . $this->profileName
1036
			);
1037
		}
1038
		do {
1039
			$reqCount++;
1040
			$this->fopenErrors = [];
1041
			set_error_handler( [ $this, 'errorHandler' ] );
1042
			$fh = fopen( $url, "r", false, $context );
1043
			restore_error_handler();
1044
1045
			if ( !$fh ) {
1046
				// HACK for instant commons.
1047
				// If we are contacting (commons|upload).wikimedia.org
1048
				// try again with CN_match for en.wikipedia.org
1049
				// as php does not handle SubjectAltName properly
1050
				// prior to "peer_name" option in php 5.6
1051
				if ( isset( $options['ssl']['CN_match'] )
1052
					&& ( $options['ssl']['CN_match'] === 'commons.wikimedia.org'
1053
						|| $options['ssl']['CN_match'] === 'upload.wikimedia.org' )
1054
				) {
1055
					$options['ssl']['CN_match'] = 'en.wikipedia.org';
1056
					$context = stream_context_create( $options );
1057
					continue;
1058
				}
1059
				break;
1060
			}
1061
1062
			$result = stream_get_meta_data( $fh );
1063
			$this->headerList = $result['wrapper_data'];
1064
			$this->parseHeader();
1065
1066
			if ( !$this->followRedirects ) {
1067
				break;
1068
			}
1069
1070
			# Handle manual redirection
1071
			if ( !$this->isRedirect() || $reqCount > $this->maxRedirects ) {
1072
				break;
1073
			}
1074
			# Check security of URL
1075
			$url = $this->getResponseHeader( "Location" );
1076
1077
			if ( !Http::isValidURI( $url ) ) {
1078
				wfDebug( __METHOD__ . ": insecure redirection\n" );
1079
				break;
1080
			}
1081
		} while ( true );
1082
		if ( $this->profiler ) {
1083
			$this->profiler->scopedProfileOut( $profileSection );
1084
		}
1085
1086
		$this->setStatus();
1087
1088
		if ( $fh === false ) {
1089
			if ( $this->fopenErrors ) {
1090
				LoggerFactory::getInstance( 'http' )->warning( __CLASS__
1091
					. ': error opening connection: {errstr1}', $this->fopenErrors );
1092
			}
1093
			$this->status->fatal( 'http-request-error' );
1094
			return $this->status;
1095
		}
1096
1097
		if ( $result['timed_out'] ) {
1098
			$this->status->fatal( 'http-timed-out', $this->url );
1099
			return $this->status;
1100
		}
1101
1102
		// If everything went OK, or we received some error code
1103
		// get the response body content.
1104
		if ( $this->status->isOK() || (int)$this->respStatus >= 300 ) {
1105
			while ( !feof( $fh ) ) {
1106
				$buf = fread( $fh, 8192 );
1107
1108
				if ( $buf === false ) {
1109
					$this->status->fatal( 'http-read-error' );
1110
					break;
1111
				}
1112
1113
				if ( strlen( $buf ) ) {
1114
					call_user_func( $this->callback, $fh, $buf );
1115
				}
1116
			}
1117
		}
1118
		fclose( $fh );
1119
1120
		return $this->status;
1121
	}
1122
}
1123