1<?php
2namespace SimpleSAML\Utils;
3
4use SimpleSAML\Module;
5use SimpleSAML\Logger;
6
7/**
8 * HTTP-related utility methods.
9 *
10 * @package SimpleSAMLphp
11 */
12class HTTP
13{
14
15    /**
16     * Obtain a URL where we can redirect to securely post a form with the given data to a specific destination.
17     *
18     * @param string $destination The destination URL.
19     * @param array  $data An associative array containing the data to be posted to $destination.
20     *
21     * @throws \SimpleSAML_Error_Exception If the current session is transient.
22     * @return string  A URL which allows to securely post a form to $destination.
23     *
24     * @author Jaime Perez, UNINETT AS <jaime.perez@uninett.no>
25     */
26    private static function getSecurePOSTRedirectURL($destination, $data)
27    {
28        $session = \SimpleSAML_Session::getSessionFromRequest();
29        $id = self::savePOSTData($session, $destination, $data);
30
31        // get the session ID
32        $session_id = $session->getSessionId();
33        if (is_null($session_id)) {
34            // this is a transient session, it is pointless to continue
35            throw new \SimpleSAML_Error_Exception('Cannot save POST data to a transient session.');
36        }
37
38        // encrypt the session ID and the random ID
39        $info = base64_encode(Crypto::aesEncrypt($session_id.':'.$id));
40
41        $url = Module::getModuleURL('core/postredirect.php', array('RedirInfo' => $info));
42        return preg_replace('#^https:#', 'http:', $url);
43    }
44
45
46    /**
47     * Retrieve Host value from $_SERVER environment variables.
48     *
49     * @return string The current host name, including the port if needed. It will use localhost when unable to
50     *     determine the current host.
51     *
52     * @author Olav Morken, UNINETT AS <olav.morken@uninett.no>
53     */
54    private static function getServerHost()
55    {
56        if (array_key_exists('HTTP_HOST', $_SERVER)) {
57            $current = $_SERVER['HTTP_HOST'];
58        } elseif (array_key_exists('SERVER_NAME', $_SERVER)) {
59            $current = $_SERVER['SERVER_NAME'];
60        } else {
61            // almost certainly not what you want, but...
62            $current = 'localhost';
63        }
64
65        if (strstr($current, ":")) {
66            $decomposed = explode(":", $current);
67            $port = array_pop($decomposed);
68            if (!is_numeric($port)) {
69                array_push($decomposed, $port);
70            }
71            $current = implode(":", $decomposed);
72        }
73        return $current;
74    }
75
76
77    /**
78     * Retrieve HTTPS status from $_SERVER environment variables.
79     *
80     * @return boolean True if the request was performed through HTTPS, false otherwise.
81     *
82     * @author Olav Morken, UNINETT AS <olav.morken@uninett.no>
83     */
84    public static function getServerHTTPS()
85    {
86        if (!array_key_exists('HTTPS', $_SERVER)) {
87            // not an https-request
88            return false;
89        }
90
91        if ($_SERVER['HTTPS'] === 'off') {
92            // IIS with HTTPS off
93            return false;
94        }
95
96        // otherwise, HTTPS will be non-empty
97        return !empty($_SERVER['HTTPS']);
98    }
99
100
101    /**
102     * Retrieve the port number from $_SERVER environment variables.
103     *
104     * @return string The port number prepended by a colon, if it is different than the default port for the protocol
105     *     (80 for HTTP, 443 for HTTPS), or an empty string otherwise.
106     *
107     * @author Olav Morken, UNINETT AS <olav.morken@uninett.no>
108     */
109    public static function getServerPort()
110    {
111        $default_port = self::getServerHTTPS() ? '443' : '80';
112        $port = isset($_SERVER['SERVER_PORT']) ? $_SERVER['SERVER_PORT'] : $default_port;
113
114        // Take care of edge-case where SERVER_PORT is an integer
115        $port = strval($port);
116
117        if ($port !== $default_port) {
118            return ':'.$port;
119        }
120        return '';
121    }
122
123
124    /**
125     * This function redirects the user to the specified address.
126     *
127     * This function will use the "HTTP 303 See Other" redirection if the current request used the POST method and the
128     * HTTP version is 1.1. Otherwise, a "HTTP 302 Found" redirection will be used.
129     *
130     * The function will also generate a simple web page with a clickable link to the target page.
131     *
132     * @param string   $url The URL we should redirect to. This URL may include query parameters. If this URL is a
133     *     relative URL (starting with '/'), then it will be turned into an absolute URL by prefixing it with the
134     *     absolute URL to the root of the website.
135     * @param string[] $parameters An array with extra query string parameters which should be appended to the URL. The
136     *     name of the parameter is the array index. The value of the parameter is the value stored in the index. Both
137     *     the name and the value will be urlencoded. If the value is NULL, then the parameter will be encoded as just
138     *     the name, without a value.
139     *
140     * @return void This function never returns.
141     * @throws \InvalidArgumentException If $url is not a string or is empty, or $parameters is not an array.
142     *
143     * @author Olav Morken, UNINETT AS <olav.morken@uninett.no>
144     * @author Mads Freek Petersen
145     * @author Jaime Perez, UNINETT AS <jaime.perez@uninett.no>
146     */
147    private static function redirect($url, $parameters = array())
148    {
149        if (!is_string($url) || empty($url) || !is_array($parameters)) {
150            throw new \InvalidArgumentException('Invalid input parameters.');
151        }
152        if (!empty($parameters)) {
153            $url = self::addURLParameters($url, $parameters);
154        }
155
156        /* Set the HTTP result code. This is either 303 See Other or
157         * 302 Found. HTTP 303 See Other is sent if the HTTP version
158         * is HTTP/1.1 and the request type was a POST request.
159         */
160        if ($_SERVER['SERVER_PROTOCOL'] === 'HTTP/1.1' &&
161            $_SERVER['REQUEST_METHOD'] === 'POST'
162        ) {
163            $code = 303;
164        } else {
165            $code = 302;
166        }
167
168        if (strlen($url) > 2048) {
169            Logger::warning('Redirecting to a URL longer than 2048 bytes.');
170        }
171
172        if (!headers_sent()) {
173            // set the location header
174            header('Location: '.$url, true, $code);
175
176            // disable caching of this response
177            header('Pragma: no-cache');
178            header('Cache-Control: no-cache, no-store, must-revalidate');
179        }
180
181        // show a minimal web page with a clickable link to the URL
182        echo '<?xml version="1.0" encoding="UTF-8"?>'."\n";
183        echo '<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN"';
184        echo ' "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">'."\n";
185        echo '<html xmlns="http://www.w3.org/1999/xhtml">'."\n";
186        echo "  <head>\n";
187        echo '    <meta http-equiv="content-type" content="text/html; charset=utf-8">'."\n";
188        echo '    <meta http-equiv="refresh" content="0;URL=\''.htmlspecialchars($url).'\'">'."\n";
189        echo "    <title>Redirect</title>\n";
190        echo "  </head>\n";
191        echo "  <body>\n";
192        echo "    <h1>Redirect</h1>\n";
193        echo '      <p>You were redirected to: <a id="redirlink" href="'.htmlspecialchars($url).'">';
194        echo htmlspecialchars($url)."</a>\n";
195        echo '        <script type="text/javascript">document.getElementById("redirlink").focus();</script>'."\n";
196        echo "      </p>\n";
197        echo "  </body>\n";
198        echo '</html>';
199
200        // end script execution
201        exit;
202    }
203
204
205    /**
206     * Save the given HTTP POST data and the destination where it should be posted to a given session.
207     *
208     * @param \SimpleSAML_Session $session The session where to temporarily store the data.
209     * @param string              $destination The destination URL where the form should be posted.
210     * @param array               $data An associative array with the data to be posted to $destination.
211     *
212     * @return string A random identifier that can be used to retrieve the data from the current session.
213     *
214     * @author Andjelko Horvat
215     * @author Jaime Perez, UNINETT AS <jaime.perez@uninett.no>
216     */
217    private static function savePOSTData(\SimpleSAML_Session $session, $destination, $data)
218    {
219        // generate a random ID to avoid replay attacks
220        $id = Random::generateID();
221        $postData = array(
222            'post' => $data,
223            'url'  => $destination,
224        );
225
226        // save the post data to the session, tied to the random ID
227        $session->setData('core_postdatalink', $id, $postData);
228
229        return $id;
230    }
231
232
233    /**
234     * Add one or more query parameters to the given URL.
235     *
236     * @param string $url The URL the query parameters should be added to.
237     * @param array  $parameters The query parameters which should be added to the url. This should be an associative
238     *     array.
239     *
240     * @return string The URL with the new query parameters.
241     * @throws \InvalidArgumentException If $url is not a string or $parameters is not an array.
242     *
243     * @author Andreas Solberg, UNINETT AS <andreas.solberg@uninett.no>
244     * @author Olav Morken, UNINETT AS <olav.morken@uninett.no>
245     */
246    public static function addURLParameters($url, $parameters)
247    {
248        if (!is_string($url) || !is_array($parameters)) {
249            throw new \InvalidArgumentException('Invalid input parameters.');
250        }
251
252        $queryStart = strpos($url, '?');
253        if ($queryStart === false) {
254            $oldQuery = array();
255            $url .= '?';
256        } else {
257            /** @var string|false $oldQuery */
258            $oldQuery = substr($url, $queryStart + 1);
259            if ($oldQuery === false) {
260                $oldQuery = array();
261            } else {
262                $oldQuery = self::parseQueryString($oldQuery);
263            }
264            $url = substr($url, 0, $queryStart + 1);
265        }
266
267        /** @var array $oldQuery */
268        $query = array_merge($oldQuery, $parameters);
269        $url .= http_build_query($query, '', '&');
270
271        return $url;
272    }
273
274
275    /**
276     * Check for session cookie, and show missing-cookie page if it is missing.
277     *
278     * @param string|null $retryURL The URL the user should access to retry the operation. Defaults to null.
279     *
280     * @return void If there is a session cookie, nothing will be returned. Otherwise, the user will be redirected to a
281     *     page telling about the missing cookie.
282     * @throws \InvalidArgumentException If $retryURL is neither a string nor null.
283     *
284     * @author Olav Morken, UNINETT AS <olav.morken@uninett.no>
285     */
286    public static function checkSessionCookie($retryURL = null)
287    {
288        if (!is_null($retryURL) && !is_string($retryURL)) {
289            throw new \InvalidArgumentException('Invalid input parameters.');
290        }
291
292        $session = \SimpleSAML_Session::getSessionFromRequest();
293        if ($session->hasSessionCookie()) {
294            return;
295        }
296
297        // we didn't have a session cookie. Redirect to the no-cookie page
298
299        $url = Module::getModuleURL('core/no_cookie.php');
300        if ($retryURL !== null) {
301            $url = self::addURLParameters($url, array('retryURL' => $retryURL));
302        }
303        self::redirectTrustedURL($url);
304    }
305
306
307    /**
308     * Check if a URL is valid and is in our list of allowed URLs.
309     *
310     * @param string $url The URL to check.
311     * @param array  $trustedSites An optional white list of domains. If none specified, the 'trusted.url.domains'
312     * configuration directive will be used.
313     *
314     * @return string The normalized URL itself if it is allowed. An empty string if the $url parameter is empty as
315     * defined by the empty() function.
316     * @throws \InvalidArgumentException If the URL is malformed.
317     * @throws \SimpleSAML_Error_Exception If the URL is not allowed by configuration.
318     *
319     * @author Jaime Perez, UNINETT AS <jaime.perez@uninett.no>
320     */
321    public static function checkURLAllowed($url, array $trustedSites = null)
322    {
323        if (empty($url)) {
324            return '';
325        }
326        $url = self::normalizeURL($url);
327
328        if (filter_var($url, FILTER_VALIDATE_URL) === false) {
329            throw new \SimpleSAML_Error_Exception('Invalid URL: '.$url);
330        }
331
332        // get the white list of domains
333        if ($trustedSites === null) {
334            $trustedSites = \SimpleSAML_Configuration::getInstance()->getValue('trusted.url.domains', array());
335        }
336
337        // validates the URL's host is among those allowed
338        if (is_array($trustedSites)) {
339            assert(is_array($trustedSites));
340            $components = parse_url($url);
341            $hostname = $components['host'];
342
343            // check for userinfo
344            if ((isset($components['user']) && strpos($components['user'], '\\') !== false) ||
345                (isset($components['pass']) && strpos($components['pass'], '\\') !== false)
346            ) {
347                throw new \SimpleSAML_Error_Exception('Invalid URL: '.$url);
348            }
349
350            // allow URLs with standard ports specified (non-standard ports must then be allowed explicitly)
351            if (isset($components['port']) &&
352                (($components['scheme'] === 'http' && $components['port'] !== 80) ||
353                 ($components['scheme'] === 'https' && $components['port'] !== 443))
354            ) {
355                $hostname = $hostname.':'.$components['port'];
356            }
357
358            $self_host = self::getSelfHostWithNonStandardPort();
359
360            $trustedRegex = \SimpleSAML_Configuration::getInstance()->getValue('trusted.url.regex', false);
361
362            $trusted = false;
363            if ($trustedRegex) {
364                // add self host to the white list
365                $trustedSites[] = preg_quote($self_host);
366                foreach ($trustedSites as $regex) {
367                    // Add start and end delimiters.
368                    $regex = "@^{$regex}$@";
369                    if (preg_match($regex, $hostname)) {
370                        $trusted = true;
371                        break;
372                    }
373                }
374            } else {
375                // add self host to the white list
376                $trustedSites[] = $self_host;
377                $trusted = in_array($hostname, $trustedSites, true);
378            }
379
380            // throw exception due to redirection to untrusted site
381            if (!$trusted) {
382                throw new \SimpleSAML_Error_Exception('URL not allowed: '.$url);
383            }
384        }
385        return $url;
386    }
387
388
389    /**
390     * Helper function to retrieve a file or URL with proxy support, also
391     * supporting proxy basic authorization..
392     *
393     * An exception will be thrown if we are unable to retrieve the data.
394     *
395     * @param string  $url The path or URL we should fetch.
396     * @param array   $context Extra context options. This parameter is optional.
397     * @param boolean $getHeaders Whether to also return response headers. Optional.
398     *
399     * @return string|array An array if $getHeaders is set, containing the data and the headers respectively; string
400     *  otherwise.
401     * @throws \InvalidArgumentException If the input parameters are invalid.
402     * @throws \SimpleSAML_Error_Exception If the file or URL cannot be retrieved.
403     *
404     * @author Andjelko Horvat
405     * @author Olav Morken, UNINETT AS <olav.morken@uninett.no>
406     * @author Marco Ferrante, University of Genova <marco@csita.unige.it>
407     */
408    public static function fetch($url, $context = array(), $getHeaders = false)
409    {
410        if (!is_string($url)) {
411            throw new \InvalidArgumentException('Invalid input parameters.');
412        }
413
414        $config = \SimpleSAML_Configuration::getInstance();
415
416        $proxy = $config->getString('proxy', null);
417        if ($proxy !== null) {
418            if (!isset($context['http']['proxy'])) {
419                $context['http']['proxy'] = $proxy;
420            }
421            $proxy_auth = $config->getString('proxy.auth', false);
422            if ($proxy_auth !== false) {
423                $context['http']['header'] = "Proxy-Authorization: Basic ".base64_encode($proxy_auth);
424            }
425            if (!isset($context['http']['request_fulluri'])) {
426                $context['http']['request_fulluri'] = true;
427            }
428            /*
429             * If the remote endpoint over HTTPS uses the SNI extension (Server Name Indication RFC 4366), the proxy
430             * could introduce a mismatch between the names in the Host: HTTP header and the SNI_server_name in TLS
431             * negotiation (thanks to Cristiano Valli @ GARR-IDEM to have pointed this problem).
432             * See: https://bugs.php.net/bug.php?id=63519
433             * These controls will force the same value for both fields.
434             * Marco Ferrante (marco@csita.unige.it), Nov 2012
435             */
436            if (preg_match('#^https#i', $url)
437                && defined('OPENSSL_TLSEXT_SERVER_NAME')
438                && OPENSSL_TLSEXT_SERVER_NAME
439            ) {
440                // extract the hostname
441                $hostname = parse_url($url, PHP_URL_HOST);
442                if (!empty($hostname)) {
443                    $context['ssl'] = array(
444                        'SNI_server_name' => $hostname,
445                        'SNI_enabled'     => true,
446                    );
447                } else {
448                    Logger::warning('Invalid URL format or local URL used through a proxy');
449                }
450            }
451        }
452
453        $context = stream_context_create($context);
454        $data = @file_get_contents($url, false, $context);
455        if ($data === false) {
456            $error = error_get_last();
457            throw new \SimpleSAML_Error_Exception('Error fetching '.var_export($url, true).':'.
458                (is_array($error) ? $error['message'] : 'no error available'));
459        }
460
461        // data and headers
462        if ($getHeaders) {
463            if (isset($http_response_header)) {
464                $headers = array();
465                foreach ($http_response_header as $h) {
466                    if (preg_match('@^HTTP/1\.[01]\s+\d{3}\s+@', $h)) {
467                        $headers = array(); // reset
468                        $headers[0] = $h;
469                        continue;
470                    }
471                    $bits = explode(':', $h, 2);
472                    if (count($bits) === 2) {
473                        $headers[strtolower($bits[0])] = trim($bits[1]);
474                    }
475                }
476            } else {
477                // no HTTP headers, probably a different protocol, e.g. file
478                $headers = null;
479            }
480            return array($data, $headers);
481        }
482
483        return $data;
484    }
485
486
487    /**
488     * This function parses the Accept-Language HTTP header and returns an associative array with each language and the
489     * score for that language. If a language includes a region, then the result will include both the language with
490     * the region and the language without the region.
491     *
492     * The returned array will be in the same order as the input.
493     *
494     * @return array An associative array with each language and the score for that language.
495     *
496     * @author Olav Morken, UNINETT AS <olav.morken@uninett.no>
497     */
498    public static function getAcceptLanguage()
499    {
500        if (!array_key_exists('HTTP_ACCEPT_LANGUAGE', $_SERVER)) {
501            // no Accept-Language header, return an empty set
502            return array();
503        }
504
505        $languages = explode(',', strtolower($_SERVER['HTTP_ACCEPT_LANGUAGE']));
506
507        $ret = array();
508
509        foreach ($languages as $l) {
510            $opts = explode(';', $l);
511
512            $l = trim(array_shift($opts)); // the language is the first element
513
514            $q = 1.0;
515
516            // iterate over all options, and check for the quality option
517            foreach ($opts as $o) {
518                $o = explode('=', $o);
519                if (count($o) < 2) {
520                    // skip option with no value
521                    continue;
522                }
523
524                $name = trim($o[0]);
525                $value = trim($o[1]);
526
527                if ($name === 'q') {
528                    $q = (float) $value;
529                }
530            }
531
532            // remove the old key to ensure that the element is added to the end
533            unset($ret[$l]);
534
535            // set the quality in the result
536            $ret[$l] = $q;
537
538            if (strpos($l, '-')) {
539                // the language includes a region part
540
541                // extract the language without the region
542                $l = explode('-', $l);
543                $l = $l[0];
544
545                // add this language to the result (unless it is defined already)
546                if (!array_key_exists($l, $ret)) {
547                    $ret[$l] = $q;
548                }
549            }
550        }
551        return $ret;
552    }
553
554
555    /**
556     * Try to guess the base SimpleSAMLphp path from the current request.
557     *
558     * This method offers just a guess, so don't rely on it.
559     *
560     * @return string The guessed base path that should correspond to the root installation of SimpleSAMLphp.
561     */
562    public static function guessBasePath()
563    {
564        if (!array_key_exists('REQUEST_URI', $_SERVER) || !array_key_exists('SCRIPT_FILENAME', $_SERVER)) {
565            return '/';
566        }
567        // get the name of the current script
568        $path = explode('/', $_SERVER['SCRIPT_FILENAME']);
569        $script = array_pop($path);
570
571        // get the portion of the URI up to the script, i.e.: /simplesaml/some/directory/script.php
572        if (!preg_match('#^/(?:[^/]+/)*'.$script.'#', $_SERVER['REQUEST_URI'], $matches)) {
573            return '/';
574        }
575        $uri_s = explode('/', $matches[0]);
576        $file_s = explode('/', $_SERVER['SCRIPT_FILENAME']);
577
578        // compare both arrays from the end, popping elements matching out of them
579        while ($uri_s[count($uri_s) - 1] === $file_s[count($file_s) - 1]) {
580            array_pop($uri_s);
581            array_pop($file_s);
582        }
583        // we are now left with the minimum part of the URI that does not match anything in the file system, use it
584        return join('/', $uri_s).'/';
585    }
586
587
588    /**
589     * Retrieve the base URL of the SimpleSAMLphp installation. The URL will always end with a '/'. For example:
590     *      https://idp.example.org/simplesaml/
591     *
592     * @return string The absolute base URL for the SimpleSAMLphp installation.
593     * @throws \SimpleSAML\Error\CriticalConfigurationError If 'baseurlpath' has an invalid format.
594     *
595     * @author Olav Morken, UNINETT AS <olav.morken@uninett.no>
596     */
597    public static function getBaseURL()
598    {
599        $globalConfig = \SimpleSAML_Configuration::getInstance();
600        $baseURL = $globalConfig->getString('baseurlpath', 'simplesaml/');
601
602        if (preg_match('#^https?://.*/?$#D', $baseURL, $matches)) {
603            // full URL in baseurlpath, override local server values
604            return rtrim($baseURL, '/').'/';
605        } elseif ((preg_match('#^/?([^/]?.*/)$#D', $baseURL, $matches)) ||
606            (preg_match('#^\*(.*)/$#D', $baseURL, $matches)) ||
607            ($baseURL === '')
608        ) {
609            // get server values
610            $protocol = 'http';
611            $protocol .= (self::getServerHTTPS()) ? 's' : '';
612            $protocol .= '://';
613
614            $hostname = self::getServerHost();
615            $port = self::getServerPort();
616            $path = $globalConfig->getBasePath();
617
618            return $protocol.$hostname.$port.$path;
619        } else {
620            /*
621             * Invalid 'baseurlpath'. We cannot recover from this, so throw a critical exception and try to be graceful
622             * with the configuration. Use a guessed base path instead of the one provided.
623             */
624            $c = $globalConfig->toArray();
625            $c['baseurlpath'] = self::guessBasePath();
626            throw new \SimpleSAML\Error\CriticalConfigurationError(
627                'Invalid value for \'baseurlpath\' in config.php. Valid format is in the form: '.
628                '[(http|https)://(hostname|fqdn)[:port]]/[path/to/simplesaml/]. It must end with a \'/\'.',
629                null,
630                $c
631            );
632        }
633    }
634
635
636    /**
637     * Retrieve the first element of the URL path.
638     *
639     * @param boolean $trailingslash Whether to add a trailing slash to the element or not. Defaults to true.
640     *
641     * @return string The first element of the URL path, with an optional, trailing slash.
642     *
643     * @author Andreas Solberg, UNINETT AS <andreas.solberg@uninett.no>
644     */
645    public static function getFirstPathElement($trailingslash = true)
646    {
647        if (preg_match('|^/(.*?)/|', $_SERVER['SCRIPT_NAME'], $matches)) {
648            return ($trailingslash ? '/' : '').$matches[1];
649        }
650        return '';
651    }
652
653
654    /**
655     * Create a link which will POST data.
656     *
657     * @param string $destination The destination URL.
658     * @param array  $data The name-value pairs which will be posted to the destination.
659     *
660     * @return string  A URL which can be accessed to post the data.
661     * @throws \InvalidArgumentException If $destination is not a string or $data is not an array.
662     *
663     * @author Andjelko Horvat
664     * @author Jaime Perez, UNINETT AS <jaime.perez@uninett.no>
665     */
666    public static function getPOSTRedirectURL($destination, $data)
667    {
668        if (!is_string($destination) || !is_array($data)) {
669            throw new \InvalidArgumentException('Invalid input parameters.');
670        }
671
672        $config = \SimpleSAML_Configuration::getInstance();
673        $allowed = $config->getBoolean('enable.http_post', false);
674
675        if ($allowed && preg_match("#^http:#", $destination) && self::isHTTPS()) {
676            // we need to post the data to HTTP
677            $url = self::getSecurePOSTRedirectURL($destination, $data);
678        } else { // post the data directly
679            $session = \SimpleSAML_Session::getSessionFromRequest();
680            $id = self::savePOSTData($session, $destination, $data);
681            $url = Module::getModuleURL('core/postredirect.php', array('RedirId' => $id));
682        }
683
684        return $url;
685    }
686
687
688    /**
689     * Retrieve our own host.
690     *
691     * E.g. www.example.com
692     *
693     * @return string The current host.
694     *
695     * @author Jaime Perez, UNINETT AS <jaime.perez@uninett.no>
696     */
697    public static function getSelfHost()
698    {
699        $decomposed = explode(':', self::getSelfHostWithNonStandardPort());
700        return array_shift($decomposed);
701    }
702
703    /**
704     * Retrieve our own host, including the port in case the it is not standard for the protocol in use. That is port
705     * 80 for HTTP and port 443 for HTTPS.
706     *
707     * E.g. www.example.com:8080
708     *
709     * @return string The current host, followed by a colon and the port number, in case the port is not standard for
710     * the protocol.
711     *
712     * @author Andreas Solberg, UNINETT AS <andreas.solberg@uninett.no>
713     * @author Olav Morken, UNINETT AS <olav.morken@uninett.no>
714     */
715    public static function getSelfHostWithNonStandardPort()
716    {
717        $url = self::getBaseURL();
718
719        /** @var int $colon getBaseURL() will allways return a valid URL */
720        $colon = strpos($url, '://');
721        $start = $colon + 3;
722        $length = strcspn($url, '/', $start);
723
724        return substr($url, $start, $length);
725    }
726
727    /**
728     * Retrieve our own host together with the URL path. Please note this function will return the base URL for the
729     * current SP, as defined in the global configuration.
730     *
731     * @return string The current host (with non-default ports included) plus the URL path.
732     *
733     * @author Andreas Solberg, UNINETT AS <andreas.solberg@uninett.no>
734     * @author Olav Morken, UNINETT AS <olav.morken@uninett.no>
735     */
736    public static function getSelfHostWithPath()
737    {
738        $baseurl = explode("/", self::getBaseURL());
739        $elements = array_slice($baseurl, 3 - count($baseurl), count($baseurl) - 4);
740        $path = implode("/", $elements);
741        return self::getSelfHostWithNonStandardPort()."/".$path;
742    }
743
744
745    /**
746     * Retrieve the current URL using the base URL in the configuration, if possible.
747     *
748     * This method will try to see if the current script is part of SimpleSAMLphp. In that case, it will use the
749     * 'baseurlpath' configuration option to rebuild the current URL based on that. If the current script is NOT
750     * part of SimpleSAMLphp, it will just return the current URL.
751     *
752     * Note that this method does NOT make use of the HTTP X-Forwarded-* set of headers.
753     *
754     * @return string The current URL, including query parameters.
755     *
756     * @author Andreas Solberg, UNINETT AS <andreas.solberg@uninett.no>
757     * @author Olav Morken, UNINETT AS <olav.morken@uninett.no>
758     * @author Jaime Perez, UNINETT AS <jaime.perez@uninett.no>
759     */
760    public static function getSelfURL()
761    {
762        $cfg = \SimpleSAML_Configuration::getInstance();
763        $baseDir = $cfg->getBaseDir();
764        $cur_path = realpath($_SERVER['SCRIPT_FILENAME']);
765        // make sure we got a string from realpath()
766        $cur_path = is_string($cur_path) ? $cur_path : '';
767        // find the path to the current script relative to the www/ directory of SimpleSAMLphp
768        $rel_path = str_replace($baseDir.'www'.DIRECTORY_SEPARATOR, '', $cur_path);
769        // convert that relative path to an HTTP query
770        $url_path = str_replace(DIRECTORY_SEPARATOR, '/', $rel_path);
771        // find where the relative path starts in the current request URI
772        $uri_pos = (!empty($url_path)) ? strpos($_SERVER['REQUEST_URI'], $url_path) : false;
773
774        if ($cur_path == $rel_path || $uri_pos === false) {
775            /*
776             * We were accessed from an external script. This can happen in the following cases:
777             *
778             * - $_SERVER['SCRIPT_FILENAME'] points to a script that doesn't exist. E.g. functional testing. In this
779             *   case, realpath() returns false and str_replace an empty string, so we compare them loosely.
780             *
781             * - The URI requested does not belong to a script in the www/ directory of SimpleSAMLphp. In that case,
782             *   removing SimpleSAMLphp's base dir from the current path yields the same path, so $cur_path and
783             *   $rel_path are equal.
784             *
785             * - The request URI does not match the current script. Even if the current script is located in the www/
786             *   directory of SimpleSAMLphp, the URI does not contain its relative path, and $uri_pos is false.
787             *
788             * It doesn't matter which one of those cases we have. We just know we can't apply our base URL to the
789             * current URI, so we need to build it back from the PHP environment, unless we have a base URL specified
790             * for this case in the configuration. First, check if that's the case.
791             */
792
793            /** @var \SimpleSAML_Configuration $appcfg */
794            $appcfg = $cfg->getConfigItem('application', null);
795            $appurl = ($appcfg instanceof \SimpleSAML_Configuration) ? $appcfg->getString('baseURL', '') : '';
796            if (!empty($appurl)) {
797                $protocol = parse_url($appurl, PHP_URL_SCHEME);
798                $hostname = parse_url($appurl, PHP_URL_HOST);
799                $port = parse_url($appurl, PHP_URL_PORT);
800                $port = !empty($port) ? ':'.$port : '';
801            } else { // no base URL specified for app, just use the current URL
802                $protocol = 'http';
803                $protocol .= (self::getServerHTTPS()) ? 's' : '';
804                $hostname = self::getServerHost();
805                $port = self::getServerPort();
806            }
807            return $protocol.'://'.$hostname.$port.$_SERVER['REQUEST_URI'];
808        }
809
810        return self::getBaseURL().$url_path.substr($_SERVER['REQUEST_URI'], $uri_pos + strlen($url_path));
811    }
812
813
814    /**
815     * Retrieve the current URL using the base URL in the configuration, containing the protocol, the host and
816     * optionally, the port number.
817     *
818     * @return string The current URL without path or query parameters.
819     *
820     * @author Andreas Solberg, UNINETT AS <andreas.solberg@uninett.no>
821     * @author Olav Morken, UNINETT AS <olav.morken@uninett.no>
822     */
823    public static function getSelfURLHost()
824    {
825        $url = self::getSelfURL();
826
827        /** @var int $colon getBaseURL() will allways return a valid URL */
828        $colon = strpos($url, '://');
829        $start = $colon + 3;
830        $length = strcspn($url, '/', $start) + $start;
831        return substr($url, 0, $length);
832    }
833
834
835    /**
836     * Retrieve the current URL using the base URL in the configuration, without the query parameters.
837     *
838     * @return string The current URL, not including query parameters.
839     *
840     * @author Andreas Solberg, UNINETT AS <andreas.solberg@uninett.no>
841     * @author Jaime Perez, UNINETT AS <jaime.perez@uninett.no>
842     */
843    public static function getSelfURLNoQuery()
844    {
845        $url = self::getSelfURL();
846        $pos = strpos($url, '?');
847        if (!$pos) {
848            return $url;
849        }
850        return substr($url, 0, $pos);
851    }
852
853
854    /**
855     * This function checks if we are using HTTPS as protocol.
856     *
857     * @return boolean True if the HTTPS is used, false otherwise.
858     *
859     * @author Olav Morken, UNINETT AS <olav.morken@uninett.no>
860     * @author Jaime Perez, UNINETT AS <jaime.perez@uninett.no>
861     */
862    public static function isHTTPS()
863    {
864        return strpos(self::getSelfURL(), 'https://') === 0;
865    }
866
867
868    /**
869     * Normalizes a URL to an absolute URL and validate it. In addition to resolving the URL, this function makes sure
870     * that it is a link to an http or https site.
871     *
872     * @param string $url The relative URL.
873     *
874     * @return string An absolute URL for the given relative URL.
875     * @throws \InvalidArgumentException If $url is not a string or a valid URL.
876     *
877     * @author Olav Morken, UNINETT AS <olav.morken@uninett.no>
878     * @author Jaime Perez, UNINETT AS <jaime.perez@uninett.no>
879     */
880    public static function normalizeURL($url)
881    {
882        if (!is_string($url)) {
883            throw new \InvalidArgumentException('Invalid input parameters.');
884        }
885
886        $url = self::resolveURL($url, self::getSelfURL());
887
888        // verify that the URL is to a http or https site
889        if (!preg_match('@^https?://@i', $url)) {
890            throw new \InvalidArgumentException('Invalid URL: '.$url);
891        }
892
893        return $url;
894    }
895
896
897    /**
898     * Parse a query string into an array.
899     *
900     * This function parses a query string into an array, similar to the way the builtin 'parse_str' works, except it
901     * doesn't handle arrays, and it doesn't do "magic quotes".
902     *
903     * Query parameters without values will be set to an empty string.
904     *
905     * @param string $query_string The query string which should be parsed.
906     *
907     * @return array The query string as an associative array.
908     * @throws \InvalidArgumentException If $query_string is not a string.
909     *
910     * @author Olav Morken, UNINETT AS <olav.morken@uninett.no>
911     */
912    public static function parseQueryString($query_string)
913    {
914        if (!is_string($query_string)) {
915            throw new \InvalidArgumentException('Invalid input parameters.');
916        }
917
918        $res = array();
919        if (empty($query_string)) {
920            return $res;
921        }
922
923        foreach (explode('&', $query_string) as $param) {
924            $param = explode('=', $param);
925            $name = urldecode($param[0]);
926            if (count($param) === 1) {
927                $value = '';
928            } else {
929                $value = urldecode($param[1]);
930            }
931            $res[$name] = $value;
932        }
933        return $res;
934    }
935
936
937    /**
938     * This function redirects to the specified URL without performing any security checks. Please, do NOT use this
939     * function with user supplied URLs.
940     *
941     * This function will use the "HTTP 303 See Other" redirection if the current request used the POST method and the
942     * HTTP version is 1.1. Otherwise, a "HTTP 302 Found" redirection will be used.
943     *
944     * The function will also generate a simple web page with a clickable  link to the target URL.
945     *
946     * @param string   $url The URL we should redirect to. This URL may include query parameters. If this URL is a
947     * relative URL (starting with '/'), then it will be turned into an absolute URL by prefixing it with the absolute
948     * URL to the root of the website.
949     * @param string[] $parameters An array with extra query string parameters which should be appended to the URL. The
950     * name of the parameter is the array index. The value of the parameter is the value stored in the index. Both the
951     * name and the value will be urlencoded. If the value is NULL, then the parameter will be encoded as just the
952     * name, without a value.
953     *
954     * @return void This function never returns.
955     * @throws \InvalidArgumentException If $url is not a string or $parameters is not an array.
956     *
957     * @author Jaime Perez, UNINETT AS <jaime.perez@uninett.no>
958     */
959    public static function redirectTrustedURL($url, $parameters = array())
960    {
961        if (!is_string($url) || !is_array($parameters)) {
962            throw new \InvalidArgumentException('Invalid input parameters.');
963        }
964
965        $url = self::normalizeURL($url);
966        self::redirect($url, $parameters);
967    }
968
969
970    /**
971     * This function redirects to the specified URL after performing the appropriate security checks on it.
972     * Particularly, it will make sure that the provided URL is allowed by the 'trusted.url.domains' directive in the
973     * configuration.
974     *
975     * If the aforementioned option is not set or the URL does correspond to a trusted site, it performs a redirection
976     * to it. If the site is not trusted, an exception will be thrown.
977     *
978     * @param string   $url The URL we should redirect to. This URL may include query parameters. If this URL is a
979     * relative URL (starting with '/'), then it will be turned into an absolute URL by prefixing it with the absolute
980     * URL to the root of the website.
981     * @param string[] $parameters An array with extra query string parameters which should be appended to the URL. The
982     * name of the parameter is the array index. The value of the parameter is the value stored in the index. Both the
983     * name and the value will be urlencoded. If the value is NULL, then the parameter will be encoded as just the
984     * name, without a value.
985     *
986     * @return void This function never returns.
987     * @throws \InvalidArgumentException If $url is not a string or $parameters is not an array.
988     *
989     * @author Jaime Perez, UNINETT AS <jaime.perez@uninett.no>
990     */
991    public static function redirectUntrustedURL($url, $parameters = array())
992    {
993        if (!is_string($url) || !is_array($parameters)) {
994            throw new \InvalidArgumentException('Invalid input parameters.');
995        }
996
997        $url = self::checkURLAllowed($url);
998        self::redirect($url, $parameters);
999    }
1000
1001
1002    /**
1003     * Resolve a (possibly relative) URL relative to a given base URL.
1004     *
1005     * This function supports these forms of relative URLs:
1006     * - ^\w+: Absolute URL. E.g. "http://www.example.com:port/path?query#fragment".
1007     * - ^// Same protocol. E.g. "//www.example.com:port/path?query#fragment"
1008     * - ^/ Same protocol and host. E.g. "/path?query#fragment".
1009     * - ^? Same protocol, host and path, replace query string & fragment. E.g. "?query#fragment".
1010     * - ^# Same protocol, host, path and query, replace fragment. E.g. "#fragment".
1011     * - The rest: Relative to the base path.
1012     *
1013     * @param string $url The relative URL.
1014     * @param string $base The base URL. Defaults to the base URL of this installation of SimpleSAMLphp.
1015     *
1016     * @return string An absolute URL for the given relative URL.
1017     * @throws \InvalidArgumentException If the base URL cannot be parsed into a valid URL, or the given parameters
1018     *     are not strings.
1019     *
1020     * @author Olav Morken, UNINETT AS <olav.morken@uninett.no>
1021     * @author Jaime Perez, UNINETT AS <jaime.perez@uninett.no>
1022     */
1023    public static function resolveURL($url, $base = null)
1024    {
1025        if ($base === null) {
1026            $base = self::getBaseURL();
1027        }
1028
1029        if (!is_string($url) || !is_string($base)) {
1030            throw new \InvalidArgumentException('Invalid input parameters.');
1031        }
1032
1033        if (!preg_match('/^((((\w+:)\/\/[^\/]+)(\/[^?#]*))(?:\?[^#]*)?)(?:#.*)?/', $base, $baseParsed)) {
1034            throw new \InvalidArgumentException('Unable to parse base url: '.$base);
1035        }
1036
1037        $baseDir = dirname($baseParsed[5].'filename');
1038        $baseScheme = $baseParsed[4];
1039        $baseHost = $baseParsed[3];
1040        $basePath = $baseParsed[2];
1041        $baseQuery = $baseParsed[1];
1042
1043        if (preg_match('$^\w+:$', $url)) {
1044            return $url;
1045        }
1046
1047        if (substr($url, 0, 2) === '//') {
1048            return $baseScheme.$url;
1049        }
1050
1051        if ($url[0] === '/') {
1052            return $baseHost.$url;
1053        }
1054        if ($url[0] === '?') {
1055            return $basePath.$url;
1056        }
1057        if ($url[0] === '#') {
1058            return $baseQuery.$url;
1059        }
1060
1061        // we have a relative path. Remove query string/fragment and save it as $tail
1062        $queryPos = strpos($url, '?');
1063        $fragmentPos = strpos($url, '#');
1064        if ($queryPos !== false || $fragmentPos !== false) {
1065            if ($queryPos === false) {
1066                $tailPos = $fragmentPos;
1067            } elseif ($fragmentPos === false) {
1068                $tailPos = $queryPos;
1069            } elseif ($queryPos < $fragmentPos) {
1070                $tailPos = $queryPos;
1071            } else {
1072                $tailPos = $fragmentPos;
1073            }
1074
1075            $tail = substr($url, $tailPos);
1076            $dir = substr($url, 0, $tailPos);
1077        } else {
1078            $dir = $url;
1079            $tail = '';
1080        }
1081
1082        $dir = System::resolvePath($dir, $baseDir);
1083
1084        return $baseHost.$dir.$tail;
1085    }
1086
1087
1088    /**
1089     * Set a cookie.
1090     *
1091     * @param string      $name The name of the cookie.
1092     * @param string|NULL $value The value of the cookie. Set to NULL to delete the cookie.
1093     * @param array|NULL  $params Cookie parameters.
1094     * @param bool        $throw Whether to throw exception if setcookie() fails.
1095     *
1096     * @throws \InvalidArgumentException If any parameter has an incorrect type.
1097     * @throws \SimpleSAML\Error\CannotSetCookie If the headers were already sent and the cookie cannot be set.
1098     *
1099     * @return void
1100     *
1101     * @author Andjelko Horvat
1102     * @author Jaime Perez, UNINETT AS <jaime.perez@uninett.no>
1103     */
1104    public static function setCookie($name, $value, $params = null, $throw = true)
1105    {
1106        if (!(is_string($name) && // $name must be a string
1107            (is_string($value) || is_null($value)) && // $value can be a string or null
1108            (is_array($params) || is_null($params)) && // $params can be an array or null
1109            is_bool($throw)) // $throw must be boolean
1110        ) {
1111            throw new \InvalidArgumentException('Invalid input parameters.');
1112        }
1113
1114        $default_params = array(
1115            'lifetime' => 0,
1116            'expire'   => null,
1117            'path'     => '/',
1118            'domain'   => null,
1119            'secure'   => false,
1120            'httponly' => true,
1121            'raw'      => false,
1122        );
1123
1124        if ($params !== null) {
1125            $params = array_merge($default_params, $params);
1126        } else {
1127            $params = $default_params;
1128        }
1129
1130        // Do not set secure cookie if not on HTTPS
1131        if ($params['secure'] && !self::isHTTPS()) {
1132            if ($throw) {
1133                throw new \SimpleSAML\Error\CannotSetCookie(
1134                    'Setting secure cookie on plain HTTP is not allowed.',
1135                    \SimpleSAML\Error\CannotSetCookie::SECURE_COOKIE
1136                );
1137            }
1138            Logger::warning('Error setting cookie: setting secure cookie on plain HTTP is not allowed.');
1139            return;
1140        }
1141
1142        if ($value === null) {
1143            $expire = time() - 365 * 24 * 60 * 60;
1144        } elseif (isset($params['expire'])) {
1145            $expire = $params['expire'];
1146        } elseif ($params['lifetime'] === 0) {
1147            $expire = 0;
1148        } else {
1149            $expire = time() + $params['lifetime'];
1150        }
1151
1152        if ($params['raw']) {
1153            $success = @setrawcookie(
1154                $name,
1155                $value,
1156                $expire,
1157                $params['path'],
1158                $params['domain'],
1159                $params['secure'],
1160                $params['httponly']
1161            );
1162        } else {
1163            $success = @setcookie(
1164                $name,
1165                $value,
1166                $expire,
1167                $params['path'],
1168                $params['domain'],
1169                $params['secure'],
1170                $params['httponly']
1171            );
1172        }
1173
1174        if (!$success) {
1175            if ($throw) {
1176                throw new \SimpleSAML\Error\CannotSetCookie(
1177                    'Headers already sent.',
1178                    \SimpleSAML\Error\CannotSetCookie::HEADERS_SENT
1179                );
1180            }
1181            Logger::warning('Error setting cookie: headers already sent.');
1182        }
1183    }
1184
1185
1186    /**
1187     * Submit a POST form to a specific destination.
1188     *
1189     * This function never returns.
1190     *
1191     * @param string $destination The destination URL.
1192     * @param array  $data An associative array with the data to be posted to $destination.
1193     *
1194     * @throws \InvalidArgumentException If $destination is not a string or $data is not an array.
1195     *
1196     * @return void
1197     *
1198     * @author Olav Morken, UNINETT AS <olav.morken@uninett.no>
1199     * @author Andjelko Horvat
1200     * @author Jaime Perez, UNINETT AS <jaime.perez@uninett.no>
1201     */
1202    public static function submitPOSTData($destination, $data)
1203    {
1204        if (!is_string($destination) || !is_array($data)) {
1205            throw new \InvalidArgumentException('Invalid input parameters.');
1206        }
1207
1208        $config = \SimpleSAML_Configuration::getInstance();
1209        $allowed = $config->getBoolean('enable.http_post', false);
1210
1211        if ($allowed && preg_match("#^http:#", $destination) && self::isHTTPS()) {
1212            // we need to post the data to HTTP
1213            self::redirect(self::getSecurePOSTRedirectURL($destination, $data));
1214        }
1215
1216        $p = new \SimpleSAML_XHTML_Template($config, 'post.php');
1217        $p->data['destination'] = $destination;
1218        $p->data['post'] = $data;
1219        $p->show();
1220        exit(0);
1221    }
1222}
1223