UrlHelper.php 15.2 KB
Newer Older
1 2 3 4 5 6
<?php

namespace Drupal\Component\Utility;

/**
 * Helper class URL based methods.
7 8
 *
 * @ingroup utility
9
 */
10
class UrlHelper {
11 12 13 14 15 16

  /**
   * The list of allowed protocols.
   *
   * @var array
   */
17
  protected static $allowedProtocols = ['http', 'https'];
18 19 20 21

  /**
   * Parses an array into a valid, rawurlencoded query string.
   *
22
   * Function rawurlencode() is RFC3986 compliant, and as a consequence RFC3987
23 24 25 26 27 28 29
   * compliant. The latter defines the required format of "URLs" in HTML5.
   * urlencode() is almost the same as rawurlencode(), except that it encodes
   * spaces as "+" instead of "%20". This makes its result non compliant to
   * RFC3986 and as a consequence non compliant to RFC3987 and as a consequence
   * not valid as a "URL" in HTML5.
   *
   * @param array $query
30 31
   *   The query parameter array to be processed; for instance,
   *   \Drupal::request()->query->all().
32
   * @param string $parent
33 34
   *   (optional) Internal use only. Used to build the $query array key for
   *   nested items. Defaults to an empty string.
35 36 37 38 39 40 41 42
   *
   * @return string
   *   A rawurlencoded string which can be used as or appended to the URL query
   *   string.
   *
   * @ingroup php_wrappers
   */
  public static function buildQuery(array $query, $parent = '') {
43
    $params = [];
44 45

    foreach ($query as $key => $value) {
46
      $key = ($parent ? $parent . rawurlencode('[' . $key . ']') : rawurlencode($key));
47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78

      // Recurse into children.
      if (is_array($value)) {
        $params[] = static::buildQuery($value, $key);
      }
      // If a query parameter value is NULL, only append its key.
      elseif (!isset($value)) {
        $params[] = $key;
      }
      else {
        // For better readability of paths in query strings, we decode slashes.
        $params[] = $key . '=' . str_replace('%2F', '/', rawurlencode($value));
      }
    }

    return implode('&', $params);
  }

  /**
   * Filters a URL query parameter array to remove unwanted elements.
   *
   * @param array $query
   *   An array to be processed.
   * @param array $exclude
   *   (optional) A list of $query array keys to remove. Use "parent[child]" to
   *   exclude nested items.
   * @param string $parent
   *   Internal use only. Used to build the $query array key for nested items.
   *
   * @return
   *   An array containing query parameters.
   */
79
  public static function filterQueryParameters(array $query, array $exclude = [], $parent = '') {
80 81 82 83 84 85 86 87
    // If $exclude is empty, there is nothing to filter.
    if (empty($exclude)) {
      return $query;
    }
    elseif (!$parent) {
      $exclude = array_flip($exclude);
    }

88
    $params = [];
89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106
    foreach ($query as $key => $value) {
      $string_key = ($parent ? $parent . '[' . $key . ']' : $key);
      if (isset($exclude[$string_key])) {
        continue;
      }

      if (is_array($value)) {
        $params[$key] = static::filterQueryParameters($value, $exclude, $string_key);
      }
      else {
        $params[$key] = $value;
      }
    }

    return $params;
  }

  /**
107
   * Parses a URL string into its path, query, and fragment components.
108
   *
109 110 111 112 113
   * This function splits both internal paths like @code node?b=c#d @endcode and
   * external URLs like @code https://example.com/a?b=c#d @endcode into their
   * component parts. See
   * @link http://tools.ietf.org/html/rfc3986#section-3 RFC 3986 @endlink for an
   * explanation of what the component parts are.
114
   *
115 116
   * Note that, unlike the RFC, when passed an external URL, this function
   * groups the scheme, authority, and path together into the path component.
117 118
   *
   * @param string $url
119
   *   The internal path or external URL string to parse.
120
   *
121 122 123 124 125 126 127
   * @return array
   *   An associative array containing:
   *   - path: The path component of $url. If $url is an external URL, this
   *     includes the scheme, authority, and path.
   *   - query: An array of query parameters from $url, if they exist.
   *   - fragment: The fragment component from $url, if it exists.
   *
128
   * @see \Drupal\Core\Utility\LinkGenerator
129
   * @see http://tools.ietf.org/html/rfc3986
130 131 132 133
   *
   * @ingroup php_wrappers
   */
  public static function parse($url) {
134
    $options = [
135
      'path' => NULL,
136
      'query' => [],
137
      'fragment' => '',
138
    ];
139 140 141

    // External URLs: not using parse_url() here, so we do not have to rebuild
    // the scheme, host, and path without having any use for it.
142 143 144 145 146 147
    // The URL is considered external if it contains the '://' delimiter. Since
    // a URL can also be passed as a query argument, we check if this delimiter
    // appears in front of the '?' query argument delimiter.
    $scheme_delimiter_position = strpos($url, '://');
    $query_delimiter_position = strpos($url, '?');
    if ($scheme_delimiter_position !== FALSE && ($query_delimiter_position === FALSE || $scheme_delimiter_position < $query_delimiter_position)) {
148 149 150 151 152
      // Split off the fragment, if any.
      if (strpos($url, '#') !== FALSE) {
        list($url, $options['fragment']) = explode('#', $url, 2);
      }

153
      // Split off everything before the query string into 'path'.
154
      $parts = explode('?', $url, 2);
155 156 157 158 159 160

      // Don't support URLs without a path, like 'http://'.
      list(, $path) = explode('://', $parts[0], 2);
      if ($path != '') {
        $options['path'] = $parts[0];
      }
161 162
      // If there is a query string, transform it into keyed query parameters.
      if (isset($parts[1])) {
163
        parse_str($parts[1], $options['query']);
164 165 166 167
      }
    }
    // Internal URLs.
    else {
168 169
      // parse_url() does not support relative URLs, so make it absolute. For
      // instance, the relative URL "foo/bar:1" isn't properly parsed.
170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199
      $parts = parse_url('http://example.com/' . $url);
      // Strip the leading slash that was just added.
      $options['path'] = substr($parts['path'], 1);
      if (isset($parts['query'])) {
        parse_str($parts['query'], $options['query']);
      }
      if (isset($parts['fragment'])) {
        $options['fragment'] = $parts['fragment'];
      }
    }

    return $options;
  }

  /**
   * Encodes a Drupal path for use in a URL.
   *
   * For aesthetic reasons slashes are not escaped.
   *
   * @param string $path
   *   The Drupal path to encode.
   *
   * @return string
   *   The encoded path.
   */
  public static function encodePath($path) {
    return str_replace('%2F', '/', rawurlencode($path));
  }

  /**
200
   * Determines whether a path is external to Drupal.
201
   *
202 203 204
   * An example of an external path is http://example.com. If a path cannot be
   * assessed by Drupal's menu handler, then we must treat it as potentially
   * insecure.
205 206 207 208 209 210 211 212 213 214
   *
   * @param string $path
   *   The internal path or external URL being linked to, such as "node/34" or
   *   "http://example.com/foo".
   *
   * @return bool
   *   TRUE or FALSE, where TRUE indicates an external path.
   */
  public static function isExternal($path) {
    $colonpos = strpos($path, ':');
215 216 217 218
    // Some browsers treat \ as / so normalize to forward slashes.
    $path = str_replace('\\', '/', $path);
    // If the path starts with 2 slashes then it is always considered an
    // external URL without an explicit protocol part.
219
    return (strpos($path, '//') === 0)
220 221 222 223 224 225 226 227
      // Leading control characters may be ignored or mishandled by browsers,
      // so assume such a path may lead to an external location. The \p{C}
      // character class matches all UTF-8 control, unassigned, and private
      // characters.
      || (preg_match('/^\p{C}/u', $path) !== 0)
      // Avoid calling static::stripDangerousProtocols() if there is any slash
      // (/), hash (#) or question_mark (?) before the colon (:) occurrence -
      // if any - as this would clearly mean it is not a URL.
228 229 230
      || ($colonpos !== FALSE
        && !preg_match('![/?#]!', substr($path, 0, $colonpos))
        && static::stripDangerousProtocols($path) == $path);
231 232 233 234 235 236 237 238 239 240
  }

  /**
   * Determines if an external URL points to this installation.
   *
   * @param string $url
   *   A string containing an external URL, such as "http://example.com/foo".
   * @param string $base_url
   *   The base URL string to check against, such as "http://example.com/"
   *
241
   * @return bool
242
   *   TRUE if the URL has the same domain and base path.
243 244
   *
   * @throws \InvalidArgumentException
245
   *   Exception thrown when either $url or $base_url are not fully qualified.
246 247
   */
  public static function externalIsLocal($url, $base_url) {
248 249 250 251
    // Some browsers treat \ as / so normalize to forward slashes.
    $url = str_replace('\\', '/', $url);

    // Leading control characters may be ignored or mishandled by browsers, so
252
    // assume such a path may lead to a non-local location. The \p{C} character
253 254 255 256 257
    // class matches all UTF-8 control, unassigned, and private characters.
    if (preg_match('/^\p{C}/u', $url) !== 0) {
      return FALSE;
    }

258
    $url_parts = parse_url($url);
259 260 261
    $base_parts = parse_url($base_url);

    if (empty($base_parts['host']) || empty($url_parts['host'])) {
262
      throw new \InvalidArgumentException('A path was passed when a fully qualified domain was expected.');
263
    }
264

265 266 267
    if (!isset($url_parts['path']) || !isset($base_parts['path'])) {
      return (!isset($base_parts['path']) || $base_parts['path'] == '/')
        && ($url_parts['host'] == $base_parts['host']);
268 269 270
    }
    else {
      // When comparing base paths, we need a trailing slash to make sure a
271 272
      // partial URL match isn't occurring. Since base_path() always returns
      // with a trailing slash, we don't need to add the trailing slash here.
273
      return ($url_parts['host'] == $base_parts['host'] && stripos($url_parts['path'], $base_parts['path']) === 0);
274 275 276 277 278 279 280 281 282 283 284 285 286 287 288
    }
  }

  /**
   * Processes an HTML attribute value and strips dangerous protocols from URLs.
   *
   * @param string $string
   *   The string with the attribute value.
   *
   * @return string
   *   Cleaned up and HTML-escaped version of $string.
   */
  public static function filterBadProtocol($string) {
    // Get the plain text representation of the attribute value (i.e. its
    // meaning).
289
    $string = Html::decodeEntities($string);
290
    return Html::escape(static::stripDangerousProtocols($string));
291 292
  }

293 294 295 296 297 298 299 300 301 302
  /**
   * Gets the allowed protocols.
   *
   * @return array
   *   An array of protocols, for example http, https and irc.
   */
  public static function getAllowedProtocols() {
    return static::$allowedProtocols;
  }

303 304 305 306 307 308
  /**
   * Sets the allowed protocols.
   *
   * @param array $protocols
   *   An array of protocols, for example http, https and irc.
   */
309
  public static function setAllowedProtocols(array $protocols = []) {
310 311 312 313
    static::$allowedProtocols = $protocols;
  }

  /**
314
   * Strips dangerous protocols (for example, 'javascript:') from a URI.
315 316 317
   *
   * This function must be called for all URIs within user-entered input prior
   * to being output to an HTML attribute value. It is often called as part of
318 319 320 321
   * \Drupal\Component\Utility\UrlHelper::filterBadProtocol() or
   * \Drupal\Component\Utility\Xss::filter(), but those functions return an
   * HTML-encoded string, so this function can be called independently when the
   * output needs to be a plain-text string for passing to functions that will
322 323 324 325
   * call Html::escape() separately. The exact behavior depends on the value:
   * - If the value is a well-formed (per RFC 3986) relative URL or
   *   absolute URL that does not use a dangerous protocol (like
   *   "javascript:"), then the URL remains unchanged. This includes all
326
   *   URLs generated via Url::toString().
327 328 329 330 331 332 333
   * - If the value is a well-formed absolute URL with a dangerous protocol,
   *   the protocol is stripped. This process is repeated on the remaining URL
   *   until it is stripped down to a safe protocol.
   * - If the value is not a well-formed URL, the same sanitization behavior as
   *   for well-formed URLs will be invoked, which strips most substrings that
   *   precede a ":". The result can be used in URL attributes such as "href"
   *   or "src" (only after calling Html::escape() separately), but this may not
334 335
   *   produce valid HTML (for example, malformed URLs within "href" attributes
   *   fail HTML validation). This can be avoided by using
336 337
   *   Url::fromUri($possibly_not_a_url)->toString(), which either throws an
   *   exception or returns a well-formed URL.
338 339 340 341 342 343 344 345 346
   *
   * @param string $uri
   *   A plain-text URI that might contain dangerous protocols.
   *
   * @return string
   *   A plain-text URI stripped of dangerous protocols. As with all plain-text
   *   strings, this return value must not be output to an HTML page without
   *   being sanitized first. However, it can be passed to functions
   *   expecting plain-text strings.
347 348 349 350
   *
   * @see \Drupal\Component\Utility\Html::escape()
   * @see \Drupal\Core\Url::toString()
   * @see \Drupal\Core\Url::fromUri()
351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418
   */
  public static function stripDangerousProtocols($uri) {
    $allowed_protocols = array_flip(static::$allowedProtocols);

    // Iteratively remove any invalid protocol found.
    do {
      $before = $uri;
      $colonpos = strpos($uri, ':');
      if ($colonpos > 0) {
        // We found a colon, possibly a protocol. Verify.
        $protocol = substr($uri, 0, $colonpos);
        // If a colon is preceded by a slash, question mark or hash, it cannot
        // possibly be part of the URL scheme. This must be a relative URL, which
        // inherits the (safe) protocol of the base document.
        if (preg_match('![/?#]!', $protocol)) {
          break;
        }
        // Check if this is a disallowed protocol. Per RFC2616, section 3.2.3
        // (URI Comparison) scheme comparison must be case-insensitive.
        if (!isset($allowed_protocols[strtolower($protocol)])) {
          $uri = substr($uri, $colonpos + 1);
        }
      }
    } while ($before != $uri);

    return $uri;
  }

  /**
   * Verifies the syntax of the given URL.
   *
   * This function should only be used on actual URLs. It should not be used for
   * Drupal menu paths, which can contain arbitrary characters.
   * Valid values per RFC 3986.
   *
   * @param string $url
   *   The URL to verify.
   * @param bool $absolute
   *   Whether the URL is absolute (beginning with a scheme such as "http:").
   *
   * @return bool
   *   TRUE if the URL is in a valid format, FALSE otherwise.
   */
  public static function isValid($url, $absolute = FALSE) {
    if ($absolute) {
      return (bool) preg_match("
        /^                                                      # Start at the beginning of the text
        (?:ftp|https?|feed):\/\/                                # Look for ftp, http, https or feed schemes
        (?:                                                     # Userinfo (optional) which is typically
          (?:(?:[\w\.\-\+!$&'\(\)*\+,;=]|%[0-9a-f]{2})+:)*      # a username or a username and password
          (?:[\w\.\-\+%!$&'\(\)*\+,;=]|%[0-9a-f]{2})+@          # combination
        )?
        (?:
          (?:[a-z0-9\-\.]|%[0-9a-f]{2})+                        # A domain name or a IPv4 address
          |(?:\[(?:[0-9a-f]{0,4}:)*(?:[0-9a-f]{0,4})\])         # or a well formed IPv6 address
        )
        (?::[0-9]+)?                                            # Server port number (optional)
        (?:[\/|\?]
          (?:[\w#!:\.\?\+=&@$'~*,;\/\(\)\[\]\-]|%[0-9a-f]{2})   # The path and query (optional)
        *)?
      $/xi", $url);
    }
    else {
      return (bool) preg_match("/^(?:[\w#!:\.\?\+=&@$'~*,;\/\(\)\[\]\-]|%[0-9a-f]{2})+$/i", $url);
    }
  }

}