Properly set the Host header to target host instead of proxy host
[GitHub/WoltLab/WCF.git] / wcfsetup / install / files / lib / util / HTTPRequest.class.php
CommitLineData
86fc0430
TD
1<?php
2namespace wcf\util;
3536d2fe
AE
3use wcf\system\exception\HTTPNotFoundException;
4use wcf\system\exception\HTTPServerErrorException;
5use wcf\system\exception\HTTPUnauthorizedException;
86fc0430
TD
6use wcf\system\exception\SystemException;
7use wcf\system\io\RemoteFile;
8use wcf\system\Regex;
9use wcf\system\WCF;
10
11/**
8fe14bd6 12 * Sends HTTP/1.1 requests.
86fc0430
TD
13 * It supports POST, SSL, Basic Auth etc.
14 *
3536d2fe 15 * @author Tim Duesterhus
ca4ba303 16 * @copyright 2001-2014 WoltLab GmbH
86fc0430
TD
17 * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
18 * @package com.woltlab.wcf
19 * @subpackage util
20 * @category Community Framework
21 */
a195ffa6 22final class HTTPRequest {
86fc0430
TD
23 /**
24 * given options
a17de04e 25 * @var array
86fc0430
TD
26 */
27 private $options = array();
28
29 /**
30 * given post parameters
a17de04e 31 * @var array
86fc0430
TD
32 */
33 private $postParameters = array();
34
5f70a0de
TD
35 /**
36 * given files
06355ec3 37 * @var array
5f70a0de
TD
38 */
39 private $files = array();
40
86fc0430 41 /**
60f613e2 42 * indicates if request will be made via SSL
a17de04e 43 * @var boolean
86fc0430
TD
44 */
45 private $useSSL = false;
46
47 /**
48 * target host
a17de04e 49 * @var string
86fc0430
TD
50 */
51 private $host;
52
53 /**
54 * target port
a17de04e 55 * @var integer
86fc0430
TD
56 */
57 private $port;
a17de04e 58
86fc0430
TD
59 /**
60 * target path
a17de04e 61 * @var string
86fc0430
TD
62 */
63 private $path;
64
65 /**
66 * target query string
a17de04e 67 * @var string
86fc0430
TD
68 */
69 private $query;
70
25cdc083
AE
71 /**
72 * request URL
73 * @var string
74 */
75 private $url = '';
76
86fc0430
TD
77 /**
78 * request headers
a17de04e 79 * @var array<string>
86fc0430
TD
80 */
81 private $headers = array();
82
8fe14bd6
TD
83 /**
84 * legacy headers
85 * @var array<string>
86 */
87 private $legacyHeaders = array();
88
5f70a0de
TD
89 /**
90 * request body
91 * @var string
92 */
93 private $body = '';
94
86fc0430
TD
95 /**
96 * reply headers
a17de04e 97 * @var array<string>
86fc0430
TD
98 */
99 private $replyHeaders = array();
100
101 /**
102 * reply body
a17de04e 103 * @var string
86fc0430
TD
104 */
105 private $replyBody = '';
106
107 /**
108 * reply status code
a17de04e 109 * @var integer
86fc0430
TD
110 */
111 private $statusCode = 0;
112
113 /**
a17de04e 114 * Constructs a new instance of HTTPRequest.
86fc0430
TD
115 *
116 * @param string $url URL to connect to
117 * @param array<string> $options
1b20f990 118 * @param mixed $postParameters Parameters to send via POST
5f70a0de 119 * @param array $files Files to attach to the request
86fc0430 120 */
1b20f990 121 public function __construct($url, array $options = array(), $postParameters = array(), array $files = array()) {
86fc0430
TD
122 $this->setURL($url);
123
124 $this->postParameters = $postParameters;
5f70a0de 125 $this->files = $files;
86fc0430
TD
126
127 $this->setOptions($options);
128
a195ffa6 129 // set default headers
8fe14bd6
TD
130 $this->addHeader('user-agent', "HTTP.PHP (HTTPRequest.class.php; WoltLab Community Framework/".WCF_VERSION."; ".WCF::getLanguage()->languageCode.")");
131 $this->addHeader('accept', '*/*');
132 $this->addHeader('accept-language', WCF::getLanguage()->getFixedLanguageCode());
133
4d28d5a2 134 if (isset($this->options['maxLength'])) {
5262964b 135 $this->addHeader('Range', 'bytes=0-'.($this->options['maxLength'] - 1));
4d28d5a2
SG
136 }
137
86fc0430 138 if ($this->options['method'] !== 'GET') {
5f70a0de 139 if (empty($this->files)) {
1b20f990
AE
140 if (is_array($postParameters)) {
141 $this->body = http_build_query($this->postParameters, '', '&');
142 }
143 else if (is_string($postParameters) && !empty($postParameters)) {
144 $this->body = $postParameters;
145 }
146
8fe14bd6 147 $this->addHeader('content-type', 'application/x-www-form-urlencoded');
5f70a0de
TD
148 }
149 else {
150 $boundary = StringUtil::getRandomID();
8fe14bd6 151 $this->addHeader('content-type', 'multipart/form-data; boundary='.$boundary);
5f70a0de
TD
152
153 // source of the iterators: http://stackoverflow.com/a/7623716/782822
154 if (!empty($this->postParameters)) {
155 $iterator = new \RecursiveIteratorIterator(new \RecursiveArrayIterator($this->postParameters), \RecursiveIteratorIterator::SELF_FIRST);
156 foreach ($iterator as $k => $v) {
157 if (!$iterator->hasChildren()) {
158 $key = '';
159 for ($i = 0, $max = $iterator->getDepth(); $i <= $max; $i++) {
160 if ($i === 0) $key .= $iterator->getSubIterator($i)->key();
161 else $key .= '['.$iterator->getSubIterator($i)->key().']';
162 }
163
164 $this->body .= "--".$boundary."\r\n";
165 $this->body .= 'Content-Disposition: form-data; name="'.$key.'"'."\r\n\r\n";
166 $this->body .= $v."\r\n";
167 }
168 }
169 }
170
171 $iterator = new \RecursiveIteratorIterator(new \RecursiveArrayIterator($this->files), \RecursiveIteratorIterator::SELF_FIRST);
172 foreach ($iterator as $k => $v) {
173 if (!$iterator->hasChildren()) {
174 $key = '';
175 for ($i = 0, $max = $iterator->getDepth(); $i <= $max; $i++) {
176 if ($i === 0) $key .= $iterator->getSubIterator($i)->key();
177 else $key .= '['.$iterator->getSubIterator($i)->key().']';
178 }
179
180 $this->body .= "--".$boundary."\r\n";
181 $this->body .= 'Content-Disposition: form-data; name="'.$k.'"; filename="'.basename($v).'"'."\r\n";
182 $this->body .= 'Content-Type: '.(FileUtil::getMimeType($v) ?: 'application/octet-stream.')."\r\n\r\n";
183 $this->body .= file_get_contents($v)."\r\n";
184 }
185 }
186
187 $this->body .= "--".$boundary."--";
188 }
8fe14bd6 189 $this->addHeader('content-length', strlen($this->body));
86fc0430
TD
190 }
191 if (isset($this->options['auth'])) {
8fe14bd6 192 $this->addHeader('authorization', "Basic ".base64_encode($options['auth']['username'].":".$options['auth']['password']));
86fc0430 193 }
8fe14bd6 194 $this->addHeader('connection', 'Close');
86fc0430
TD
195 }
196
197 /**
198 * Parses the given URL and applies PROXY_SERVER_HTTP.
199 *
a17de04e 200 * @param string $url
86fc0430
TD
201 */
202 private function setURL($url) {
adc039a5 203 $parsedUrl = $originUrl = parse_url($url);
86fc0430
TD
204 if (PROXY_SERVER_HTTP) {
205 $parsedUrl = parse_url(PROXY_SERVER_HTTP);
206 $this->path = $url;
207 }
208 else {
86fc0430
TD
209 $this->path = isset($parsedUrl['path']) ? $parsedUrl['path'] : '/';
210 }
d5bd7602 211
86fc0430
TD
212 $this->useSSL = $parsedUrl['scheme'] === 'https';
213 $this->host = $parsedUrl['host'];
214 $this->port = isset($parsedUrl['port']) ? $parsedUrl['port'] : ($this->useSSL ? 443 : 80);
86fc0430 215 $this->query = isset($parsedUrl['query']) ? $parsedUrl['query'] : '';
d5bd7602
AE
216
217 // update the 'Host:' header if URL has changed
adc039a5
TD
218 if ($this->url != $url) {
219 $originUseSSL = $originUrl['scheme'] === 'https';
220 $originHost = $originUrl['host'];
221 $originPort = isset($originUrl['port']) ? $originUrl['port'] : ($originUseSSL ? 443 : 80);
222 $this->addHeader('host', $originHost.($originPort != ($originUseSSL ? 443 : 80) ? ':'.$originPort : ''));
d5bd7602
AE
223 }
224
225 $this->url = $url;
86fc0430
TD
226 }
227
228 /**
229 * Executes the HTTP request.
230 */
231 public function execute() {
232 // connect
233 $remoteFile = new RemoteFile(($this->useSSL ? 'ssl://' : '').$this->host, $this->port, $this->options['timeout']);
234
8fe14bd6 235 $request = $this->options['method']." ".$this->path.($this->query ? '?'.$this->query : '')." HTTP/1.1\r\n";
86fc0430 236
a195ffa6 237 // add headers
86fc0430
TD
238 foreach ($this->headers as $name => $values) {
239 foreach ($values as $value) {
240 $request .= $name.": ".$value."\r\n";
241 }
242 }
243 $request .= "\r\n";
8fe14bd6 244
a195ffa6 245 // add post parameters
5f70a0de 246 if ($this->options['method'] !== 'GET') $request .= $this->body."\r\n\r\n";
d5bd7602 247
86fc0430
TD
248 $remoteFile->puts($request);
249
250 $inHeader = true;
251 $this->replyHeaders = array();
252 $this->replyBody = '';
8fe14bd6 253 $chunkLength = 0;
5262964b 254 $bodyLength = 0;
c7fe2510 255
8d2262a1 256 $chunkedTransferRegex = new Regex('(^|,)[ \t]*chunked[ \t]*$', Regex::CASE_INSENSITIVE);
7ced4c6a
TD
257 // read http response, until one of is true
258 // a) EOF is reached
259 // b) bodyLength is at least maxLength
260 // c) bodyLength is at least Content-Length
261 while (!(
262 $remoteFile->eof() ||
263 (isset($this->options['maxLength']) && $bodyLength >= $this->options['maxLength']) ||
264 (isset($this->replyHeaders['content-length']) && $bodyLength >= end($this->replyHeaders['content-length']))
265 )) {
266
8fe14bd6 267 if ($chunkLength) {
5262964b 268 if (isset($this->options['maxLength'])) $chunkLength = min($chunkLength, $this->options['maxLength'] - $bodyLength);
8fe14bd6
TD
269 $line = $remoteFile->read($chunkLength);
270 }
8d2262a1 271 else if (!$inHeader && (!isset($this->replyHeaders['transfer-encoding']) || !$chunkedTransferRegex->match(end($this->replyHeaders['transfer-encoding'])))) {
4b3a6b71
TD
272 $length = 1024;
273 if (isset($this->options['maxLength'])) $length = min($length, $this->options['maxLength'] - $bodyLength);
7ced4c6a
TD
274 if (isset($this->replyHeaders['content-length'])) $length = min($length, end($this->replyHeaders['content-length']) - $bodyLength);
275
276 $line = $remoteFile->read($length);
277 }
8fe14bd6
TD
278 else {
279 $line = $remoteFile->gets();
280 }
281
86fc0430
TD
282 if ($inHeader) {
283 if (rtrim($line) === '') {
284 $inHeader = false;
8fe14bd6
TD
285 $this->parseReplyHeaders();
286
86fc0430
TD
287 continue;
288 }
289 $this->replyHeaders[] = $line;
290 }
291 else {
8fe14bd6 292 if (isset($this->replyHeaders['transfer-encoding']) && $chunkedTransferRegex->match(end($this->replyHeaders['transfer-encoding']))) {
8fe14bd6
TD
293 // last chunk finished
294 if ($chunkLength === 0) {
295 // read hex data and trash chunk-extension
296 list($hex) = explode(';', $line, 2);
297 $chunkLength = hexdec($hex);
298
299 // $chunkLength === 0 -> no more data
300 if ($chunkLength === 0) {
301 // clear remaining response
7ced4c6a 302 while (!$remoteFile->gets(1024));
8fe14bd6 303
8f3e6138
TD
304 // remove chunked from transfer-encoding
305 $this->replyHeaders['transfer-encoding'] = array_filter(array_map(function ($element) use ($chunkedTransferRegex) {
306 return $chunkedTransferRegex->replace($element, '');
307 }, $this->replyHeaders['transfer-encoding']), 'trim');
308 if (empty($this->replyHeaders['transfer-encoding'])) unset($this->replyHeaders['transfer-encoding']);
309
8fe14bd6
TD
310 // break out of main reading loop
311 break;
312 }
313 }
314 else {
315 $this->replyBody .= $line;
316 $chunkLength -= strlen($line);
8f3e6138
TD
317 $bodyLength += strlen($line);
318 if ($chunkLength === 0) $remoteFile->read(2); // CRLF
8fe14bd6
TD
319 }
320 }
321 else {
322 $this->replyBody .= $line;
8f3e6138 323 $bodyLength += strlen($line);
5262964b 324 }
86fc0430
TD
325 }
326 }
327
5262964b
TD
328 if (isset($this->options['maxLength'])) $this->replyBody = substr($this->replyBody, 0, $this->options['maxLength']);
329
8fe14bd6
TD
330 $remoteFile->close();
331
86fc0430
TD
332 $this->parseReply();
333 }
334
335 /**
8fe14bd6 336 * Parses the reply headers.
86fc0430 337 */
8fe14bd6 338 private function parseReplyHeaders() {
86fc0430 339 $headers = array();
8fe14bd6 340 $lastKey = '';
86fc0430
TD
341 foreach ($this->replyHeaders as $header) {
342 if (strpos($header, ':') === false) {
8fe14bd6 343 $headers[trim($header)] = array(trim($header));
86fc0430
TD
344 continue;
345 }
8fe14bd6
TD
346
347 // 4.2 Header fields can be
348 // extended over multiple lines by preceding each extra line with at
349 // least one SP or HT.
350 if (ltrim($header, "\t ") !== $header) {
351 $headers[$lastKey][] = array_pop($headers[$lastKey]).' '.trim($header);
352 }
353 else {
354 list($key, $value) = explode(':', $header, 2);
355
356 $lastKey = $key;
357 if (!isset($headers[$key])) $headers[$key] = array();
358 $headers[$key][] = trim($value);
359 }
86fc0430 360 }
8fe14bd6
TD
361 // 4.2 Field names are case-insensitive.
362 $this->replyHeaders = array_change_key_case($headers);
363 if (isset($this->replyHeaders['transfer-encoding'])) $this->replyHeaders['transfer-encoding'] = array(implode(',', $this->replyHeaders['transfer-encoding']));
364 $this->legacyHeaders = array_map('end', $headers);
86fc0430 365
a0eb8370 366 // get status code
86fc0430 367 $statusLine = reset($this->replyHeaders);
8fe14bd6
TD
368 $regex = new Regex('^HTTP/1.\d+ +(\d{3})');
369 if (!$regex->match($statusLine[0])) throw new SystemException("Unexpected status '".$statusLine."'");
86fc0430 370 $matches = $regex->getMatches();
aeaa135c 371 $this->statusCode = $matches[1];
8fe14bd6
TD
372 }
373
374 /**
375 * Parses the reply.
376 */
377 private function parseReply() {
378 // 4.4 Messages MUST NOT include both a Content-Length header field and a
379 // non-identity transfer-coding. If the message does include a non-
380 // identity transfer-coding, the Content-Length MUST be ignored.
5262964b 381 if (isset($this->replyHeaders['content-length']) && (!isset($this->replyHeaders['transfer-encoding']) || strtolower(end($this->replyHeaders['transfer-encoding'])) !== 'identity') && !isset($this->options['maxLength'])) {
8009cc11 382 if (strlen($this->replyBody) != end($this->replyHeaders['content-length'])) {
a0eb8370
DR
383 throw new SystemException('Body length does not match length given in header');
384 }
385 }
386
387 // validate status code
aeaa135c 388 switch ($this->statusCode) {
86fc0430
TD
389 case '301':
390 case '302':
391 case '303':
392 case '307':
393 // redirect
c7fe2510 394 if ($this->options['maxDepth'] <= 0) throw new SystemException("Received status code '".$this->statusCode."' from server, but recursion level is exhausted");
86fc0430
TD
395
396 $newRequest = clone $this;
397 $newRequest->options['maxDepth']--;
c7fe2510 398
8fe14bd6 399 // 10.3.4 The response to the request can be found under a different URI and SHOULD
c7fe2510 400 // be retrieved using a GET method on that resource.
c7fe2510 401 if ($this->statusCode == '303') {
86fc0430
TD
402 $newRequest->options['method'] = 'GET';
403 $newRequest->postParameters = array();
8fe14bd6
TD
404 $newRequest->addHeader('content-length', '');
405 $newRequest->addHeader('content-type', '');
86fc0430 406 }
c7fe2510 407
86fc0430 408 try {
8fe14bd6 409 $newRequest->setURL(end($this->replyHeaders['location']));
86fc0430
TD
410 }
411 catch (SystemException $e) {
8fe14bd6 412 throw new SystemException("Received 'Location: ".end($this->replyHeaders['location'])."' from server, which is invalid.", 0, $e);
86fc0430 413 }
86fc0430 414
283df336
TD
415 try {
416 $newRequest->execute();
417
418 // update data with data from the inner request
419 $this->url = $newRequest->url;
420 $this->statusCode = $newRequest->statusCode;
421 $this->replyHeaders = $newRequest->replyHeaders;
df37e22d 422 $this->legacyHeaders = $newRequest->legacyHeaders;
283df336
TD
423 $this->replyBody = $newRequest->replyBody;
424 }
425 catch (SystemException $e) {
426 // update data with data from the inner request
427 $this->url = $newRequest->url;
428 $this->statusCode = $newRequest->statusCode;
429 $this->replyHeaders = $newRequest->replyHeaders;
df37e22d 430 $this->legacyHeaders = $newRequest->legacyHeaders;
283df336
TD
431 $this->replyBody = $newRequest->replyBody;
432
433 throw $e;
434 }
435
86fc0430
TD
436 return;
437 break;
a17de04e 438
4d28d5a2
SG
439 case '206':
440 // check, if partial content was expected
5262964b 441 if (!isset($this->headers['range'])) {
4d28d5a2
SG
442 throw new HTTPServerErrorException("Received unexpected status code '206' from server");
443 }
5262964b 444 else if (!isset($this->replyHeaders['content-range'])) {
4d28d5a2
SG
445 throw new HTTPServerErrorException("Content-Range is missing in reply header");
446 }
447 break;
448
3536d2fe 449 case '401':
5cd59413 450 case '402':
3536d2fe
AE
451 case '403':
452 throw new HTTPUnauthorizedException("Received status code '".$this->statusCode."' from server");
453 break;
454
455 case '404':
456 throw new HTTPNotFoundException("Received status code '404' from server");
457 break;
8fe14bd6 458
86fc0430 459 default:
8fe14bd6
TD
460 // 6.1.1 However, applications MUST
461 // understand the class of any status code, as indicated by the first
462 // digit, and treat any unrecognized response as being equivalent to the
463 // x00 status code of that class, with the exception that an
464 // unrecognized response MUST NOT be cached.
465 switch (substr($this->statusCode, 0, 1)) {
466 case '2': // 200 and unknown 2XX
467 case '3': // 300 and unknown 3XX
468 // we are fine
469 break;
470 case '5': // 500 and unknown 5XX
471 throw new HTTPServerErrorException("Received status code '".$this->statusCode."' from server");
472 break;
473 default:
474 throw new SystemException("Received unhandled status code '".$this->statusCode."' from server");
475 break;
476 }
86fc0430
TD
477 break;
478 }
86fc0430
TD
479 }
480
481 /**
482 * Returns an array with the replied data.
8fe14bd6 483 * Note that the 'headers' element is deprecated and may be removed in the future.
86fc0430 484 *
a17de04e 485 * @return array
86fc0430
TD
486 */
487 public function getReply() {
a195ffa6
TD
488 return array(
489 'statusCode' => $this->statusCode,
8fe14bd6
TD
490 'headers' => $this->legacyHeaders,
491 'httpHeaders' => $this->replyHeaders,
25cdc083
AE
492 'body' => $this->replyBody,
493 'url' => $this->url
a195ffa6 494 );
86fc0430
TD
495 }
496
497 /**
498 * Sets options and applies default values when an option is omitted.
499 *
a17de04e 500 * @param array $options
86fc0430
TD
501 */
502 private function setOptions(array $options) {
503 if (!isset($options['timeout'])) {
c7fe2510 504 $options['timeout'] = 10;
86fc0430
TD
505 }
506
507 if (!isset($options['method'])) {
5f70a0de 508 $options['method'] = (!empty($this->postParameters) || !empty($this->files) ? 'POST' : 'GET');
86fc0430
TD
509 }
510
511 if (!isset($options['maxDepth'])) {
512 $options['maxDepth'] = 2;
513 }
514
515 if (isset($options['auth'])) {
516 if (!isset($options['auth']['username'])) {
c7fe2510 517 throw new SystemException('Username is missing in authentification data.');
86fc0430
TD
518 }
519 if (!isset($options['auth']['password'])) {
c7fe2510 520 throw new SystemException('Password is missing in authentification data.');
86fc0430
TD
521 }
522 }
523
524 $this->options = $options;
525 }
526
527 /**
528 * Adds a header to this request.
c7fe2510 529 * When an empty value is given existing headers of this name will be removed. When append
86fc0430
TD
530 * is set to false existing values will be overwritten.
531 *
a17de04e
MS
532 * @param string $name
533 * @param string $value
534 * @param boolean $append
86fc0430
TD
535 */
536 public function addHeader($name, $value, $append = false) {
8fe14bd6
TD
537 // 4.2 Field names are case-insensitive.
538 $name = strtolower($name);
539
86fc0430
TD
540 if ($value === '') {
541 unset($this->headers[$name]);
542 return;
543 }
544
545 if ($append && isset($this->headers[$name])) {
546 $this->headers[$name][] = $value;
547 }
a377993e
TD
548 else {
549 $this->headers[$name] = array($value);
550 }
86fc0430
TD
551 }
552
553 /**
554 * Resets reply data when cloning.
555 */
556 private function __clone() {
557 $this->replyHeaders = array();
558 $this->replyBody = '';
559 $this->statusCode = 0;
560 }
58e1d71f 561}