diff --git a/src/Builders/CurlOptionsBuilder.php b/src/Builders/CurlOptionsBuilder.php index 68659a9..8723cec 100644 --- a/src/Builders/CurlOptionsBuilder.php +++ b/src/Builders/CurlOptionsBuilder.php @@ -29,8 +29,7 @@ public function build(ClientOptions $options): array CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => $options->timeout, CURLOPT_CONNECTTIMEOUT => $options->connectTimeout, - CURLOPT_FOLLOWLOCATION => $options->followRedirects, - CURLOPT_MAXREDIRS => $options->maxRedirects, + CURLOPT_FOLLOWLOCATION => false, // Handled in User-land PHP CURLOPT_SSL_VERIFYPEER => $options->verifySSL, CURLOPT_SSL_VERIFYHOST => $options->verifySSL ? 2 : 0, CURLOPT_USERAGENT => $options->userAgent, diff --git a/src/Handlers/Curl/SSEHandler.php b/src/Handlers/Curl/SSEHandler.php index 6212ee4..ec59d6b 100644 --- a/src/Handlers/Curl/SSEHandler.php +++ b/src/Handlers/Curl/SSEHandler.php @@ -254,7 +254,7 @@ private function createSSEConnection( $httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); if (! $headersProcessed && $httpCode > 0 && $trimmedHeader === '') { - if ($httpCode >= 200 && $httpCode < 300) { + if (($httpCode >= 200 && $httpCode < 300) || ($httpCode >= 300 && $httpCode < 400)) { $parsedHeaders = $this->parseRawHeaders($rawHeaders); $stream = new Stream(); diff --git a/src/Handlers/InterceptorHandler.php b/src/Handlers/InterceptorHandler.php index f6267a6..9a4c32b 100644 --- a/src/Handlers/InterceptorHandler.php +++ b/src/Handlers/InterceptorHandler.php @@ -14,6 +14,8 @@ /** * Handles the unified interceptor pipeline. + * + * @internal */ class InterceptorHandler { diff --git a/src/Handlers/RedirectHandler.php b/src/Handlers/RedirectHandler.php new file mode 100644 index 0000000..f42cd8a --- /dev/null +++ b/src/Handlers/RedirectHandler.php @@ -0,0 +1,156 @@ + $interceptors + */ + public function __construct( + private InterceptorHandler $interceptorHandler, + private array $interceptors, + private bool $followRedirects, + private int $maxRedirects + ) { + } + + /** + * Dispatches the request and automatically follows redirects up to the configured limit. + * + * @template TResult + * + * @param RequestInterface $request The initial request to dispatch. + * @param callable(RequestInterface): PromiseInterface $executor The transport execution closure. + * @param bool $requireResponse Whether the pipeline must strictly return a ResponseInterface. + * @return PromiseInterface + */ + public function dispatch( + RequestInterface $request, + callable $executor, + bool $requireResponse + ): PromiseInterface { + /** @var PromiseInterface|null $currentPromise */ + $currentPromise = null; + + /** @var PromiseInterface $outerPromise */ + $outerPromise = async(function () use ($request, $executor, $requireResponse, &$currentPromise) { + $redirectCount = 0; + $currentRequest = $request; + + while (true) { + $currentPromise = $this->interceptorHandler->process( + request: $currentRequest, + interceptors: $this->interceptors, + executor: $executor, + requireResponse: $requireResponse + ); + + /** @var TResult $response */ + $response = await($currentPromise); + $currentPromise = null; + + $statusCode = 0; + /** @var string|null $location */ + $location = null; + + if ($response instanceof ResponseInterface) { + $statusCode = $response->getStatusCode(); + $location = $response->getHeaderLine('Location'); + } elseif (\is_array($response) && isset($response['status'], $response['headers'])) { + /** @var int $statusCode */ + $statusCode = $response['status']; + /** @var mixed $headers */ + $headers = $response['headers']; + + if (\is_iterable($headers)) { + foreach ($headers as $name => $values) { + if (\is_string($name) && \strtolower($name) === 'location') { + $locVal = \is_array($values) ? ($values[0] ?? '') : $values; + $location = \is_scalar($locVal) ? (string) $locVal : ''; + + break; + } + } + } + } else { + return $response; + } + + // Determine if the response should follow the redirect + if (! $this->followRedirects || $statusCode < 300 || $statusCode >= 400 || $location === null || $location === '') { + return $response; + } + + UriValidator::assertNoControlCharacters($location); + + if ($redirectCount >= $this->maxRedirects) { + throw new RequestException( + "Will not follow more than {$this->maxRedirects} redirects", + 0, + null, + (string) $currentRequest->getUri() + ); + } + + if ($response instanceof SSEResponseInterface || $response instanceof StreamingResponseInterface) { + $response->close(); + } elseif ($response instanceof ResponseInterface) { + $response->getBody()->close(); + } + + $newUri = RedirectUriResolver::resolve($currentRequest->getUri(), $location); + + UriValidator::assertAllowedScheme($newUri); + $isCrossDomain = UriValidator::isCrossDomain($currentRequest->getUri(), $newUri); + + $currentRequest = clone $currentRequest; + $currentRequest = $currentRequest->withUri($newUri); + + // RFC 7231 Redirect method downgrade handling (e.g. POST to GET) + if ($statusCode === 303 || ($statusCode <= 302 && \in_array(\strtoupper($currentRequest->getMethod()), ['POST', 'PUT', 'DELETE'], true))) { + $currentRequest = $currentRequest->withMethod('GET')->body(''); + $currentRequest = $currentRequest->withoutHeader('Content-Type')->withoutHeader('Content-Length'); + } + + // Security: Strip credentials on cross-origin redirects + if ($isCrossDomain) { + $currentRequest = $currentRequest->withoutHeader('Authorization')->withoutHeader('Cookie'); + } + + $redirectCount++; + } + }); + + $outerPromise->onCancel(function () use (&$currentPromise): void { + if ($currentPromise instanceof PromiseInterface && ! $currentPromise->isSettled()) { + $currentPromise->cancelChain(); + } + }); + + return $outerPromise; + } +} diff --git a/src/HttpClient.php b/src/HttpClient.php index ed3505b..15be7d1 100644 --- a/src/HttpClient.php +++ b/src/HttpClient.php @@ -8,6 +8,7 @@ use Hibla\HttpClient\Builders\CurlOptionsBuilder; use Hibla\HttpClient\Handlers\HttpHandler; use Hibla\HttpClient\Handlers\InterceptorHandler; +use Hibla\HttpClient\Handlers\RedirectHandler; use Hibla\HttpClient\Interfaces\Cookie\CookieJarInterface; use Hibla\HttpClient\Interfaces\Handler\HttpHandlerInterface; use Hibla\HttpClient\Interfaces\Handler\TransportOptionsBuilderInterface; @@ -15,9 +16,11 @@ use Hibla\HttpClient\Interfaces\RequestInterface; use Hibla\HttpClient\Interfaces\ResponseInterface; use Hibla\HttpClient\Interfaces\SSE\SSEBuilderInterface; +use Hibla\HttpClient\Interfaces\StreamingResponseInterface; use Hibla\HttpClient\SSE\SSEBuilder; use Hibla\HttpClient\SSE\SSEConnector; use Hibla\HttpClient\Traits\StreamTrait; +use Hibla\HttpClient\Validators\UriValidator; use Hibla\HttpClient\ValueObjects\ClientOptions; use Hibla\HttpClient\ValueObjects\ProxyConfig; use Hibla\HttpClient\ValueObjects\RetryConfig; @@ -919,17 +922,11 @@ public function head(string $url): PromiseInterface */ public function send(string $method, string $url): PromiseInterface { - $expandedUrl = $this->expandUriTemplate($url); - $initialRequest = $this->request - ->withMethod($method) - ->withUri(new Uri($expandedUrl)) - ; + $uri = $this->createValidatedUri($url); + $initialRequest = $this->request->withMethod($method)->withUri($uri); - return $this->interceptorHandler->process( - request: $initialRequest, - interceptors: $this->interceptors, - executor: $this->executeRequest(...), - ); + /** @var PromiseInterface */ + return $this->dispatchWithRedirects($initialRequest, fn (RequestInterface $req): PromiseInterface => $this->executeRequest($req), true); } /** @@ -937,24 +934,17 @@ public function send(string $method, string $url): PromiseInterface */ public function stream(string $url, ?callable $onChunk = null): PromiseInterface { - $expandedUrl = $this->expandUriTemplate($url); - $initialRequest = $this->request - ->withMethod($this->getMethod()) - ->withUri(new Uri($expandedUrl)) - ; - - return $this->interceptorHandler->process( - request: $initialRequest, - interceptors: $this->interceptors, - executor: function (RequestInterface $processed) use ($onChunk) { - $effectiveTimeout = $this->timeoutExplicitlySet ? $this->timeout : 0; + $uri = $this->createValidatedUri($url); + $initialRequest = $this->request->withMethod($this->getMethod())->withUri($uri); - $clientOptions = $this->buildClientOptionsFromProcessed($processed, timeout: $effectiveTimeout); - $options = $this->resolveTransportOptionsBuilder()->buildForStreaming($clientOptions); + /** @var PromiseInterface */ + return $this->dispatchWithRedirects($initialRequest, function (RequestInterface $processed) use ($onChunk): PromiseInterface { + $effectiveTimeout = $this->timeoutExplicitlySet ? $this->timeout : 0; + $clientOptions = $this->buildClientOptionsFromProcessed($processed, timeout: $effectiveTimeout); + $options = $this->resolveTransportOptionsBuilder()->buildForStreaming($clientOptions); - return $this->resolveHandler()->stream((string) $processed->getUri(), $options, $onChunk); - } - ); + return $this->resolveHandler()->stream((string) $processed->getUri(), $options, $onChunk); + }, true); } /** @@ -962,30 +952,18 @@ public function stream(string $url, ?callable $onChunk = null): PromiseInterface */ public function upload(string $url, string $source, ?callable $onProgress = null): PromiseInterface { - $expandedUrl = $this->expandUriTemplate($url); $method = $this->methodExplicitlySet ? $this->getMethod() : 'PUT'; - $initialRequest = $this->request - ->withMethod($method) - ->withUri(new Uri($expandedUrl)) - ; + $uri = $this->createValidatedUri($url); + $initialRequest = $this->request->withMethod($method)->withUri($uri); - return $this->interceptorHandler->process( - request: $initialRequest, - interceptors: $this->interceptors, - executor: function (RequestInterface $processed) use ($source, $onProgress) { - $effectiveTimeout = $this->timeoutExplicitlySet ? $this->timeout : 0; - $clientOptions = $this->buildClientOptionsFromProcessed($processed, timeout: $effectiveTimeout); - $options = $this->resolveTransportOptionsBuilder()->buildForUpload($clientOptions, $source); - - return $this->resolveHandler()->upload( - (string) $processed->getUri(), - $source, - $options, - $onProgress, - ); - }, - requireResponse: false - ); + /** @var PromiseInterface, protocol_version: string|null}> */ + return $this->dispatchWithRedirects($initialRequest, function (RequestInterface $processed) use ($source, $onProgress): PromiseInterface { + $effectiveTimeout = $this->timeoutExplicitlySet ? $this->timeout : 0; + $clientOptions = $this->buildClientOptionsFromProcessed($processed, timeout: $effectiveTimeout); + $options = $this->resolveTransportOptionsBuilder()->buildForUpload($clientOptions, $source); + + return $this->resolveHandler()->upload((string) $processed->getUri(), $source, $options, $onProgress); + }, false); } /** @@ -993,32 +971,17 @@ public function upload(string $url, string $source, ?callable $onProgress = null */ public function download(string $url, string $destination, ?callable $onProgress = null): PromiseInterface { - $expandedUrl = $this->expandUriTemplate($url); - $initialRequest = $this->request - ->withMethod($this->getMethod()) - ->withUri(new Uri($expandedUrl)) - ; + $uri = $this->createValidatedUri($url); + $initialRequest = $this->request->withMethod($this->getMethod())->withUri($uri); - /** @var PromiseInterface, protocol_version: string|null, size: int|false}> $promise */ - $promise = $this->interceptorHandler->process( - request: $initialRequest, - interceptors: $this->interceptors, - executor: function (RequestInterface $processed) use ($destination, $onProgress) { - $effectiveTimeout = $this->timeoutExplicitlySet ? $this->timeout : 0; - $clientOptions = $this->buildClientOptionsFromProcessed($processed, timeout: $effectiveTimeout); - $options = $this->resolveTransportOptionsBuilder()->buildForDownload($clientOptions, $destination); - - return $this->resolveHandler()->download( - (string) $processed->getUri(), - $destination, - $options, - $onProgress, - ); - }, - requireResponse: false - ); + /** @var PromiseInterface, protocol_version: string|null, size: int|false}> */ + return $this->dispatchWithRedirects($initialRequest, function (RequestInterface $processed) use ($destination, $onProgress): PromiseInterface { + $effectiveTimeout = $this->timeoutExplicitlySet ? $this->timeout : 0; + $clientOptions = $this->buildClientOptionsFromProcessed($processed, timeout: $effectiveTimeout); + $options = $this->resolveTransportOptionsBuilder()->buildForDownload($clientOptions, $destination); - return $promise; + return $this->resolveHandler()->download((string) $processed->getUri(), $destination, $options, $onProgress); + }, false); } /** @@ -1026,16 +989,12 @@ public function download(string $url, string $destination, ?callable $onProgress */ public function sse(string $url): SSEBuilderInterface { - $expandedUrl = $this->expandUriTemplate($url); - $method = $this->methodExplicitlySet ? $this->getMethod() : ($this->request->getBody()->getSize() > 0 ? 'POST' : 'GET'); - $initialRequest = $this->request - ->withMethod($method) - ->withUri(new Uri($expandedUrl)) - ; + $uri = $this->createValidatedUri($url); + $initialRequest = $this->request->withMethod($method)->withUri($uri); $effectiveTimeout = $this->timeoutExplicitlySet ? $this->timeout : 0; @@ -1046,14 +1005,13 @@ public function sse(string $url): SSEBuilderInterface }; $connector = new SSEConnector( - interceptorHandler: $this->interceptorHandler, - httpHandler: $this->resolveHandler(), - interceptors: $this->interceptors, - request: $initialRequest, - optionsBuilder: $optionsBuilder + $this->resolveHandler(), + $initialRequest, + $optionsBuilder, + $this->dispatchWithRedirects(...) ); - return new SSEBuilder($expandedUrl, $connector); + return new SSEBuilder((string)$uri, $connector); } /** @@ -1183,7 +1141,7 @@ function (array $matches): string { $param = $this->urlParameters[$key]; - if (! is_scalar($param) && ! ($param instanceof \Stringable)) { + if (! \is_scalar($param) && ! ($param instanceof \Stringable)) { return $matches[0]; } @@ -1205,7 +1163,7 @@ function (array $matches): string { private static function resolveRequest(mixed $value, bool $fromPromise): RequestInterface { if ($value === null) { - throw new \LogicException(sprintf( + throw new \LogicException(\sprintf( '%s passed to interceptRequest() must %s a %s instance, got null/void.', $fromPromise ? 'The ' . PromiseInterface::class : 'Callback', $fromPromise ? 'resolve to' : 'return', @@ -1273,4 +1231,45 @@ private function ensureCurlExtensionLoaded(): void ); } } + + /** + * Expands a URI template, validates it against control characters, and returns a safe Uri object. + */ + private function createValidatedUri(string $url): UriInterface + { + $expandedUrl = $this->expandUriTemplate($url); + + UriValidator::assertNoControlCharacters($expandedUrl); + + $uri = new Uri($expandedUrl); + UriValidator::assertAllowedScheme($uri); + + return $uri; + } + + /** + * Delegates the request execution to the RedirectHandler, allowing it to recursively + * manage 3xx responses and re-feed them through the interceptor pipeline. + * + * @template TResult + * + * @param RequestInterface $request + * @param callable(RequestInterface): PromiseInterface $executor + * @param bool $requireResponse + * @return PromiseInterface + */ + private function dispatchWithRedirects( + RequestInterface $request, + callable $executor, + bool $requireResponse + ): PromiseInterface { + $redirectHandler = new RedirectHandler( + $this->interceptorHandler, + $this->interceptors, + $this->followRedirects, + $this->maxRedirects + ); + + return $redirectHandler->dispatch($request, $executor, $requireResponse); + } } diff --git a/src/Message.php b/src/Message.php index c032d7f..239f364 100644 --- a/src/Message.php +++ b/src/Message.php @@ -30,7 +30,7 @@ abstract class Message implements MessageInterface /** * The HTTP protocol version. */ - protected string $protocol = '1.1'; + protected string $protocol = '2.0'; /** * An associative array of HTTP headers, keyed by original header name casing. diff --git a/src/Request.php b/src/Request.php index 6f7bf89..048e895 100644 --- a/src/Request.php +++ b/src/Request.php @@ -89,16 +89,62 @@ class Request extends Message implements RequestInterface private bool $bodyExplicitlySet = false; /** - * Initialise a blank pending request. + * Initialise a request, optionally seeding all PSR-7 fields up front. * - * Prefer the HttpClient fluent API over constructing Request directly. - * HttpClient seeds the initial User-Agent from GlobalConfig before - * handing the instance to the interceptor pipeline. - */ - public function __construct() - { + * All arguments are optional so that the zero-argument form used by + * HttpClient's fluent API continues to work unchanged. When arguments + * are supplied every value is routed through the same validated setter + * that the builder methods use, so construction can never produce an + * instance that would be rejected mid-chain. + * + * Prefer {@see self::create()} when constructing requests inline — the + * named-constructor form avoids positional-argument awkwardness when only + * a subset of fields need to be seeded. + * + * @param string $method HTTP method token (case-insensitive, stored upper-case). + * @param string|UriInterface $uri Request URI or a raw URL string. + * @param array $headers Header map applied via {@see withHeaders()}. + * @param string|StreamInterface|null $body Raw body string or an existing stream. + * @param string $version HTTP protocol version (e.g. "1.1", "2"). + * + * @throws InvalidArgumentException If the method token, any header name/value, or protocol + * version fails RFC 9110 / 9112 validation. + */ + public function __construct( + string $method = 'GET', + string|UriInterface $uri = '', + array $headers = [], + string|StreamInterface|null $body = null, + string $version = '2.0', + ) { $this->uri = new Uri(''); $this->body = $this->createTempStream(); + + if ($method !== 'GET') { + $this->applyFrom($this->withMethod($method)); + } + + if ($uri !== '') { + $this->applyFrom( + $this->withUri($uri instanceof UriInterface ? $uri : new Uri($uri)), + ); + } + + if ($headers !== []) { + $this->applyFrom($this->withHeaders($headers)); + } + + if ($body !== null) { + $this->applyFrom( + $body instanceof StreamInterface + ? $this->withBody($body) + : $this->body($body), + ); + } + + if ($version !== '2.0') { + $this->applyFrom($this->withProtocolVersion($version)); + } } /** @@ -539,20 +585,20 @@ public function cookieWithAttributes(string $name, string $value, array $attribu } $new->cookieJar->setCookie(new Cookie( - name: $name, - value: $value, - expires: isset($attributes['expires']) && is_numeric($attributes['expires']) - ? (int) $attributes['expires'] : null, - domain: isset($attributes['domain']) && \is_string($attributes['domain']) - ? $attributes['domain'] : null, - path: isset($attributes['path']) && \is_string($attributes['path']) - ? $attributes['path'] : null, - secure: isset($attributes['secure']) && (bool) $attributes['secure'], + name: $name, + value: $value, + expires: isset($attributes['expires']) && is_numeric($attributes['expires']) + ? (int) $attributes['expires'] : null, + domain: isset($attributes['domain']) && \is_string($attributes['domain']) + ? $attributes['domain'] : null, + path: isset($attributes['path']) && \is_string($attributes['path']) + ? $attributes['path'] : null, + secure: isset($attributes['secure']) && (bool) $attributes['secure'], httpOnly: isset($attributes['httpOnly']) && (bool) $attributes['httpOnly'], - maxAge: isset($attributes['maxAge']) && \is_numeric($attributes['maxAge']) - ? (int) $attributes['maxAge'] : null, + maxAge: isset($attributes['maxAge']) && \is_numeric($attributes['maxAge']) + ? (int) $attributes['maxAge'] : null, sameSite: isset($attributes['sameSite']) && \is_string($attributes['sameSite']) - ? $attributes['sameSite'] : null, + ? $attributes['sameSite'] : null, )); return $new; @@ -669,4 +715,33 @@ private function normalizeToken(string $token, string $type): string return $token; } + + /** + * Absorb all mutable state from a clone produced by a builder method. + * + * Builder methods (withMethod, withUri, …) return clones rather than + * mutating $this, which is correct for immutable value objects at runtime + * but inconvenient during construction — PHP does not allow reassigning + * $this. This method bridges that gap by copying every property from the + * post-builder clone back into the instance under construction. + * + * Safe to call only from {@see __construct()} before the instance has + * escaped to user code. Calling it on a live object would silently break + * immutability guarantees. + */ + private function applyFrom(self $source): void + { + $this->protocol = $source->protocol; + $this->headers = $source->headers; + $this->headerNames = $source->headerNames; + $this->body = $source->body; + $this->method = $source->method; + $this->uri = $source->uri; + $this->requestTarget = $source->requestTarget; + $this->auth = $source->auth; + $this->options = $source->options; + $this->userAgent = $source->userAgent; + $this->bodyExplicitlySet = $source->bodyExplicitlySet; + $this->cookieJar = $source->cookieJar; + } } diff --git a/src/SSE/SSEConnector.php b/src/SSE/SSEConnector.php index 0eac7e3..6dc63e4 100644 --- a/src/SSE/SSEConnector.php +++ b/src/SSE/SSEConnector.php @@ -4,7 +4,6 @@ namespace Hibla\HttpClient\SSE; -use Hibla\HttpClient\Handlers\InterceptorHandler; use Hibla\HttpClient\Interfaces\Handler\HttpHandlerInterface; use Hibla\HttpClient\Interfaces\RequestInterface; use Hibla\HttpClient\Interfaces\SSEResponseInterface; @@ -19,18 +18,16 @@ final class SSEConnector { /** - * @param InterceptorHandler $interceptorHandler The interceptor handler to use for the request pipeline * @param HttpHandlerInterface $httpHandler The HTTP handler to use for the request - * @param array $interceptors The interceptors to use for the request pipeline * @param Request $request The initial request to use for the connection attempt * @param \Closure(RequestInterface): array $optionsBuilder + * @param \Closure $dispatcher */ public function __construct( - private readonly InterceptorHandler $interceptorHandler, private readonly HttpHandlerInterface $httpHandler, - private readonly array $interceptors, private readonly Request $request, private readonly \Closure $optionsBuilder, + private readonly \Closure $dispatcher ) { } @@ -47,22 +44,20 @@ public function __invoke( ?callable $onError, ?SSEReconnectConfig $reconnectConfig ): PromiseInterface { - /** @var PromiseInterface $pipelinePromise */ - $pipelinePromise = $this->interceptorHandler->process( - $this->request, - $this->interceptors, - function (RequestInterface $processed) use ($onEvent, $onError, $reconnectConfig): PromiseInterface { - $finalOptions = ($this->optionsBuilder)($processed); + $executor = function (RequestInterface $processed) use ($onEvent, $onError, $reconnectConfig): PromiseInterface { + $finalOptions = ($this->optionsBuilder)($processed); + + return $this->httpHandler->sse( + (string) $processed->getUri(), + $finalOptions, + $onEvent, + $onError, + $reconnectConfig + ); + }; - return $this->httpHandler->sse( - (string) $processed->getUri(), - $finalOptions, - $onEvent, - $onError, - $reconnectConfig - ); - } - ); + /** @var PromiseInterface $pipelinePromise */ + $pipelinePromise = ($this->dispatcher)($this->request, $executor, true); return new CancelableSSEPromise($pipelinePromise); } diff --git a/src/Utils/RedirectUriResolver.php b/src/Utils/RedirectUriResolver.php new file mode 100644 index 0000000..8c4ed62 --- /dev/null +++ b/src/Utils/RedirectUriResolver.php @@ -0,0 +1,91 @@ +getScheme() !== '') { + return $locationUri; + } + + if ($locationUri->getHost() !== '') { + return $locationUri->withScheme($base->getScheme()); + } + + $newUri = $base->withQuery($locationUri->getQuery()) + ->withFragment($locationUri->getFragment()) + ; + + if ($locationUri->getPath() === '') { + return $newUri; + } + + if (\str_starts_with($locationUri->getPath(), '/')) { + return $newUri->withPath(self::removeDotSegments($locationUri->getPath())); + } + + $basePath = $base->getPath(); + if ($basePath === '') { + $basePath = '/'; + } + + $lastSlashPos = \strrpos($basePath, '/'); + $dir = $lastSlashPos !== false ? \substr($basePath, 0, $lastSlashPos + 1) : '/'; + $mergedPath = $dir . $locationUri->getPath(); + + return $newUri->withPath(self::removeDotSegments($mergedPath)); + } + + /** + * Removes dot segments from a path per RFC 3986 section 5.2.4. + */ + private static function removeDotSegments(string $path): string + { + if (! \str_contains($path, '.')) { + return $path; + } + + $parts = \explode('/', $path); + $result = []; + + foreach ($parts as $part) { + if ($part === '.' || $part === '') { + continue; + } + if ($part === '..') { + \array_pop($result); + } else { + $result[] = $part; + } + } + + $newPath = '/' . \implode('/', $result); + + if (\str_ends_with($path, '/') || \str_ends_with($path, '/.') || \str_ends_with($path, '/..')) { + if ($newPath !== '/') { + $newPath .= '/'; + } + } + + return $newPath; + } +} diff --git a/src/Validators/UriValidator.php b/src/Validators/UriValidator.php new file mode 100644 index 0000000..da6480f --- /dev/null +++ b/src/Validators/UriValidator.php @@ -0,0 +1,106 @@ +getScheme()); + + // allow empty schemes (relative URIs) or explicitly http/https. + if ($scheme !== '' && $scheme !== 'http' && $scheme !== 'https') { + throw new NetworkException( + "The scheme '{$scheme}' is not supported. Only 'http' and 'https' are allowed.", + 0, + null, + (string) $uri + ); + } + } + + /** + * Asserts that an IP address is not in a private or reserved range. + * + * @throws NetworkException + */ + public static function assertPublicIp(string $ip, string $originalUrl): void + { + // FILTER_FLAG_NO_PRIV_RANGE: Blocks 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 + // FILTER_FLAG_NO_RES_RANGE: Blocks 127.0.0.0/8, 169.254.0.0/16, etc. + $flags = FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE; + + if (filter_var($ip, FILTER_VALIDATE_IP, $flags) === false) { + throw new NetworkException( + "Access to private or reserved network address '{$ip}' is restricted.", + 0, + null, + $originalUrl + ); + } + } + + /** + * Determines if two URIs are cross-domain (RFC 6454). + * Automatically handles edge cases like IPv6 Zone IDs (RFC 6874). + */ + public static function isCrossDomain(UriInterface $original, UriInterface $new): bool + { + $originalHost = \strtolower($original->getHost()); + $newHost = \strtolower($new->getHost()); + + // Strip IPv6 Zone IDs (e.g., [::1%25eth0] -> [::1]) per RFC 6874 + $originalHost = \preg_replace('/%[^\]]+\]/', ']', $originalHost) ?? $originalHost; + $newHost = \preg_replace('/%[^\]]+\]/', ']', $newHost) ?? $newHost; + + // Normalize unicode and punycode to ASCII via UTS#46 so that + // münchen.de and xn--mnchen-3ya.de compare as equal (same origin), + // while Cyrillic/Greek lookalikes remain distinct (cross-origin). + if (\function_exists('idn_to_ascii')) { + $normalizedOriginal = \idn_to_ascii($originalHost, \IDNA_DEFAULT, \INTL_IDNA_VARIANT_UTS46); + $normalizedNew = \idn_to_ascii($newHost, \IDNA_DEFAULT, \INTL_IDNA_VARIANT_UTS46); + + if ($normalizedOriginal !== false) { + $originalHost = $normalizedOriginal; + } + + if ($normalizedNew !== false) { + $newHost = $normalizedNew; + } + } + + return $originalHost !== $newHost + || $original->getPort() !== $new->getPort() + || $original->getScheme() !== $new->getScheme(); + } +} diff --git a/tests/Cookie/CookieHandlingTest.php b/tests/Cookie/CookieHandlingTest.php index 5855390..55a8e80 100644 --- a/tests/Cookie/CookieHandlingTest.php +++ b/tests/Cookie/CookieHandlingTest.php @@ -608,4 +608,82 @@ expect($jar->getAllCookies())->toBeEmpty(); }); }); + + describe('Redirect handling edge cases', function () { + + test('Authorization header is stripped when redirecting to a different host', function () { + $client = (new HttpClient()) + ->withToken('secret-token') + ->redirects(true) + ; + + $response = await($client->get(HttpBin::url('/redirect-to?url=https://google.com'))); + + expect($response->getStatusCode())->toBeGreaterThanOrEqual(200); + }); + + test('POST request switches to GET on 303 See Other', function () { + $response = await( + (new HttpClient()) + ->redirects(true) + ->withJson(['foo' => 'bar']) + ->post(HttpBin::url('/status/303')) + ); + + expect($response->json('url'))->toContain('/get'); + expect($response->json('headers.Content-Type'))->toBeNull(); + }); + + test('POST request switches to GET on 301/302 redirects per common practice', function () { + $response = await( + (new HttpClient()) + ->redirects(true) + ->withForm(['user' => 'test']) + ->post(HttpBin::url('/status/302')) + ); + + expect($response->json('url'))->not->toContain('/post'); + }); + + test('relative redirect paths are resolved correctly against the base URL', function () { + $response = await( + (new HttpClient()) + ->redirects(true) + ->get(HttpBin::url('/relative-redirect/1')) + ); + + expect($response->successful())->toBeTrue(); + expect($response->json('url'))->toContain('/get'); + }); + + test('maxRedirects limit prevents infinite loops', function () { + $client = (new HttpClient()) + ->redirects(true, 2) + ; + expect(fn () => await($client->get(HttpBin::url('/redirect/5')))) + ->toThrow( + Hibla\HttpClient\Exceptions\RequestException::class, + 'Will not follow more than 2 redirects' + ) + ; + }); + + test('cookies accumulated across multiple redirect hops are all preserved', function () { + $jar = new CookieJar(); + + $target = HttpBin::url('/cookies/set?a=1&b=2'); + $url = HttpBin::url('/redirect-to?url=' . urlencode($target)); + + $response = await( + (new HttpClient()) + ->useCookieJar($jar) + ->redirects(true) + ->get($url) + ); + + $cookies = $response->json('cookies'); + expect($cookies['a'])->toBe('1'); + expect($cookies['b'])->toBe('2'); + }); + }); }); diff --git a/tests/Feature/SecurityVulnerabilityTest.php b/tests/Feature/SecurityVulnerabilityTest.php new file mode 100644 index 0000000..7d71f88 --- /dev/null +++ b/tests/Feature/SecurityVulnerabilityTest.php @@ -0,0 +1,819 @@ +withToken('super-secret-token') + ->redirects(true) + ->get(HttpBin::url('/redirect-to?url=' . urlencode($crossDomainUrl))) + ); + + expect($response->successful())->toBeTrue(); + expect($response->json('headers.Authorization'))->toBeNull(); + }); + + it('strips Cookie header when redirecting to a different host', function () { + $baseUrl = HttpBin::baseUrl(); + $crossDomainUrl = str_replace('127.0.0.1', 'localhost', $baseUrl) . '/get'; + + $response = await( + Http::client() + ->withCookie('session_id', 'sensitive-session-data') + ->redirects(true) + ->get(HttpBin::url('/redirect-to?url=' . urlencode($crossDomainUrl))) + ); + + expect($response->successful())->toBeTrue(); + expect($response->json('headers.Cookie'))->toBeNull(); + }); + + it('strips credentials when redirecting to a different port on the same host', function () { + $nextHopRequest = null; + + $client = Http::client() + ->withToken('port-secret') + ->redirects(true) + ->interceptRequest(function (RequestInterface $req) use (&$nextHopRequest) { + $nextHopRequest = $req; + + return $req; + }) + ; + + $targetUrl = 'http://127.0.0.1:9999/get'; + + try { + await($client->get(HttpBin::url('/redirect-to?url=' . urlencode($targetUrl)))); + } catch (NetworkException $e) { + // Expected failure + } + + expect($nextHopRequest)->not->toBeNull(); + expect((string)$nextHopRequest->getUri())->toBe($targetUrl); + expect($nextHopRequest->hasHeader('Authorization'))->toBeFalse(); + }); + + it('preserves Authorization and Cookie headers ONLY on exact same-domain redirect', function () { + $sameDomainUrl = HttpBin::url('/get'); + + $response = await( + Http::client() + ->withToken('super-secret-token') + ->withCookie('session_id', 'sensitive-session-data') + ->redirects(true) + ->get(HttpBin::url('/redirect-to?url=' . urlencode($sameDomainUrl))) + ); + + expect($response->successful())->toBeTrue(); + expect($response->json('headers.Authorization.0'))->toBe('Bearer super-secret-token'); + expect($response->json('headers.Cookie.0'))->toContain('session_id=sensitive-session-data'); + }); + }); + + describe('POST Payload Leakage Prevention (RFC 7231)', function () { + + it('strips sensitive POST payloads and switches to GET on 302 redirect', function () { + $targetUrl = HttpBin::url('/get'); + + $response = await( + Http::client() + ->withJson(['credit_card' => 'tok_12345', 'cvv' => '123']) + ->redirects(true) + ->post(HttpBin::url('/redirect-to?url=' . urlencode($targetUrl))) + ); + + expect($response->json('url'))->toContain('/get'); + expect($response->json('headers.Content-Type'))->toBeNull(); + expect($response->json('headers.Content-Length'))->toBeNull(); + expect($response->json('json'))->toBeNull(); + }); + + it('strips POST form parameters and switches to GET on 303 See Other', function () { + $response = await( + Http::client() + ->withForm(['password' => 'super_secret']) + ->redirects(true) + ->post(HttpBin::url('/status/303')) + ); + + expect($response->json('url'))->toContain('/get'); + expect($response->json('form'))->toBeEmpty(); + expect($response->json('headers.Content-Type'))->toBeNull(); + }); + }); + + describe('CRLF Header Injection Prevention', function () { + + describe('CRLF Header Injection Prevention', function () { + + it('prevents CRLF injection via malicious URLs', function () { + $maliciousUrl = HttpBin::url("/get\r\nEvil-Header: injected"); + + expect(fn () => await(Http::client()->get($maliciousUrl))) + ->toThrow(InvalidArgumentException::class) + ; + }); + }); + }); + + describe('Protocol and Schema Security', function () { + + it('strips credentials when downgrading from HTTPS to HTTP', function () { + $nextHopRequest = null; + + $client = Http::client() + ->withToken('secure-token') + ->redirects(true) + ->intercept(function (RequestInterface $req, callable $next) use (&$nextHopRequest) { + $nextHopRequest = $req; + + if ($req->getUri()->getScheme() === 'https') { + return new Response('', 302, ['Location' => 'http://api.example.com/insecure']); + } + + return new Response('OK', 200); + }) + ; + + $response = await($client->get('https://api.example.com/secure')); + + expect($response->status())->toBe(200); + expect($nextHopRequest)->not->toBeNull(); + expect((string)$nextHopRequest->getUri())->toBe('http://api.example.com/insecure'); + expect($nextHopRequest->hasHeader('Authorization'))->toBeFalse(); + }); + }); + + describe('Header Smuggling & Injection Defense', function () { + + it('blocks CRLF injection in header names at the builder level', function () { + expect(fn () => Http::withHeader("X-Inject\r\nEvil: true", 'value')) + ->toThrow(InvalidArgumentException::class, 'RFC 9110') + ; + }); + + it('blocks CRLF injection in header values at the builder level', function () { + expect(fn () => Http::withHeader('X-Header', "safe-value\r\nInjected: true")) + ->toThrow(InvalidArgumentException::class, 'forbidden') + ; + }); + + it('blocks NUL byte smuggling in headers', function () { + expect(fn () => Http::withHeader('X-Header', "value\0smuggled")) + ->toThrow(InvalidArgumentException::class) + ; + }); + }); + + describe('URI Path Sanitization (Path Traversal)', function () { + + it('normalizes malicious relative paths in Location headers', function () { + $base = new Uri('http://example.com/static/img/'); + $maliciousLocation = '../../etc/passwd'; + + $resolved = RedirectUriResolver::resolve($base, $maliciousLocation); + + expect($resolved->getPath())->toBe('/etc/passwd'); + }); + + it('prevents path traversal from breaking out of the root', function () { + $base = new Uri('http://example.com/'); + $maliciousLocation = '/../../etc/passwd'; + + $resolved = RedirectUriResolver::resolve($base, $maliciousLocation); + + expect($resolved->getPath())->toBe('/etc/passwd'); + }); + }); + + describe('Circular and Infinite Redirect Defense', function () { + + it('detects a circular redirect loop and kills the request', function () { + $client = Http::client()->redirects(true, 5); + + $promise = $client->get(HttpBin::url('/relative-redirect/10')); + + expect(fn () => await($promise))->toThrow( + RequestException::class, + 'Will not follow more than 5 redirects' + ); + }); + }); + + describe('Manually-Added Credential Header Stripping on Redirect (CVE-2022-31042 / CVE-2022-31043)', function () { + + it('strips a raw Cookie header added via withHeader on cross-host redirect', function () { + $nextHopRequest = null; + + $client = Http::client() + ->withHeader('Cookie', 'raw_session=abc123; raw_token=secret') + ->redirects(true) + ->intercept(function (RequestInterface $req, callable $next) use (&$nextHopRequest) { + if ($req->getUri()->getHost() === 'origin.example.com') { + return new Response('', 302, ['Location' => 'http://other.example.com/landing']); + } + + $nextHopRequest = $req; + + return new Response('OK', 200); + }) + ; + + $response = await($client->get('http://origin.example.com/start')); + + expect($response->status())->toBe(200); + expect($nextHopRequest)->not->toBeNull(); + expect($nextHopRequest->hasHeader('Cookie'))->toBeFalse(); + }); + + it('strips a raw Authorization header added via withHeader on HTTPS-to-HTTP downgrade', function () { + $nextHopRequest = null; + + $client = Http::client() + ->withHeader('Authorization', 'Bearer raw-injected-token') + ->redirects(true) + ->intercept(function (RequestInterface $req, callable $next) use (&$nextHopRequest) { + if ($req->getUri()->getScheme() === 'https') { + return new Response('', 302, ['Location' => 'http://api.example.com/insecure']); + } + + $nextHopRequest = $req; + + return new Response('OK', 200); + }) + ; + + $response = await($client->get('https://api.example.com/secure')); + + expect($response->status())->toBe(200); + expect($nextHopRequest)->not->toBeNull(); + expect($nextHopRequest->hasHeader('Authorization'))->toBeFalse(); + }); + }); + + describe('Basic/Digest Auth Credential Stripping on Redirect (CVE-2022-31090)', function () { + + it('strips Basic auth credentials on cross-host redirect', function () { + $nextHopRequest = null; + + $client = Http::client() + ->withBasicAuth('admin', 'super-secret-password') + ->redirects(true) + ->intercept(function (RequestInterface $req, callable $next) use (&$nextHopRequest) { + if ($req->getUri()->getHost() === 'origin.example.com') { + return new Response('', 302, ['Location' => 'http://evil.example.com/steal']); + } + + $nextHopRequest = $req; + + return new Response('OK', 200); + }) + ; + + $response = await($client->get('http://origin.example.com/api')); + + expect($response->status())->toBe(200); + expect($nextHopRequest)->not->toBeNull(); + expect($nextHopRequest->hasHeader('Authorization'))->toBeFalse(); + }); + + it('strips Digest auth credentials when port changes on the same host', function () { + $nextHopRequest = null; + + $client = Http::client() + ->withDigestAuth('user', 'digest-secret') + ->redirects(true) + ->intercept(function (RequestInterface $req, callable $next) use (&$nextHopRequest) { + if ($req->getUri()->getPort() === null || $req->getUri()->getPort() === 80) { + return new Response('', 302, ['Location' => 'http://api.example.com:9090/resource']); + } + + $nextHopRequest = $req; + + return new Response('OK', 200); + }) + ; + + $response = await($client->get('http://api.example.com/resource')); + + expect($response->status())->toBe(200); + expect($nextHopRequest)->not->toBeNull(); + expect($nextHopRequest->hasHeader('Authorization'))->toBeFalse(); + }); + }); + + describe('Cross-Domain Cookie Jar Poisoning Prevention (CVE-2022-29248)', function () { + + it('does not store a Set-Cookie for a domain other than the responding host', function () { + $cookieJar = null; + + $client = Http::client() + ->withCookieJar() + ->intercept(function (RequestInterface $req, callable $next) { + return new Response('', 200, [ + 'Set-Cookie' => 'stolen_session=evil; Domain=other-bank.example.com; Path=/', + ]); + }) + ; + + $firstResponse = await($client->get('http://my-bank.example.com/account')); + + $cookieJar = $client->getCookieJar(); + + expect($cookieJar)->not->toBeNull(); + + $poisonedRequest = null; + + await( + Http::client() + ->useCookieJar($cookieJar) + ->interceptRequest(function (RequestInterface $req) use (&$poisonedRequest) { + $poisonedRequest = $req; + + return $req; + }) + ->intercept(function (RequestInterface $req, callable $next) { + return new Response('OK', 200); + }) + ->get('http://other-bank.example.com/transfer') + ); + + expect($poisonedRequest)->not->toBeNull(); + expect($poisonedRequest->getHeaderLine('Cookie'))->not->toContain('stolen_session=evil'); + }); + + it('does not accept a cookie with a superdomain that would affect sibling subdomains', function () { + $cookieJar = null; + + $client = Http::client() + ->withCookieJar() + ->intercept(function (RequestInterface $req, callable $next) { + return new Response('', 200, [ + 'Set-Cookie' => 'session=highjack; Domain=.example.com; Path=/', + ]); + }) + ; + + await($client->get('http://api.example.com/data')); + + $cookieJar = $client->getCookieJar(); + + $sentToSibling = null; + + await( + Http::client() + ->useCookieJar($cookieJar) + ->interceptRequest(function (RequestInterface $req) use (&$sentToSibling) { + $sentToSibling = $req; + + return $req; + }) + ->intercept(fn ($req, $next) => new Response('OK', 200)) + ->get('http://auth.example.com/login') + ); + + expect($sentToSibling)->not->toBeNull(); + expect($sentToSibling->getHeaderLine('Cookie'))->not->toContain('session=highjack'); + }); + }); + + describe('307 / 308 Redirect — Method and Body Preservation (RFC 7231 §6.4.7)', function () { + + it('preserves POST method and JSON body on a 307 Temporary Redirect', function () { + $targetUrl = HttpBin::url('/post'); + + $response = await( + Http::client() + ->withJson(['order_id' => 'ORD-9876', 'amount' => 49.99]) + ->redirects(true) + ->post(HttpBin::url('/redirect-to?url=' . urlencode($targetUrl) . '&status_code=307')) + ); + + expect($response->json('method'))->toBe('POST'); + expect($response->json('json.order_id'))->toBe('ORD-9876'); + expect($response->json('json.amount'))->toBe(49.99); + }); + + it('preserves PUT method and body on a 308 Permanent Redirect', function () { + $targetUrl = HttpBin::url('/put'); + + $response = await( + Http::client() + ->withJson(['resource' => 'updated-value']) + ->redirects(true) + ->put(HttpBin::url('/redirect-to?url=' . urlencode($targetUrl) . '&status_code=308')) + ); + + expect($response->json('method'))->toBe('PUT'); + expect($response->json('json.resource'))->toBe('updated-value'); + }); + + it('still strips credentials from a 307 redirect targeting a different host', function () { + $nextHopRequest = null; + + $client = Http::client() + ->withToken('should-be-stripped') + ->withJson(['sensitive' => 'payload']) + ->redirects(true) + ->intercept(function (RequestInterface $req, callable $next) use (&$nextHopRequest) { + if ($req->getUri()->getHost() === 'origin.example.com') { + return new Response('', 307, ['Location' => 'http://evil.example.com/steal']); + } + + $nextHopRequest = $req; + + return new Response('OK', 200); + }) + ; + + $response = await($client->post('http://origin.example.com/submit')); + + expect($response->status())->toBe(200); + expect($nextHopRequest)->not->toBeNull(); + expect($nextHopRequest->hasHeader('Authorization'))->toBeFalse(); + }); + }); + + describe('Dangerous URI Scheme Rejection (SSRF Protocol Pivot)', function () { + + it('rejects a file:// URI to prevent local file disclosure', function () { + expect(fn () => await(Http::client()->get('file:///etc/passwd'))) + ->toThrow(NetworkException::class) + ; + }); + + it('rejects a gopher:// URI used in SSRF protocol pivots', function () { + expect(fn () => await(Http::client()->get('gopher://127.0.0.1:6379/_FLUSHALL'))) + ->toThrow(NetworkException::class) + ; + }); + + it('rejects a dict:// URI targeting internal services', function () { + expect(fn () => await(Http::client()->get('dict://127.0.0.1:11211/stat'))) + ->toThrow(NetworkException::class) + ; + }); + + it('rejects a redirect from HTTPS to a file:// URI', function () { + $client = Http::client() + ->redirects(true) + ->intercept(function (RequestInterface $req, callable $next) { + if ($req->getUri()->getScheme() === 'https') { + return new Response('', 302, ['Location' => 'file:///etc/shadow']); + } + + return new Response('shadow_contents', 200); + }) + ; + + expect(fn () => await($client->get('https://api.example.com/resource'))) + ->toThrow(NetworkException::class) + ; + }); + }); + + describe('URL Authority Confusion and Userinfo Injection', function () { + + it('resolves the actual host correctly when userinfo mimics a trusted host', function () { + $base = new Uri('http://example.com/'); + $maliciousLocation = 'http://trusted.example.com@evil.com/steal'; + + $resolved = RedirectUriResolver::resolve($base, $maliciousLocation); + + expect($resolved->getHost())->toBe('evil.com'); + expect($resolved->getUserInfo())->toContain('trusted.example.com'); + }); + + it('strips credentials when a redirect target contains userinfo in the authority', function () { + $nextHopRequest = null; + + $client = Http::client() + ->withToken('victim-token') + ->redirects(true) + ->intercept(function (RequestInterface $req, callable $next) use (&$nextHopRequest) { + if ($req->getUri()->getHost() === 'origin.example.com') { + return new Response('', 302, [ + 'Location' => 'http://trusted.origin.example.com@evil.com/steal', + ]); + } + + $nextHopRequest = $req; + + return new Response('OK', 200); + }) + ; + + $response = await($client->get('http://origin.example.com/api')); + + expect($response->status())->toBe(200); + expect($nextHopRequest)->not->toBeNull(); + expect($nextHopRequest->hasHeader('Authorization'))->toBeFalse(); + }); + }); + + describe('Null Byte and Control Character Injection in URLs', function () { + + it('throws on a URL containing a null byte', function () { + expect(fn () => await(Http::client()->get("http://example.com/path\0/../secret"))) + ->toThrow(InvalidArgumentException::class) + ; + }); + + it('throws on a URL containing a bare line feed', function () { + expect(fn () => await(Http::client()->get("http://example.com/path\nHost: evil.com"))) + ->toThrow(InvalidArgumentException::class) + ; + }); + }); + + describe('Obsolete Header Line-Folding (obs-fold) Rejection', function () { + + it('rejects a header value that uses obsolete line folding', function () { + expect(fn () => Http::withHeader('X-Custom', "legitimate-value\r\n injected-continuation")) + ->toThrow(InvalidArgumentException::class) + ; + }); + + it('rejects a header value that uses a bare LF fold without CR', function () { + expect(fn () => Http::withHeader('X-Custom', "value\n continued")) + ->toThrow(InvalidArgumentException::class) + ; + }); + }); + + describe('Scheme-Relative Redirect Handling', function () { + + it('resolves a scheme-relative Location against the current scheme', function () { + $base = new Uri('https://api.example.com/v1/resource'); + $relativeLocation = '//cdn.example.com/assets/file.js'; + + $resolved = RedirectUriResolver::resolve($base, $relativeLocation); + + expect($resolved->getScheme())->toBe('https'); + expect($resolved->getHost())->toBe('cdn.example.com'); + }); + + it('strips credentials on a scheme-relative redirect to a different host', function () { + $nextHopRequest = null; + + $client = Http::client() + ->withToken('my-secret') + ->redirects(true) + ->intercept(function (RequestInterface $req, callable $next) use (&$nextHopRequest) { + if ($req->getUri()->getHost() === 'api.example.com') { + return new Response('', 302, ['Location' => '//other.example.com/resource']); + } + + $nextHopRequest = $req; + + return new Response('OK', 200); + }) + ; + + $response = await($client->get('https://api.example.com/secure')); + + expect($response->status())->toBe(200); + expect($nextHopRequest)->not->toBeNull(); + expect($nextHopRequest->hasHeader('Authorization'))->toBeFalse(); + }); + }); + + describe('IPv6 Zone ID Stripping in Origin Comparisons', function () { + + it('treats an IPv6 address with a zone ID as the same origin as the bare address', function () { + $nextHopRequest = null; + + $client = Http::client() + ->withToken('ipv6-token') + ->redirects(true) + ->intercept(function (RequestInterface $req, callable $next) use (&$nextHopRequest) { + if (! str_contains((string)$req->getUri(), 'eth0')) { + return new Response('', 302, [ + 'Location' => 'http://[::1%25eth0]/same-host-resource', + ]); + } + + $nextHopRequest = $req; + + return new Response('OK', 200); + }) + ; + + $response = await($client->get('http://[::1]/start')); + + expect($response->status())->toBe(200); + expect($nextHopRequest)->not->toBeNull(); + expect($nextHopRequest->hasHeader('Authorization'))->toBeTrue(); + }); + }); + + describe('Redirect to Loopback / Private Range Credential Stripping (SSRF Mitigation)', function () { + + it('strips credentials when a redirect targets the loopback address', function () { + $nextHopRequest = null; + + $client = Http::client() + ->withToken('production-api-key') + ->redirects(true) + ->intercept(function (RequestInterface $req, callable $next) use (&$nextHopRequest) { + if ($req->getUri()->getHost() === 'public.example.com') { + return new Response('', 302, ['Location' => 'http://127.0.0.1/internal']); + } + + $nextHopRequest = $req; + + return new Response('OK', 200); + }) + ; + + $response = await($client->get('https://public.example.com/api')); + + expect($response->status())->toBe(200); + expect($nextHopRequest)->not->toBeNull(); + expect($nextHopRequest->hasHeader('Authorization'))->toBeFalse(); + }); + + it('strips credentials when a redirect targets an RFC 1918 private address', function () { + $nextHopRequest = null; + + $client = Http::client() + ->withToken('production-api-key') + ->redirects(true) + ->intercept(function (RequestInterface $req, callable $next) use (&$nextHopRequest) { + if ($req->getUri()->getHost() === 'public.example.com') { + return new Response('', 302, ['Location' => 'http://192.168.1.1/router-admin']); + } + + $nextHopRequest = $req; + + return new Response('OK', 200); + }) + ; + + $response = await($client->get('https://public.example.com/api')); + + expect($response->status())->toBe(200); + expect($nextHopRequest)->not->toBeNull(); + expect($nextHopRequest->hasHeader('Authorization'))->toBeFalse(); + }); + }); + + describe('Host Header Injection Prevention', function () { + + it('blocks a CRLF-injected Host override in a header value', function () { + expect(fn () => Http::withHeader('Host', "legitimate.com\r\nHost: evil.com")) + ->toThrow(InvalidArgumentException::class) + ; + }); + + it('blocks a CRLF-injected X-Forwarded-Host header', function () { + expect(fn () => Http::withHeader('X-Forwarded-Host', "legitimate.com\r\nX-Forwarded-Host: evil.com")) + ->toThrow(InvalidArgumentException::class) + ; + }); + }); + + describe('IDN Homograph Redirect Attack Prevention', function () { + + it('strips credentials when redirected to a Cyrillic lookalike of the origin host', function () { + $nextHopRequest = null; + + $client = Http::client() + ->withToken('victim-token') + ->redirects(true) + ->intercept(function (RequestInterface $req, callable $next) use (&$nextHopRequest) { + if ($req->getUri()->getHost() === 'apple.com') { + // аpple.com — Cyrillic 'а', visually identical to Latin 'a' + return new Response('', 302, ['Location' => 'https://аpple.com/steal']); + } + + $nextHopRequest = $req; + + return new Response('OK', 200); + }) + ; + + $response = await($client->get('https://apple.com/api')); + + expect($response->status())->toBe(200); + expect($nextHopRequest)->not->toBeNull(); + expect($nextHopRequest->hasHeader('Authorization'))->toBeFalse(); + }); + + it('preserves credentials when redirected to the punycode equivalent of the same unicode origin', function () { + // münchen.de and xn--mnchen-3ya.de are the same domain after IDN normalisation. + // Credentials must be preserved — this is a legitimate same-origin redirect. + $nextHopRequest = null; + + $client = Http::client() + ->withToken('legitimate-token') + ->redirects(true) + ->intercept(function (RequestInterface $req, callable $next) use (&$nextHopRequest) { + if ($req->getUri()->getHost() === 'münchen.de') { + return new Response('', 302, ['Location' => 'https://xn--mnchen-3ya.de/resource']); + } + + $nextHopRequest = $req; + + return new Response('OK', 200); + }) + ; + + $response = await($client->get('https://münchen.de/api')); + + expect($response->status())->toBe(200); + expect($nextHopRequest)->not->toBeNull(); + expect($nextHopRequest->hasHeader('Authorization'))->toBeTrue(); + }); + }); + + describe('DNS Rebinding — Partial Application-Layer Mitigations', function () { + + it('strips credentials when a rebind-forced scheme downgrade is detected in a redirect', function () { + $nextHopRequest = null; + + $client = Http::client() + ->withToken('production-api-key') + ->redirects(true) + ->intercept(function (RequestInterface $req, callable $next) use (&$nextHopRequest) { + if ($req->getUri()->getScheme() === 'https') { + return new Response('', 302, ['Location' => 'http://evil.com/rebind-target']); + } + + $nextHopRequest = $req; + + return new Response('OK', 200); + }) + ; + + $response = await($client->get('https://evil.com/start')); + + expect($response->status())->toBe(200); + expect($nextHopRequest)->not->toBeNull(); + expect($nextHopRequest->hasHeader('Authorization'))->toBeFalse(); + }); + + it('strips credentials when a rebind redirect pivots to a loopback address', function () { + $nextHopRequest = null; + + $client = Http::client() + ->withToken('production-api-key') + ->redirects(true) + ->intercept(function (RequestInterface $req, callable $next) use (&$nextHopRequest) { + if ($req->getUri()->getHost() === 'evil.com') { + return new Response('', 302, ['Location' => 'http://127.0.0.1/internal-api']); + } + + $nextHopRequest = $req; + + return new Response('OK', 200); + }) + ; + + $response = await($client->get('https://evil.com/start')); + + expect($response->status())->toBe(200); + expect($nextHopRequest)->not->toBeNull(); + expect($nextHopRequest->hasHeader('Authorization'))->toBeFalse(); + }); + + it('documents that same-host DNS rebinding with no scheme change cannot be detected at the client layer', function () { + // This is the true DNS rebinding gap: evil.com resolves to a public IP + // at request time, then rebinds to 127.0.0.1 before the TCP handshake. + // No redirect is involved, the host string never changes so the + // HTTP client has no signal to act on. Full mitigation requires: + // Egress firewall rules blocking RFC 1918 ranges. + // RPZ or split-horizon DNS rejecting private-IP answers. + // A custom DNS resolver that pins the IP and re-validates before connect. + expect(true)->toBeTrue(); + })->todo('Requires network-level egress filtering or a custom DNS resolver integration likely using hibla DNS resolver and Separate Secure Dns Http Handler.'); + }); +}); diff --git a/tests/PSR7/RequestTest.php b/tests/PSR7/RequestTest.php new file mode 100644 index 0000000..2ba94cc --- /dev/null +++ b/tests/PSR7/RequestTest.php @@ -0,0 +1,491 @@ +getMethod())->toBe('GET'); + expect((string) $request->getUri())->toBe('/'); + expect($request->getProtocolVersion())->toBe('2.0'); + expect($request->hasExplicitBody())->toBeFalse(); + }); + + it('constructs with a method and URI string', function () { + $request = new Request('POST', 'https://api.example.com/v1/orders'); + + expect($request->getMethod())->toBe('POST'); + expect((string) $request->getUri())->toBe('https://api.example.com/v1/orders'); + }); + + it('constructs with a UriInterface instance', function () { + $uri = new Uri('https://api.example.com/v1/users'); + $request = new Request('GET', $uri); + + expect($request->getUri())->toBe($uri); + }); + + it('constructs with headers', function () { + $request = new Request('GET', '', ['Accept' => 'application/json', 'X-Foo' => 'bar']); + + expect($request->getHeaderLine('Accept'))->toBe('application/json'); + expect($request->getHeaderLine('X-Foo'))->toBe('bar'); + }); + + it('constructs with a string body', function () { + $request = new Request('POST', '', [], 'raw body content'); + + expect($request->hasExplicitBody())->toBeTrue(); + expect((string) $request->getBody())->toBe('raw body content'); + }); + + it('constructs with a StreamInterface body', function () { + $stream = Stream::fromString('streamed content'); + $request = new Request('POST', '', [], $stream); + + expect($request->getBody())->toBe($stream); + expect($request->hasExplicitBody())->toBeTrue(); + }); + + it('constructs with a custom protocol version', function () { + $request = new Request('GET', '', [], null, '2'); + + expect($request->getProtocolVersion())->toBe('2'); + }); + + it('normalises a lower-case method to upper-case during construction', function () { + $request = new Request('post', 'https://api.example.com'); + + expect($request->getMethod())->toBe('POST'); + }); + + it('syncs the Host header from the URI during construction', function () { + $request = new Request('GET', 'https://api.example.com/path'); + + expect($request->getHeaderLine('Host'))->toBe('api.example.com'); + }); + + it('syncs Host with port when the URI contains a non-standard port', function () { + $request = new Request('GET', 'https://api.example.com:8443/path'); + + expect($request->getHeaderLine('Host'))->toBe('api.example.com:8443'); + }); + + it('throws for an invalid method token during construction', function () { + expect(fn () => new Request('INVALID METHOD'))->toThrow(InvalidArgumentException::class); + }); +}); + +describe('Immutability', function () { + it('withMethod() returns a new instance and does not mutate the original', function () { + $r1 = new Request('GET'); + $r2 = $r1->withMethod('POST'); + + expect($r1)->not->toBe($r2); + expect($r1->getMethod())->toBe('GET'); + expect($r2->getMethod())->toBe('POST'); + }); + + it('withUri() returns a new instance and does not mutate the original', function () { + $r1 = new Request('GET', 'https://example.com'); + $r2 = $r1->withUri(new Uri('https://other.com')); + + expect($r1)->not->toBe($r2); + expect((string) $r1->getUri())->toBe('https://example.com/'); + expect((string) $r2->getUri())->toBe('https://other.com/'); + }); + + it('withHeader() returns a new instance and does not mutate the original', function () { + $r1 = new Request(); + $r2 = $r1->withHeader('X-Foo', 'bar'); + + expect($r1)->not->toBe($r2); + expect($r1->hasHeader('X-Foo'))->toBeFalse(); + expect($r2->getHeaderLine('X-Foo'))->toBe('bar'); + }); + + it('withBody() returns a new instance and does not mutate the original', function () { + $r1 = new Request(); + $r2 = $r1->withBody(Stream::fromString('hello')); + + expect($r1)->not->toBe($r2); + expect((string) $r1->getBody())->toBe(''); + expect((string) $r2->getBody())->toBe('hello'); + }); +}); + +describe('Method', function () { + it('withMethod() stores the method in upper-case', function () { + $request = (new Request())->withMethod('delete'); + + expect($request->getMethod())->toBe('DELETE'); + }); + + it('withMethod() returns the same instance when the method is unchanged', function () { + $request = new Request('GET'); + + expect($request->withMethod('GET'))->toBe($request); + }); + + it('withMethod() throws for an empty string', function () { + expect(fn () => (new Request())->withMethod(''))->toThrow(InvalidArgumentException::class); + }); + + it('withMethod() throws for a method containing spaces', function () { + expect(fn () => (new Request())->withMethod('GET POST'))->toThrow(InvalidArgumentException::class); + }); + + it('withMethod() throws for a method containing CR/LF', function () { + expect(fn () => (new Request())->withMethod("GET\r\n"))->toThrow(InvalidArgumentException::class); + }); +}); + +describe('URI & Request Target', function () { + it('getRequestTarget() derives the target from the URI path', function () { + $request = new Request('GET', 'https://example.com/api/resource'); + + expect($request->getRequestTarget())->toBe('/api/resource'); + }); + + it('getRequestTarget() includes the query string', function () { + $request = new Request('GET', 'https://example.com/search?q=test&page=2'); + + expect($request->getRequestTarget())->toBe('/search?q=test&page=2'); + }); + + it('getRequestTarget() defaults to "/" when the URI path is empty', function () { + $request = new Request('GET', 'https://example.com'); + + expect($request->getRequestTarget())->toBe('/'); + }); + + it('withRequestTarget() overrides the derived target', function () { + $request = (new Request('GET', 'https://example.com/original')) + ->withRequestTarget('/override?foo=bar') + ; + + expect($request->getRequestTarget())->toBe('/override?foo=bar'); + }); + + it('withRequestTarget() returns the same instance when the value is unchanged', function () { + $request = (new Request())->withRequestTarget('/same'); + + expect($request->withRequestTarget('/same'))->toBe($request); + }); + + it('withUri() updates the Host header by default', function () { + $request = (new Request('GET', 'https://old.example.com')) + ->withUri(new Uri('https://new.example.com')) + ; + + expect($request->getHeaderLine('Host'))->toBe('new.example.com'); + }); + + it('withUri() preserves the Host header when $preserveHost is true and Host is set', function () { + $request = (new Request('GET', 'https://old.example.com')) + ->withUri(new Uri('https://new.example.com'), preserveHost: true) + ; + + expect($request->getHeaderLine('Host'))->toBe('old.example.com'); + }); + + it('withUri() returns the same instance when the URI is identical', function () { + $uri = new Uri('https://example.com'); + $request = new Request('GET', $uri); + + expect($request->withUri($uri))->toBe($request); + }); +}); + +describe('Header Helpers', function () { + it('contentType() sets the Content-Type header', function () { + $request = (new Request())->contentType('text/plain'); + + expect($request->getHeaderLine('Content-Type'))->toBe('text/plain'); + }); + + it('accept() sets the Accept header', function () { + $request = (new Request())->accept('application/json'); + + expect($request->getHeaderLine('Accept'))->toBe('application/json'); + }); + + it('asJson() sets Content-Type to application/json', function () { + expect((new Request())->asJson()->getHeaderLine('Content-Type')) + ->toBe('application/json') + ; + }); + + it('asForm() sets Content-Type to application/x-www-form-urlencoded', function () { + expect((new Request())->asForm()->getHeaderLine('Content-Type')) + ->toBe('application/x-www-form-urlencoded') + ; + }); + + it('asXml() sets Content-Type to application/xml', function () { + expect((new Request())->asXml()->getHeaderLine('Content-Type')) + ->toBe('application/xml') + ; + }); + + it('withHeaders() sets multiple headers in one call', function () { + $request = (new Request())->withHeaders([ + 'Accept' => 'application/json', + 'X-Request-ID' => 'abc-123', + ]); + + expect($request->getHeaderLine('Accept'))->toBe('application/json'); + expect($request->getHeaderLine('X-Request-ID'))->toBe('abc-123'); + }); + + it('withAddedHeader() appends a value to an existing header', function () { + $request = (new Request()) + ->withHeader('X-Multi', 'first') + ->withAddedHeader('X-Multi', 'second') + ; + + expect($request->getHeader('X-Multi'))->toBe(['first', 'second']); + }); + + it('withoutHeader() removes an existing header', function () { + $request = (new Request()) + ->withHeader('X-Foo', 'bar') + ->withoutHeader('X-Foo') + ; + + expect($request->hasHeader('X-Foo'))->toBeFalse(); + }); +}); + +describe('Authentication', function () { + it('withToken() sets a Bearer Authorization header', function () { + $request = (new Request())->withToken('my-secret-token'); + + expect($request->getHeaderLine('Authorization'))->toBe('Bearer my-secret-token'); + }); + + it('withToken() supports a custom scheme', function () { + $request = (new Request())->withToken('my-key', 'ApiKey'); + + expect($request->getHeaderLine('Authorization'))->toBe('ApiKey my-key'); + }); + + it('withToken() strips a duplicate scheme prefix from the token', function () { + $request = (new Request())->withToken('Bearer my-secret-token'); + + expect($request->getHeaderLine('Authorization'))->toBe('Bearer my-secret-token'); + }); + + it('withToken() clears any existing auth tuple', function () { + $request = (new Request()) + ->withBasicAuth('user', 'pass') + ->withToken('new-token') + ; + + expect($request->getAuth())->toBeNull(); + expect($request->getHeaderLine('Authorization'))->toBe('Bearer new-token'); + }); + + it('withToken() throws for an invalid scheme token', function () { + expect(fn () => (new Request())->withToken('tok', 'Bad Scheme')) + ->toThrow(InvalidArgumentException::class) + ; + }); + + it('withBasicAuth() stores the auth tuple and removes the Authorization header', function () { + $request = (new Request()) + ->withHeader('Authorization', 'Bearer old') + ->withBasicAuth('alice', 's3cr3t') + ; + + expect($request->getAuth())->toBe(['basic', 'alice', 's3cr3t']); + expect($request->hasHeader('Authorization'))->toBeFalse(); + }); + + it('withDigestAuth() stores the auth tuple and removes the Authorization header', function () { + $request = (new Request()) + ->withHeader('Authorization', 'Bearer old') + ->withDigestAuth('bob', 'p@ss') + ; + + expect($request->getAuth())->toBe(['digest', 'bob', 'p@ss']); + expect($request->hasHeader('Authorization'))->toBeFalse(); + }); +}); + +describe('Body Helpers', function () { + it('body() writes a raw string and marks the body as explicitly set', function () { + $request = (new Request())->body('raw content'); + + expect((string) $request->getBody())->toBe('raw content'); + expect($request->hasExplicitBody())->toBeTrue(); + }); + + it('withJson() encodes an array and sets Content-Type to application/json', function () { + $request = (new Request())->withJson(['key' => 'value']); + + expect($request->getHeaderLine('Content-Type'))->toBe('application/json'); + expect(json_decode((string) $request->getBody(), true))->toBe(['key' => 'value']); + expect($request->hasExplicitBody())->toBeTrue(); + }); + + it('withJson() throws for data that cannot be encoded', function () { + expect(fn () => (new Request())->withJson(["\xB1\x31"])) + ->toThrow(InvalidArgumentException::class) + ; + }); + + it('withXml() accepts a string and sets Content-Type to application/xml', function () { + $xml = '1'; + $request = (new Request())->withXml($xml); + + expect($request->getHeaderLine('Content-Type'))->toBe('application/xml'); + expect((string) $request->getBody())->toBe($xml); + }); + + it('withXml() accepts a SimpleXMLElement', function () { + $xml = new SimpleXMLElement('1'); + $request = (new Request())->withXml($xml); + + expect($request->getHeaderLine('Content-Type'))->toBe('application/xml'); + expect((string) $request->getBody())->toContain('1'); + }); + + it('withForm() URL-encodes an array and sets the correct Content-Type', function () { + $request = (new Request())->withForm(['foo' => 'bar', 'baz' => 'qux']); + + expect($request->getHeaderLine('Content-Type')) + ->toBe('application/x-www-form-urlencoded') + ; + expect((string) $request->getBody())->toBe('foo=bar&baz=qux'); + }); + + it('withMultipart() stores the field map in options and removes Content-Type', function () { + $request = (new Request()) + ->withHeader('Content-Type', 'application/json') + ->withMultipart(['field' => 'value']) + ; + + expect($request->getOptions()['multipart'])->toBe(['field' => 'value']); + expect($request->hasHeader('Content-Type'))->toBeFalse(); + expect($request->hasExplicitBody())->toBeTrue(); + }); + + it('withMultipart() merges into an existing multipart map', function () { + $request = (new Request()) + ->withMultipart(['a' => '1']) + ->withMultipart(['b' => '2']) + ; + + expect($request->getOptions()['multipart'])->toBe(['a' => '1', 'b' => '2']); + }); + + it('withMultipartEntry() adds a single resolved entry to the multipart map', function () { + $entry = ['contents' => 'data', 'filename' => 'file.txt']; + $request = (new Request())->withMultipartEntry('upload', $entry); + + expect($request->getOptions()['multipart']['upload'])->toBe($entry); + expect($request->hasHeader('Content-Type'))->toBeFalse(); + }); +}); + +describe('User-Agent', function () { + it('withUserAgent() stores the value and returns a new instance', function () { + $r1 = new Request(); + $r2 = $r1->withUserAgent('MyClient/1.0'); + + expect($r1)->not->toBe($r2); + expect($r1->getUserAgent())->toBeNull(); + expect($r2->getUserAgent())->toBe('MyClient/1.0'); + }); +}); + +describe('Cookie Helpers', function () { + it('withCookie() appends a cookie to the Cookie header', function () { + $request = (new Request())->withCookie('session', 'abc123'); + + expect($request->getHeaderLine('Cookie'))->toBe('session=abc123'); + }); + + it('withCookie() appends to an existing Cookie header', function () { + $request = (new Request()) + ->withCookie('a', '1') + ->withCookie('b', '2') + ; + + expect($request->getHeaderLine('Cookie'))->toBe('a=1; b=2'); + }); + + it('withCookie() throws for an invalid cookie name', function () { + expect(fn () => (new Request())->withCookie('invalid name', 'value')) + ->toThrow(InvalidArgumentException::class) + ; + }); + + it('withCookie() throws for an invalid cookie value', function () { + expect(fn () => (new Request())->withCookie('name', 'val;ue')) + ->toThrow(InvalidArgumentException::class) + ; + }); + + it('withCookies() sets multiple cookies at once', function () { + $request = (new Request())->withCookies(['x' => '1', 'y' => '2']); + + expect($request->getHeaderLine('Cookie'))->toBe('x=1; y=2'); + }); + + it('withCookieJar() attaches a fresh CookieJar', function () { + $request = (new Request())->withCookieJar(); + + expect($request->getCookieJar())->toBeInstanceOf(CookieJar::class); + }); + + it('useCookieJar() attaches the provided jar', function () { + $jar = Mockery::mock(CookieJarInterface::class); + $request = (new Request())->useCookieJar($jar); + + expect($request->getCookieJar())->toBe($jar); + }); + + it('clearCookies() removes the Cookie header and clears the jar', function () { + $jar = Mockery::mock(CookieJarInterface::class); + $jar->shouldReceive('clear')->once(); + + $request = (new Request()) + ->withCookie('a', '1') + ->useCookieJar($jar) + ->clearCookies() + ; + + expect($request->hasHeader('Cookie'))->toBeFalse(); + }); + + it('cookieWithAttributes() creates a jar when none is set and stores the cookie', function () { + $request = (new Request())->cookieWithAttributes('pref', 'dark', [ + 'path' => '/', + 'secure' => true, + 'httpOnly' => true, + ]); + + $jar = $request->getCookieJar(); + expect($jar)->not->toBeNull(); + }); + + it('cookieWithAttributes() reuses an existing jar', function () { + $r1 = (new Request())->withCookieJar(); + $r2 = $r1->cookieWithAttributes('token', 'xyz', []); + + expect($r2->getCookieJar())->toBe($r1->getCookieJar()); + }); +}); diff --git a/tests/Unit/UriValidatorTest.php b/tests/Unit/UriValidatorTest.php new file mode 100644 index 0000000..6bd848b --- /dev/null +++ b/tests/Unit/UriValidatorTest.php @@ -0,0 +1,350 @@ + UriValidator::assertNoControlCharacters('https://api.example.com/users?page=1')) + ->not->toThrow(InvalidArgumentException::class) + ; + }); + + it('accepts a URL with percent-encoded characters', function () { + expect(fn () => UriValidator::assertNoControlCharacters('https://example.com/path%20with%20spaces')) + ->not->toThrow(InvalidArgumentException::class) + ; + }); + + it('throws on a carriage return in the URL', function () { + expect(fn () => UriValidator::assertNoControlCharacters("https://example.com/path\rEvil: injected")) + ->toThrow(InvalidArgumentException::class) + ; + }); + + it('throws on a line feed in the URL', function () { + expect(fn () => UriValidator::assertNoControlCharacters("https://example.com/path\nHost: evil.com")) + ->toThrow(InvalidArgumentException::class) + ; + }); + + it('throws on a CRLF sequence in the URL (request-splitting)', function () { + expect(fn () => UriValidator::assertNoControlCharacters("https://example.com/get\r\nEvil-Header: injected")) + ->toThrow(InvalidArgumentException::class) + ; + }); + + it('throws on a null byte in the URL', function () { + expect(fn () => UriValidator::assertNoControlCharacters("https://example.com/path\0/../secret")) + ->toThrow(InvalidArgumentException::class) + ; + }); + + it('throws on a tab character in the URL', function () { + expect(fn () => UriValidator::assertNoControlCharacters("https://example.com/path\there")) + ->toThrow(InvalidArgumentException::class) + ; + }); + + it('throws on a low ASCII control character (e.g. 0x01)', function () { + expect(fn () => UriValidator::assertNoControlCharacters("https://example.com/\x01resource")) + ->toThrow(InvalidArgumentException::class) + ; + }); + + it('throws on the DEL character (0x7F)', function () { + expect(fn () => UriValidator::assertNoControlCharacters("https://example.com/path\x7F")) + ->toThrow(InvalidArgumentException::class) + ; + }); + + it('throws when a control character appears only in the query string', function () { + expect(fn () => UriValidator::assertNoControlCharacters("https://example.com/path?q=value\r\nInjected: yes")) + ->toThrow(InvalidArgumentException::class) + ; + }); + + it('throws when a control character appears only in the fragment', function () { + expect(fn () => UriValidator::assertNoControlCharacters("https://example.com/path#section\nnewline")) + ->toThrow(InvalidArgumentException::class) + ; + }); + + it('accepts an empty string without throwing', function () { + expect(fn () => UriValidator::assertNoControlCharacters('')) + ->not->toThrow(InvalidArgumentException::class) + ; + }); + }); + + describe('assertAllowedScheme', function () { + + it('accepts an http URI', function () { + expect(fn () => UriValidator::assertAllowedScheme(new Uri('http://example.com/api'))) + ->not->toThrow(NetworkException::class) + ; + }); + + it('accepts an https URI', function () { + expect(fn () => UriValidator::assertAllowedScheme(new Uri('https://example.com/api'))) + ->not->toThrow(NetworkException::class) + ; + }); + + it('accepts an uppercase HTTP scheme (normalised comparison)', function () { + expect(fn () => UriValidator::assertAllowedScheme(new Uri('HTTP://example.com/api'))) + ->not->toThrow(NetworkException::class) + ; + }); + + it('accepts an uppercase HTTPS scheme (normalised comparison)', function () { + expect(fn () => UriValidator::assertAllowedScheme(new Uri('HTTPS://example.com/api'))) + ->not->toThrow(NetworkException::class) + ; + }); + + it('accepts a URI with an empty scheme (relative URI)', function () { + expect(fn () => UriValidator::assertAllowedScheme(new Uri('//example.com/relative'))) + ->not->toThrow(NetworkException::class) + ; + }); + + it('throws on a file:// URI', function () { + expect(fn () => UriValidator::assertAllowedScheme(new Uri('file:///etc/passwd'))) + ->toThrow(NetworkException::class) + ; + }); + + it('throws on a gopher:// URI', function () { + expect(fn () => UriValidator::assertAllowedScheme(new Uri('gopher://127.0.0.1:6379/_FLUSHALL'))) + ->toThrow(NetworkException::class) + ; + }); + + it('throws on a dict:// URI', function () { + expect(fn () => UriValidator::assertAllowedScheme(new Uri('dict://127.0.0.1:11211/stat'))) + ->toThrow(NetworkException::class) + ; + }); + + it('throws on an ftp:// URI', function () { + expect(fn () => UriValidator::assertAllowedScheme(new Uri('ftp://internal.example.com/data'))) + ->toThrow(NetworkException::class) + ; + }); + + it('throws on an ldap:// URI', function () { + expect(fn () => UriValidator::assertAllowedScheme(new Uri('ldap://127.0.0.1/dc=example,dc=com'))) + ->toThrow(NetworkException::class) + ; + }); + + it('throws on a javascript: URI', function () { + expect(fn () => UriValidator::assertAllowedScheme(new Uri('javascript:alert(1)'))) + ->toThrow(NetworkException::class) + ; + }); + + it('throws on a data: URI', function () { + expect(fn () => UriValidator::assertAllowedScheme(new Uri('data:text/html,'))) + ->toThrow(NetworkException::class) + ; + }); + + it('includes the blocked scheme name in the exception message', function () { + $thrownMessage = null; + + try { + UriValidator::assertAllowedScheme(new Uri('gopher://127.0.0.1/evil')); + } catch (NetworkException $e) { + $thrownMessage = $e->getMessage(); + } + + expect($thrownMessage)->toContain('gopher'); + }); + }); + + describe('isCrossDomain', function () { + + describe('same-origin — must return false', function () { + + it('returns false for identical http URIs', function () { + $a = new Uri('http://example.com/path'); + $b = new Uri('http://example.com/other-path'); + + expect(UriValidator::isCrossDomain($a, $b))->toBeFalse(); + }); + + it('returns false for identical https URIs with explicit default port', function () { + $a = new Uri('https://example.com/start'); + $b = new Uri('https://example.com/end'); + + expect(UriValidator::isCrossDomain($a, $b))->toBeFalse(); + }); + + it('returns false when hosts differ only in case', function () { + $a = new Uri('https://EXAMPLE.COM/path'); + $b = new Uri('https://example.com/path'); + + expect(UriValidator::isCrossDomain($a, $b))->toBeFalse(); + }); + + it('returns false for matching IPv4 loopback addresses', function () { + $a = new Uri('http://127.0.0.1/api'); + $b = new Uri('http://127.0.0.1/internal'); + + expect(UriValidator::isCrossDomain($a, $b))->toBeFalse(); + }); + + it('returns false for matching IPv6 addresses without zone IDs', function () { + $a = new Uri('http://[::1]/path'); + $b = new Uri('http://[::1]/other'); + + expect(UriValidator::isCrossDomain($a, $b))->toBeFalse(); + }); + + it('returns false when both URIs have the same explicit non-standard port', function () { + $a = new Uri('http://example.com:8080/path'); + $b = new Uri('http://example.com:8080/other'); + + expect(UriValidator::isCrossDomain($a, $b))->toBeFalse(); + }); + + it('returns false for an IPv6 address with a zone ID vs the same bare address (RFC 6874)', function () { + $a = new Uri('http://[::1]/start'); + $b = new Uri('http://[::1%25eth0]/resource'); + + expect(UriValidator::isCrossDomain($a, $b))->toBeFalse(); + }); + + it('returns false when both URIs carry the same IPv6 zone ID', function () { + $a = new Uri('http://[::1%25eth0]/path'); + $b = new Uri('http://[::1%25eth0]/other'); + + expect(UriValidator::isCrossDomain($a, $b))->toBeFalse(); + }); + }); + + describe('cross-origin — must return true', function () { + + it('returns true when the host changes', function () { + $a = new Uri('https://origin.example.com/api'); + $b = new Uri('https://evil.example.com/steal'); + + expect(UriValidator::isCrossDomain($a, $b))->toBeTrue(); + }); + + it('returns true when the scheme downgrades from https to http', function () { + $a = new Uri('https://example.com/secure'); + $b = new Uri('http://example.com/insecure'); + + expect(UriValidator::isCrossDomain($a, $b))->toBeTrue(); + }); + + it('returns true when the scheme upgrades from http to https', function () { + $a = new Uri('http://example.com/page'); + $b = new Uri('https://example.com/page'); + + expect(UriValidator::isCrossDomain($a, $b))->toBeTrue(); + }); + + it('returns true when the port changes (CVE-2022-31091)', function () { + $a = new Uri('http://example.com:8080/api'); + $b = new Uri('http://example.com:9090/api'); + + expect(UriValidator::isCrossDomain($a, $b))->toBeTrue(); + }); + + it('returns true when the original has no port and the redirect adds one', function () { + $a = new Uri('http://example.com/api'); + $b = new Uri('http://example.com:8080/api'); + + expect(UriValidator::isCrossDomain($a, $b))->toBeTrue(); + }); + + it('returns true when the original has a port and the redirect removes it', function () { + $a = new Uri('http://example.com:8080/api'); + $b = new Uri('http://example.com/api'); + + expect(UriValidator::isCrossDomain($a, $b))->toBeTrue(); + }); + + it('returns true when only a subdomain is added', function () { + $a = new Uri('https://example.com/api'); + $b = new Uri('https://sub.example.com/api'); + + expect(UriValidator::isCrossDomain($a, $b))->toBeTrue(); + }); + + it('returns true for a redirect to an IPv4 loopback even from a public host', function () { + $a = new Uri('https://public.example.com/api'); + $b = new Uri('http://127.0.0.1/internal'); + + expect(UriValidator::isCrossDomain($a, $b))->toBeTrue(); + }); + + it('returns true for two different IPv6 zone IDs on the same base address', function () { + $a = new Uri('http://[::1%25eth0]/path'); + $b = new Uri('http://[::1%25eth1]/path'); + + expect(UriValidator::isCrossDomain($a, $b))->toBeFalse(); + }); + + it('returns true when host changes and scheme changes simultaneously', function () { + $a = new Uri('https://origin.example.com/secure'); + $b = new Uri('http://evil.com/steal'); + + expect(UriValidator::isCrossDomain($a, $b))->toBeTrue(); + }); + }); + }); + + describe('IDN / Punycode Homograph Attack Prevention', function () { + + describe('same-origin — must return false', function () { + + it('treats a unicode host and its punycode encoding as the same origin', function () { + $a = new Uri('https://münchen.de/account'); + $b = new Uri('https://xn--mnchen-3ya.de/account'); + + expect(UriValidator::isCrossDomain($a, $b))->toBeFalse(); + }); + + it('treats two punycode representations of the same unicode domain as same origin', function () { + $a = new Uri('https://xn--mnchen-3ya.de/path'); + $b = new Uri('https://xn--mnchen-3ya.de/other'); + + expect(UriValidator::isCrossDomain($a, $b))->toBeFalse(); + }); + }); + + describe('cross-origin — must return true', function () { + + it('treats a Cyrillic lookalike as a different origin from the ASCII domain', function () { + $legitimate = new Uri('https://apple.com/login'); + $homograph = new Uri('https://аpple.com/login'); // Cyrillic а + + expect(UriValidator::isCrossDomain($legitimate, $homograph))->toBeTrue(); + }); + + it('treats a Greek lookalike as a different origin from the ASCII domain', function () { + $legitimate = new Uri('https://google.com/'); + $homograph = new Uri('https://ɡoogle.com/'); // U+0261 + + expect(UriValidator::isCrossDomain($legitimate, $homograph))->toBeTrue(); + }); + + it('treats two different IDN domains as cross-origin', function () { + $a = new Uri('https://münchen.de/path'); + $b = new Uri('https://zürich.ch/path'); + + expect(UriValidator::isCrossDomain($a, $b))->toBeTrue(); + }); + }); + }); +});