Skip to content

Commit c83c46a

Browse files
Work around parse_url() bug (bis)
1 parent 11f6f8a commit c83c46a

File tree

7 files changed

+45
-22
lines changed

7 files changed

+45
-22
lines changed

CurlHttpClient.php

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -421,8 +421,9 @@ private static function createRedirectResolver(array $options, string $host): \C
421421
}
422422
}
423423

424-
return static function ($ch, string $location, bool $noContent) use (&$redirectHeaders, $options) {
424+
return static function ($ch, string $location, bool $noContent, bool &$locationHasHost) use (&$redirectHeaders, $options) {
425425
try {
426+
$locationHasHost = false;
426427
$location = self::parseUrl($location);
427428
} catch (InvalidArgumentException $e) {
428429
return null;
@@ -436,8 +437,10 @@ private static function createRedirectResolver(array $options, string $host): \C
436437
$redirectHeaders['with_auth'] = array_filter($redirectHeaders['with_auth'], $filterContentHeaders);
437438
}
438439

439-
if ($redirectHeaders && $host = parse_url('http:'.$location['authority'], \PHP_URL_HOST)) {
440-
$requestHeaders = $redirectHeaders['host'] === $host ? $redirectHeaders['with_auth'] : $redirectHeaders['no_auth'];
440+
$locationHasHost = isset($location['authority']);
441+
442+
if ($redirectHeaders && $locationHasHost) {
443+
$requestHeaders = parse_url($location['authority'], \PHP_URL_HOST) === $redirectHeaders['host'] ? $redirectHeaders['with_auth'] : $redirectHeaders['no_auth'];
441444
curl_setopt($ch, \CURLOPT_HTTPHEADER, $requestHeaders);
442445
} elseif ($noContent && $redirectHeaders) {
443446
curl_setopt($ch, \CURLOPT_HTTPHEADER, $redirectHeaders['with_auth']);

HttpClientTrait.php

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -514,29 +514,37 @@ private static function resolveUrl(array $url, ?array $base, array $queryDefault
514514
*/
515515
private static function parseUrl(string $url, array $query = [], array $allowedSchemes = ['http' => 80, 'https' => 443]): array
516516
{
517-
if (false === $parts = parse_url($url)) {
518-
if ('/' !== ($url[0] ?? '') || false === $parts = parse_url($url.'#')) {
519-
throw new InvalidArgumentException(sprintf('Malformed URL "%s".', $url));
520-
}
521-
unset($parts['fragment']);
517+
$tail = '';
518+
519+
if (false === $parts = parse_url(\strlen($url) !== strcspn($url, '?#') ? $url : $url.$tail = '#')) {
520+
throw new InvalidArgumentException(sprintf('Malformed URL "%s".', $url));
522521
}
523522

524523
if ($query) {
525524
$parts['query'] = self::mergeQueryString($parts['query'] ?? null, $query, true);
526525
}
527526

527+
$scheme = $parts['scheme'] ?? null;
528+
$host = $parts['host'] ?? null;
529+
530+
if (!$scheme && $host && !str_starts_with($url, '//')) {
531+
$parts = parse_url(':/'.$url.$tail);
532+
$parts['path'] = substr($parts['path'], 2);
533+
$scheme = $host = null;
534+
}
535+
528536
$port = $parts['port'] ?? 0;
529537

530-
if (null !== $scheme = $parts['scheme'] ?? null) {
538+
if (null !== $scheme) {
531539
if (!isset($allowedSchemes[$scheme = strtolower($scheme)])) {
532-
throw new InvalidArgumentException(sprintf('Unsupported scheme in "%s".', $url));
540+
throw new InvalidArgumentException(sprintf('Unsupported scheme in "%s": "%s" expected.', $url, implode('" or "', array_keys($allowedSchemes))));
533541
}
534542

535543
$port = $allowedSchemes[$scheme] === $port ? 0 : $port;
536544
$scheme .= ':';
537545
}
538546

539-
if (null !== $host = $parts['host'] ?? null) {
547+
if (null !== $host) {
540548
if (!\defined('INTL_IDNA_VARIANT_UTS46') && preg_match('/[\x80-\xFF]/', $host)) {
541549
throw new InvalidArgumentException(sprintf('Unsupported IDN "%s", try enabling the "intl" PHP extension or running "composer require symfony/polyfill-intl-idn".', $host));
542550
}
@@ -564,7 +572,7 @@ private static function parseUrl(string $url, array $query = [], array $allowedS
564572
'authority' => null !== $host ? '//'.(isset($parts['user']) ? $parts['user'].(isset($parts['pass']) ? ':'.$parts['pass'] : '').'@' : '').$host : null,
565573
'path' => isset($parts['path'][0]) ? $parts['path'] : null,
566574
'query' => isset($parts['query']) ? '?'.$parts['query'] : null,
567-
'fragment' => isset($parts['fragment']) ? '#'.$parts['fragment'] : null,
575+
'fragment' => isset($parts['fragment']) && !$tail ? '#'.$parts['fragment'] : null,
568576
];
569577
}
570578

NativeHttpClient.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -389,6 +389,7 @@ private static function createRedirectResolver(array $options, string $host, ?ar
389389
return null;
390390
}
391391

392+
$locationHasHost = isset($url['authority']);
392393
$url = self::resolveUrl($url, $info['url']);
393394
$info['redirect_url'] = implode('', $url);
394395

@@ -424,7 +425,7 @@ private static function createRedirectResolver(array $options, string $host, ?ar
424425

425426
[$host, $port] = self::parseHostPort($url, $info);
426427

427-
if (false !== (parse_url($location.'#', \PHP_URL_HOST) ?? false)) {
428+
if ($locationHasHost) {
428429
// Authorization and Cookie headers MUST NOT follow except for the initial host name
429430
$requestHeaders = $redirectHeaders['host'] === $host ? $redirectHeaders['with_auth'] : $redirectHeaders['no_auth'];
430431
$requestHeaders[] = 'Host: '.$host.$port;

Response/CurlResponse.php

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -436,17 +436,18 @@ private static function parseHeaderLine($ch, string $data, array &$info, array &
436436
$info['http_method'] = 'HEAD' === $info['http_method'] ? 'HEAD' : 'GET';
437437
curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, $info['http_method']);
438438
}
439+
$locationHasHost = false;
439440

440-
if (null === $info['redirect_url'] = $resolveRedirect($ch, $location, $noContent)) {
441+
if (null === $info['redirect_url'] = $resolveRedirect($ch, $location, $noContent, $locationHasHost)) {
441442
$options['max_redirects'] = curl_getinfo($ch, \CURLINFO_REDIRECT_COUNT);
442443
curl_setopt($ch, \CURLOPT_FOLLOWLOCATION, false);
443444
curl_setopt($ch, \CURLOPT_MAXREDIRS, $options['max_redirects']);
444-
} else {
445-
$url = parse_url($location ?? ':');
445+
} elseif ($locationHasHost) {
446+
$url = parse_url($info['redirect_url']);
446447

447-
if (isset($url['host']) && null !== $ip = $multi->dnsCache->hostnames[$url['host'] = strtolower($url['host'])] ?? null) {
448+
if (null !== $ip = $multi->dnsCache->hostnames[$url['host'] = strtolower($url['host'])] ?? null) {
448449
// Populate DNS cache for redirects if needed
449-
$port = $url['port'] ?? ('http' === ($url['scheme'] ?? parse_url(curl_getinfo($ch, \CURLINFO_EFFECTIVE_URL), \PHP_URL_SCHEME)) ? 80 : 443);
450+
$port = $url['port'] ?? ('http' === $url['scheme'] ? 80 : 443);
450451
curl_setopt($ch, \CURLOPT_RESOLVE, ["{$url['host']}:$port:$ip"]);
451452
$multi->dnsCache->removals["-{$url['host']}:$port"] = "-{$url['host']}:$port";
452453
}

Tests/HttpClientTestCase.php

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -489,4 +489,13 @@ public function testNoPrivateNetworkWithResolve()
489489

490490
$client->request('GET', 'http://symfony.com', ['resolve' => ['symfony.com' => '127.0.0.1']]);
491491
}
492+
493+
public function testNoRedirectWithInvalidLocation()
494+
{
495+
$client = $this->getHttpClient(__FUNCTION__);
496+
497+
$response = $client->request('GET', 'http://localhost:8057/302-no-scheme');
498+
499+
$this->assertSame(302, $response->getStatusCode());
500+
}
492501
}

Tests/HttpClientTraitTest.php

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ public static function provideResolveUrl(): array
102102
[self::RFC3986_BASE, 'g/../h', 'http://a/b/c/h'],
103103
[self::RFC3986_BASE, 'g;x=1/./y', 'http://a/b/c/g;x=1/y'],
104104
[self::RFC3986_BASE, 'g;x=1/../y', 'http://a/b/c/y'],
105+
[self::RFC3986_BASE, 'g/h:123/i', 'http://a/b/c/g/h:123/i'],
105106
// dot-segments in the query or fragment
106107
[self::RFC3986_BASE, 'g?y/./x', 'http://a/b/c/g?y/./x'],
107108
[self::RFC3986_BASE, 'g?y/../x', 'http://a/b/c/g?y/../x'],
@@ -127,14 +128,14 @@ public static function provideResolveUrl(): array
127128
public function testResolveUrlWithoutScheme()
128129
{
129130
$this->expectException(InvalidArgumentException::class);
130-
$this->expectExceptionMessage('Invalid URL: scheme is missing in "//localhost:8080". Did you forget to add "http(s)://"?');
131+
$this->expectExceptionMessage('Unsupported scheme in "localhost:8080": "http" or "https" expected.');
131132
self::resolveUrl(self::parseUrl('localhost:8080'), null);
132133
}
133134

134-
public function testResolveBaseUrlWitoutScheme()
135+
public function testResolveBaseUrlWithoutScheme()
135136
{
136137
$this->expectException(InvalidArgumentException::class);
137-
$this->expectExceptionMessage('Invalid URL: scheme is missing in "//localhost:8081". Did you forget to add "http(s)://"?');
138+
$this->expectExceptionMessage('Unsupported scheme in "localhost:8081": "http" or "https" expected.');
138139
self::resolveUrl(self::parseUrl('/foo'), self::parseUrl('localhost:8081'));
139140
}
140141

composer.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
"php": ">=7.2.5",
2626
"psr/log": "^1|^2|^3",
2727
"symfony/deprecation-contracts": "^2.1|^3",
28-
"symfony/http-client-contracts": "^2.5.3",
28+
"symfony/http-client-contracts": "^2.5.4",
2929
"symfony/polyfill-php73": "^1.11",
3030
"symfony/polyfill-php80": "^1.16",
3131
"symfony/service-contracts": "^1.0|^2|^3"

0 commit comments

Comments
 (0)