Skip to content

Commit

Permalink
Merge pull request #221 from php-http/fix-redirect-host
Browse files Browse the repository at this point in the history
redirection to different domain must not keep previous port
  • Loading branch information
dbu committed Sep 29, 2022
2 parents 76730e3 + 509e513 commit 4a962c6
Show file tree
Hide file tree
Showing 19 changed files with 108 additions and 76 deletions.
15 changes: 15 additions & 0 deletions .github/workflows/static.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ name: static

on:
push:
branches:
- master
pull_request:

jobs:
Expand All @@ -19,3 +21,16 @@ jobs:
REQUIRE_DEV: false
with:
args: analyze --no-progress

php-cs-fixer:
name: PHP-CS-Fixer
runs-on: ubuntu-latest

steps:
- name: Checkout code
uses: actions/checkout@v2

- name: PHP-CS-Fixer
uses: docker://oskarstark/php-cs-fixer-ga
with:
args: --dry-run --diff
2 changes: 2 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ name: tests

on:
push:
branches:
- master
pull_request:

jobs:
Expand Down
4 changes: 2 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/.php_cs
/.php_cs.cache
/.php-cs-fixer.php
/.php-cs-fixer.cache
/behat.yml
/build/
/composer.lock
Expand Down
18 changes: 18 additions & 0 deletions .php-cs-fixer.dist.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<?php

$finder = PhpCsFixer\Finder::create()
->in(__DIR__.'/src')
->in(__DIR__.'/tests')
->name('*.php')
;

$config = new PhpCsFixer\Config();

return $config
->setRiskyAllowed(true)
->setRules([
'@Symfony' => true,
'single_line_throw' => false,
])
->setFinder($finder)
;
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@

### Fixed

- Fixes false positive circular detection in RedirectPlugin in cases when target location does not contain path
- [RedirectPlugin] Fixed handling of redirection to different domain with default port
- [RedirectPlugin] Fixed false positive circular detection in RedirectPlugin in cases when target location does not contain path

## 2.5.0 - 2021-11-26

Expand Down
50 changes: 0 additions & 50 deletions spec/Plugin/RedirectPluginSpec.php
Original file line number Diff line number Diff line change
Expand Up @@ -506,54 +506,4 @@ public function it_throws_circular_redirection_exception_on_alternating_redirect
$promise->shouldReturnAnInstanceOf(HttpRejectedPromise::class);
$promise->shouldThrow(CircularRedirectionException::class)->duringWait();
}

public function it_redirects_http_to_https(
UriInterface $uri,
UriInterface $uriRedirect,
RequestInterface $request,
ResponseInterface $responseRedirect,
RequestInterface $modifiedRequest,
ResponseInterface $finalResponse,
Promise $promise
) {
$responseRedirect->getStatusCode()->willReturn(302);
$responseRedirect->hasHeader('Location')->willReturn(true);
$responseRedirect->getHeaderLine('Location')->willReturn('https://my-site.com/original');

$request->getUri()->willReturn($uri);
$request->withUri($uriRedirect)->willReturn($modifiedRequest);
$uri->__toString()->willReturn('http://my-site.com/original');
$uri->withPath('/original')->willReturn($uri);
$uri->withFragment('')->willReturn($uri);
$uri->withQuery('')->willReturn($uri);

$uri->withScheme('https')->willReturn($uriRedirect);
$uriRedirect->withHost('my-site.com')->willReturn($uriRedirect);
$uriRedirect->withPath('/original')->willReturn($uriRedirect);
$uriRedirect->withFragment('')->willReturn($uriRedirect);
$uriRedirect->withQuery('')->willReturn($uriRedirect);
$uriRedirect->__toString()->willReturn('https://my-site.com/original');

$modifiedRequest->getUri()->willReturn($uriRedirect);
$modifiedRequest->getMethod()->willReturn('GET');

$next = function (RequestInterface $receivedRequest) use ($request, $responseRedirect) {
if (Argument::is($request->getWrappedObject())->scoreArgument($receivedRequest)) {
return new HttpFulfilledPromise($responseRedirect->getWrappedObject());
}
};

$first = function (RequestInterface $receivedRequest) use ($modifiedRequest, $promise) {
if (Argument::is($modifiedRequest->getWrappedObject())->scoreArgument($receivedRequest)) {
return $promise->getWrappedObject();
}
};

$promise->getState()->willReturn(Promise::FULFILLED);
$promise->wait()->shouldBeCalled()->willReturn($finalResponse);

$finalPromise = $this->handleRequest($request, $next, $first);
$finalPromise->shouldReturnAnInstanceOf(HttpFulfilledPromise::class);
$finalPromise->wait()->shouldReturn($finalResponse);
}
}
5 changes: 4 additions & 1 deletion src/Deferred.php
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,10 @@ public function wait($unwrap = true)
return $this->value;
}

/** @var ClientExceptionInterface */
if (null === $this->failure) {
throw new \RuntimeException('Internal Error: Promise is not fulfilled but has no exception stored');
}

throw $this->failure;
}
}
4 changes: 2 additions & 2 deletions src/HttpClientPool/HttpClientPool.php
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,9 @@ public function addHttpClient($client): void
/**
* Return an http client given a specific strategy.
*
* @throws HttpClientNotFoundException When no http client has been found into the pool
*
* @return HttpClientPoolItem Return a http client that can do both sync or async
*
* @throws HttpClientNotFoundException When no http client has been found into the pool
*/
abstract protected function chooseHttpClient(): HttpClientPoolItem;

Expand Down
2 changes: 1 addition & 1 deletion src/Plugin/AddHostPlugin.php
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ final class AddHostPlugin implements Plugin
* @param array{'replace'?: bool} $config
*
* Configuration options:
* - replace: True will replace all hosts, false will only add host when none is specified.
* - replace: True will replace all hosts, false will only add host when none is specified
*/
public function __construct(UriInterface $host, array $config = [])
{
Expand Down
2 changes: 1 addition & 1 deletion src/Plugin/AddPathPlugin.php
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ public function handleRequest(RequestInterface $request, callable $next, callabl
if (substr($path, 0, strlen($prepend)) !== $prepend) {
$request = $request->withUri($request->getUri()
->withPath($prepend.$path)
);
);
}

return $next($request);
Expand Down
2 changes: 1 addition & 1 deletion src/Plugin/ContentTypePlugin.php
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ final class ContentTypePlugin implements Plugin
*
* Configuration options:
* - skip_detection: true skip detection if stream size is bigger than $size_limit
* - size_limit: size stream limit for which the detection as to be skipped.
* - size_limit: size stream limit for which the detection as to be skipped
*/
public function __construct(array $config = [])
{
Expand Down
2 changes: 1 addition & 1 deletion src/Plugin/DecoderPlugin.php
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ final class DecoderPlugin implements Plugin
* @param array{'use_content_encoding'?: bool} $config
*
* Configuration options:
* - use_content_encoding: Whether this plugin should look at the Content-Encoding header first or only at the Transfer-Encoding (defaults to true).
* - use_content_encoding: Whether this plugin should look at the Content-Encoding header first or only at the Transfer-Encoding (defaults to true)
*/
public function __construct(array $config = [])
{
Expand Down
6 changes: 3 additions & 3 deletions src/Plugin/ErrorPlugin.php
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ final class ErrorPlugin implements Plugin
* @param array{'only_server_exception'?: bool} $config
*
* Configuration options:
* - only_server_exception: Whether this plugin should only throw 5XX Exceptions (default to false).
* - only_server_exception: Whether this plugin should only throw 5XX Exceptions (default to false)
*/
public function __construct(array $config = [])
{
Expand Down Expand Up @@ -72,10 +72,10 @@ public function handleRequest(RequestInterface $request, callable $next, callabl
* @param RequestInterface $request Request of the call
* @param ResponseInterface $response Response of the call
*
* @return ResponseInterface If status code is not in 4xx or 5xx return response
*
* @throws ClientErrorException If response status code is a 4xx
* @throws ServerErrorException If response status code is a 5xx
*
* @return ResponseInterface If status code is not in 4xx or 5xx return response
*/
private function transformResponseToException(RequestInterface $request, ResponseInterface $response): ResponseInterface
{
Expand Down
36 changes: 32 additions & 4 deletions src/Plugin/RedirectPlugin.php
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ final class RedirectPlugin implements Plugin
* Configuration options:
* - preserve_header: True keeps all headers, false remove all of them, an array is interpreted as a list of header names to keep
* - use_default_for_multiple: Whether the location header must be directly used for a multiple redirection status code (300)
* - strict: When true, redirect codes 300, 301, 302 will not modify request method and body.
* - strict: When true, redirect codes 300, 301, 302 will not modify request method and body
*/
public function __construct(array $config = [])
{
Expand Down Expand Up @@ -226,13 +226,39 @@ private function createUri(ResponseInterface $redirectResponse, RequestInterface
$location = $redirectResponse->getHeaderLine('Location');
$parsedLocation = parse_url($location);

if (false === $parsedLocation) {
throw new HttpException(sprintf('Location %s could not be parsed', $location), $originalRequest, $redirectResponse);
if (false === $parsedLocation || '' === $location) {
throw new HttpException(sprintf('Location "%s" could not be parsed', $location), $originalRequest, $redirectResponse);
}

$uri = $originalRequest->getUri();

// Redirections can either use an absolute uri or a relative reference https://www.rfc-editor.org/rfc/rfc3986#section-4.2
// If relative, we need to check if we have an absolute path or not

$path = array_key_exists('path', $parsedLocation) ? $parsedLocation['path'] : '';
if (!array_key_exists('host', $parsedLocation) && '/' !== $location[0]) {
// the target is a relative-path reference, we need to merge it with the base path
$originalPath = $uri->getPath();
if ('' === $path) {
$path = $originalPath;
} elseif (($pos = strrpos($originalPath, '/')) !== false) {
$path = substr($originalPath, 0, $pos + 1).$path;
} else {
$path = '/'.$path;
}
/* replace '/./' or '/foo/../' with '/' */
$re = ['#(/\./)#', '#/(?!\.\.)[^/]+/\.\./#'];
for ($n = 1; $n > 0; $path = preg_replace($re, '/', $path, -1, $n)) {
if (null === $path) {
throw new HttpException(sprintf('Failed to resolve Location %s', $location), $originalRequest, $redirectResponse);
}
}
}
if (null === $path) {
throw new HttpException(sprintf('Failed to resolve Location %s', $location), $originalRequest, $redirectResponse);
}
$uri = $uri
->withPath(array_key_exists('path', $parsedLocation) ? $parsedLocation['path'] : '')
->withPath($path)
->withQuery(array_key_exists('query', $parsedLocation) ? $parsedLocation['query'] : '')
->withFragment(array_key_exists('fragment', $parsedLocation) ? $parsedLocation['fragment'] : '')
;
Expand All @@ -247,6 +273,8 @@ private function createUri(ResponseInterface $redirectResponse, RequestInterface

if (array_key_exists('port', $parsedLocation)) {
$uri = $uri->withPort($parsedLocation['port']);
} elseif (array_key_exists('host', $parsedLocation)) {
$uri = $uri->withPort(null);
}

return $uri;
Expand Down
1 change: 1 addition & 0 deletions src/PluginChain.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
namespace Http\Client\Common;

use function array_reverse;

use Http\Client\Common\Exception\LoopException;
use Http\Promise\Promise;
use Psr\Http\Message\RequestInterface;
Expand Down
1 change: 0 additions & 1 deletion src/PluginClient.php
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,6 @@ private function configure(array $options = []): array
*/
private function createPluginChain(array $plugins, callable $clientCallable): callable
{
/** @var callable(RequestInterface): Promise */
return new PluginChain($plugins, $clientCallable, $this->options);
}
}
4 changes: 2 additions & 2 deletions src/PluginClientFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,11 @@ public static function setFactory(callable $factory): void
/**
* @param ClientInterface|HttpAsyncClient $client
* @param Plugin[] $plugins
* @param array{'client_name'?: string} $options
* @param array{'client_name'?: string} $options
*
* Configuration options:
* - client_name: to give client a name which may be used when displaying client information
* like in the HTTPlugBundle profiler.
* like in the HTTPlugBundle profiler
*
* @see PluginClient constructor for PluginClient specific $options.
*/
Expand Down
23 changes: 20 additions & 3 deletions tests/Plugin/RedirectPluginTest.php
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
<?php

declare(strict_types=1);

namespace Plugin;
Expand Down Expand Up @@ -26,10 +27,26 @@ function () {}
)->wait();
}

public function provideRedirections(): array
{
return [
'no path on target' => ['https://example.com/path?query=value', 'https://example.com?query=value', 'https://example.com?query=value'],
'root path on target' => ['https://example.com/path?query=value', 'https://example.com/?query=value', 'https://example.com/?query=value'],
'redirect to query' => ['https://example.com', 'https://example.com?query=value', 'https://example.com?query=value'],
'redirect to different domain without port' => ['https://example.com:8000', 'https://foo.com?query=value', 'https://foo.com?query=value'],
'network-path redirect, preserve scheme' => ['https://example.com:8000', '//foo.com/path?query=value', 'https://foo.com/path?query=value'],
'absolute-path redirect, preserve host' => ['https://example.com:8000', '/path?query=value', 'https://example.com:8000/path?query=value'],
'relative-path redirect, append' => ['https://example.com:8000/path/', 'sub/path?query=value', 'https://example.com:8000/path/sub/path?query=value'],
'relative-path on non-folder' => ['https://example.com:8000/path/foo', 'sub/path?query=value', 'https://example.com:8000/path/sub/path?query=value'],
'relative-path moving up' => ['https://example.com:8000/path/', '../other?query=value', 'https://example.com:8000/other?query=value'],
'relative-path with ./' => ['https://example.com:8000/path/', './other?query=value', 'https://example.com:8000/path/other?query=value'],
'relative-path with //' => ['https://example.com:8000/path/', 'other//sub?query=value', 'https://example.com:8000/path/other//sub?query=value'],
'relative-path redirect with only query' => ['https://example.com:8000/path', '?query=value', 'https://example.com:8000/path?query=value'],
];
}

/**
* @testWith ["https://example.com/path?query=value", "https://example.com?query=value", "https://example.com?query=value"]
* ["https://example.com/path?query=value", "https://example.com/?query=value", "https://example.com/?query=value"]
* ["https://example.com", "https://example.com?query=value", "https://example.com?query=value"]
* @dataProvider provideRedirections
*/
public function testTargetUriMappingFromLocationHeader(string $originalUri, string $locationUri, string $targetUri): void
{
Expand Down
4 changes: 1 addition & 3 deletions tests/PluginChainTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,13 @@
use Http\Client\Common\PluginChain;
use Http\Promise\Promise;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Psr\Http\Message\RequestInterface;

class PluginChainTest extends TestCase
{
private function createPlugin(callable $func): Plugin
{
return new class ($func) implements Plugin
{
return new class($func) implements Plugin {
public $func;

public function __construct(callable $func)
Expand Down

0 comments on commit 4a962c6

Please sign in to comment.