| @@ -72,14 +72,28 @@ | ||
| 72 | 72 | { |
| 73 | 73 | if (self::isPathNoSchemeReference($url)) { |
| 74 | 74 | return self::parsePathNoSchemeReference($url); |
| 75 | 75 | } |
| 76 | - // Preserve bracketed IPv6 literals before encoding, including dotted IPv4 tails. | |
| 76 | + // Preserve bracketed IPv6 literals before encoding, including dotted IPv4 | |
| 77 | + // tails. DEL (\x7F) is excluded so a raw-DEL host falls through to the | |
| 78 | + // general path and is rejected rather than silently mutated by parse_url(). | |
| 77 | 79 | $prefix = ''; |
| 78 | - if (\preg_match('%^([0-9A-Za-z+.-]+://\\[[0-9:.a-fA-F]+\\])(.*?)$%', $url, $matches)) { | |
| 80 | + $ipv6Prefix = \preg_match('%\\A([0-9A-Za-z+.-]+://\\[[^\\]\\x00-\\x20\\x7F/?#@]+\\])(.*)\\z%s', $url, $matches); | |
| 81 | + if ($ipv6Prefix === \false) { | |
| 82 | + return \false; | |
| 83 | + } | |
| 84 | + if ($ipv6Prefix === 1) { | |
| 79 | 85 | /** @var array{0:string, 1:string, 2:string} $matches */ |
| 86 | + $suffix = $matches[2]; | |
| 87 | + // After the bracketed host only an optional numeric port and/or a | |
| 88 | + // path, query, or fragment may follow. Anything else (for example | |
| 89 | + // `:80@evil` or `:80x`) would let parse_url() reinterpret a | |
| 90 | + // different host. | |
| 91 | + if (\preg_match('%\\A(?::[0-9]*)?(?:[/?#].*)?\\z%s', $suffix) !== 1) { | |
| 92 | + return \false; | |
| 93 | + } | |
| 80 | 94 | $prefix = $matches[1]; |
| 81 | - $url = $matches[2]; | |
| 95 | + $url = $suffix; | |
| 82 | 96 | } |
| 83 | 97 | /** @var string|null */ |
| 84 | 98 | $encodedUrl = \preg_replace_callback('%[^:/@?&=#]+%usD', static function ($matches) { |
| 85 | 99 | return \urlencode($matches[0]); |
| @@ -340,11 +354,26 @@ | ||
| 340 | 354 | { |
| 341 | 355 | if ($host === '') { |
| 342 | 356 | return; |
| 343 | 357 | } |
| 344 | - if (\preg_match('/[\\x00-\\x20\\x7F]/', $host)) { | |
| 358 | + // Reject control characters and URI authority delimiters so getHost() | |
| 359 | + // cannot disagree with the on-wire authority. | |
| 360 | + $invalidHost = \preg_match('/[\\x00-\\x20\\x7F\\/\\?#@\\\\]/', $host); | |
| 361 | + if ($invalidHost === \false) { | |
| 362 | + throw new \RuntimeException('Unable to validate URI host: ' . \preg_last_error_msg()); | |
| 363 | + } | |
| 364 | + if ($invalidHost === 1) { | |
| 345 | 365 | throw new \InvalidArgumentException(\sprintf('Invalid host: "%s"', $host)); |
| 346 | 366 | } |
| 367 | + if (\strpos($host, '[') !== \false || \strpos($host, ']') !== \false) { | |
| 368 | + if ($host[0] !== '[' || \substr($host, -1) !== ']') { | |
| 369 | + throw new \InvalidArgumentException(\sprintf('Invalid host: "%s"', $host)); | |
| 370 | + } | |
| 371 | + return; | |
| 372 | + } | |
| 373 | + if (\strpos($host, ':') !== \false) { | |
| 374 | + throw new \InvalidArgumentException(\sprintf('Invalid host: "%s"', $host)); | |
| 375 | + } | |
| 347 | 376 | } |
| 348 | 377 | public function getScheme() : string |
| 349 | 378 | { |
| 350 | 379 | return $this->scheme; |
| @@ -499,9 +528,9 @@ | ||
| 499 | 528 | { |
| 500 | 529 | if (!\is_string($scheme)) { |
| 501 | 530 | throw new \InvalidArgumentException('Scheme must be a string'); |
| 502 | 531 | } |
| 503 | - $scheme = \strtr($scheme, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'); | |
| 532 | + $scheme = \YoastSEO_Vendor\GuzzleHttp\Psr7\Utils::asciiToLower($scheme); | |
| 504 | 533 | if ($scheme !== '' && !\preg_match('/^[a-z][a-z0-9.+-]*$/D', $scheme)) { |
| 505 | 534 | \YoastSEO_Vendor\trigger_deprecation('guzzlehttp/psr7', '2.11', 'Passing "%s" as a URI scheme is deprecated; guzzlehttp/psr7 3.0 requires URI schemes to match RFC 3986 syntax and begin with a letter.', $scheme); |
| 506 | 535 | } |
| 507 | 536 | return $scheme; |
| @@ -515,9 +544,9 @@ | ||
| 515 | 544 | { |
| 516 | 545 | if (!\is_string($component)) { |
| 517 | 546 | throw new \InvalidArgumentException('User info must be a string'); |
| 518 | 547 | } |
| 519 | - return \preg_replace_callback('/(?:[^%' . \YoastSEO_Vendor\GuzzleHttp\Psr7\Rfc3986::CHAR_UNRESERVED . \YoastSEO_Vendor\GuzzleHttp\Psr7\Rfc3986::CHAR_SUB_DELIMS . ']+|%(?![A-Fa-f0-9]{2}))/', [$this, 'rawurlencodeMatchZero'], $component); | |
| 548 | + return $this->filterComponent('/(?:[^%' . \YoastSEO_Vendor\GuzzleHttp\Psr7\Rfc3986::CHAR_UNRESERVED . \YoastSEO_Vendor\GuzzleHttp\Psr7\Rfc3986::CHAR_SUB_DELIMS . ']+|%(?![A-Fa-f0-9]{2}))/', $component, 'Unable to filter URI user info'); | |
| 520 | 549 | } |
| 521 | 550 | /** |
| 522 | 551 | * @param mixed $host |
| 523 | 552 | * |
| @@ -527,9 +556,9 @@ | ||
| 527 | 556 | { |
| 528 | 557 | if (!\is_string($host)) { |
| 529 | 558 | throw new \InvalidArgumentException('Host must be a string'); |
| 530 | 559 | } |
| 531 | - $host = \strtr($host, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'); | |
| 560 | + $host = \YoastSEO_Vendor\GuzzleHttp\Psr7\Utils::asciiToLower($host); | |
| 532 | 561 | self::assertValidHost($host); |
| 533 | 562 | return $host; |
| 534 | 563 | } |
| 535 | 564 | /** |
| @@ -595,9 +624,9 @@ | ||
| 595 | 624 | { |
| 596 | 625 | if (!\is_string($path)) { |
| 597 | 626 | throw new \InvalidArgumentException('Path must be a string'); |
| 598 | 627 | } |
| 599 | - return \preg_replace_callback('/(?:[^' . \YoastSEO_Vendor\GuzzleHttp\Psr7\Rfc3986::CHAR_UNRESERVED . \YoastSEO_Vendor\GuzzleHttp\Psr7\Rfc3986::CHAR_SUB_DELIMS . '%:@\\/]++|%(?![A-Fa-f0-9]{2}))/', [$this, 'rawurlencodeMatchZero'], $path); | |
| 628 | + return $this->filterComponent('/(?:[^' . \YoastSEO_Vendor\GuzzleHttp\Psr7\Rfc3986::CHAR_UNRESERVED . \YoastSEO_Vendor\GuzzleHttp\Psr7\Rfc3986::CHAR_SUB_DELIMS . '%:@\\/]++|%(?![A-Fa-f0-9]{2}))/', $path, 'Unable to filter URI path'); | |
| 600 | 629 | } |
| 601 | 630 | /** |
| 602 | 631 | * Filters the query string or fragment of a URI. |
| 603 | 632 | * |
| @@ -609,9 +638,17 @@ | ||
| 609 | 638 | { |
| 610 | 639 | if (!\is_string($str)) { |
| 611 | 640 | throw new \InvalidArgumentException('Query and fragment must be a string'); |
| 612 | 641 | } |
| 613 | - return \preg_replace_callback('/(?:[^' . \YoastSEO_Vendor\GuzzleHttp\Psr7\Rfc3986::CHAR_UNRESERVED . \YoastSEO_Vendor\GuzzleHttp\Psr7\Rfc3986::CHAR_SUB_DELIMS . '%:@\\/\\?]++|%(?![A-Fa-f0-9]{2}))/', [$this, 'rawurlencodeMatchZero'], $str); | |
| 642 | + return $this->filterComponent('/(?:[^' . \YoastSEO_Vendor\GuzzleHttp\Psr7\Rfc3986::CHAR_UNRESERVED . \YoastSEO_Vendor\GuzzleHttp\Psr7\Rfc3986::CHAR_SUB_DELIMS . '%:@\\/\\?]++|%(?![A-Fa-f0-9]{2}))/', $str, 'Unable to filter URI query or fragment'); | |
| 643 | + } | |
| 644 | + private function filterComponent(string $pattern, string $component, string $context) : string | |
| 645 | + { | |
| 646 | + $filtered = \preg_replace_callback($pattern, [$this, 'rawurlencodeMatchZero'], $component); | |
| 647 | + if ($filtered === null) { | |
| 648 | + throw new \RuntimeException($context . ': ' . \preg_last_error_msg()); | |
| 649 | + } | |
| 650 | + return $filtered; | |
| 614 | 651 | } |
| 615 | 652 | private function rawurlencodeMatchZero(array $match) : string |
| 616 | 653 | { |
| 617 | 654 | return \rawurlencode($match[0]); |