From e7e0bf96121f332bb08b314436fa93267c27b03e Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Sun, 12 Apr 2026 14:39:45 +0800 Subject: [PATCH 1/6] updated redirect to use more direct redirect handling on php side rather than curl for more secure and safe cookie handling --- src/Builders/CurlOptionsBuilder.php | 4 +- src/Handlers/Curl/SSEHandler.php | 2 +- src/Handlers/RedirectHandler.php | 133 ++++++++++++++++++ src/HttpClient.php | 207 ++++++++++++++-------------- src/SSE/SSEConnector.php | 37 +++-- src/Utils/RedirectUriResolver.php | 90 ++++++++++++ tests/Cookie/CookieHandlingTest.php | 78 +++++++++++ 7 files changed, 425 insertions(+), 126 deletions(-) create mode 100644 src/Handlers/RedirectHandler.php create mode 100644 src/Utils/RedirectUriResolver.php diff --git a/src/Builders/CurlOptionsBuilder.php b/src/Builders/CurlOptionsBuilder.php index 68659a9..da370bc 100644 --- a/src/Builders/CurlOptionsBuilder.php +++ b/src/Builders/CurlOptionsBuilder.php @@ -29,8 +29,8 @@ 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_MAXREDIRS => 0, // 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/RedirectHandler.php b/src/Handlers/RedirectHandler.php new file mode 100644 index 0000000..90a2397 --- /dev/null +++ b/src/Handlers/RedirectHandler.php @@ -0,0 +1,133 @@ + $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 */ + return async(function () use ($request, $executor, $requireResponse) { + $redirectCount = 0; + $currentRequest = $request; + + while (true) { + /** @var TResult $response */ + $response = await($this->interceptorHandler->process( + $currentRequest, + $this->interceptors, + $executor, + $requireResponse + )); + + $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; + } + + if (! $this->followRedirects || $statusCode < 300 || $statusCode >= 400 || $location === null || $location === '') { + return $response; + } + + 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); + + $isCrossDomain = \strtolower($currentRequest->getUri()->getHost()) !== \strtolower($newUri->getHost()) + || $currentRequest->getUri()->getPort() !== $newUri->getPort() + || $currentRequest->getUri()->getScheme() !== $newUri->getScheme(); + + $currentRequest = clone $currentRequest; + $currentRequest = $currentRequest->withUri($newUri); + + 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'); + } + + if ($isCrossDomain) { + $currentRequest = $currentRequest->withoutHeader('Authorization')->withoutHeader('Cookie'); + } + + $redirectCount++; + } + }); + } +} \ No newline at end of file diff --git a/src/HttpClient.php b/src/HttpClient.php index ed3505b..07eb45e 100644 --- a/src/HttpClient.php +++ b/src/HttpClient.php @@ -8,12 +8,14 @@ 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; use Hibla\HttpClient\Interfaces\HttpClientInterface; use Hibla\HttpClient\Interfaces\RequestInterface; use Hibla\HttpClient\Interfaces\ResponseInterface; +use Hibla\HttpClient\Interfaces\StreamingResponseInterface; use Hibla\HttpClient\Interfaces\SSE\SSEBuilderInterface; use Hibla\HttpClient\SSE\SSEBuilder; use Hibla\HttpClient\SSE\SSEConnector; @@ -113,8 +115,7 @@ class HttpClient implements HttpClientInterface public function __construct() { $this->request = (new Request()) - ->withUserAgent(self::defaultUserAgent()) - ; + ->withUserAgent(self::defaultUserAgent()); $this->interceptorHandler = new InterceptorHandler(); } @@ -201,7 +202,7 @@ public function getHeaderLine(string $name): string */ public function withHeader(string $name, $value): static { - return $this->withUpdatedRequest(fn (Request $request) => $request->withHeader($name, $value)); + return $this->withUpdatedRequest(fn(Request $request) => $request->withHeader($name, $value)); } /** @@ -209,7 +210,7 @@ public function withHeader(string $name, $value): static */ public function withAddedHeader(string $name, $value): static { - return $this->withUpdatedRequest(fn (Request $request) => $request->withAddedHeader($name, $value)); + return $this->withUpdatedRequest(fn(Request $request) => $request->withAddedHeader($name, $value)); } /** @@ -217,7 +218,7 @@ public function withAddedHeader(string $name, $value): static */ public function withoutHeader(string $name): static { - return $this->withUpdatedRequest(fn (Request $request) => $request->withoutHeader($name)); + return $this->withUpdatedRequest(fn(Request $request) => $request->withoutHeader($name)); } /** @@ -233,7 +234,7 @@ public function getBody(): StreamInterface */ public function withBody(StreamInterface $body): static { - return $this->withUpdatedRequest(fn (Request $request) => $request->withBody($body)); + return $this->withUpdatedRequest(fn(Request $request) => $request->withBody($body)); } /** @@ -249,7 +250,7 @@ public function getRequestTarget(): string */ public function withRequestTarget(string $requestTarget): static { - return $this->withUpdatedRequest(fn (Request $request) => $request->withRequestTarget($requestTarget)); + return $this->withUpdatedRequest(fn(Request $request) => $request->withRequestTarget($requestTarget)); } /** @@ -265,7 +266,7 @@ public function getMethod(): string */ public function withMethod(string $method): static { - $new = $this->withUpdatedRequest(fn (Request $request) => $request->withMethod($method)); + $new = $this->withUpdatedRequest(fn(Request $request) => $request->withMethod($method)); $new->methodExplicitlySet = true; return $new; @@ -284,7 +285,7 @@ public function getUri(): UriInterface */ public function withUri(UriInterface $uri, bool $preserveHost = false): static { - return $this->withUpdatedRequest(fn (Request $request) => $request->withUri($uri, $preserveHost)); + return $this->withUpdatedRequest(fn(Request $request) => $request->withUri($uri, $preserveHost)); } /** @@ -292,7 +293,7 @@ public function withUri(UriInterface $uri, bool $preserveHost = false): static */ public function contentType(string $type): static { - return $this->withUpdatedRequest(fn (Request $request) => $request->contentType($type)); + return $this->withUpdatedRequest(fn(Request $request) => $request->contentType($type)); } /** @@ -300,7 +301,7 @@ public function contentType(string $type): static */ public function accept(string $type): static { - return $this->withUpdatedRequest(fn (Request $request) => $request->accept($type)); + return $this->withUpdatedRequest(fn(Request $request) => $request->accept($type)); } /** @@ -308,7 +309,7 @@ public function accept(string $type): static */ public function asJson(): static { - return $this->withUpdatedRequest(fn (Request $request) => $request->asJson()); + return $this->withUpdatedRequest(fn(Request $request) => $request->asJson()); } /** @@ -316,7 +317,7 @@ public function asJson(): static */ public function asForm(): static { - return $this->withUpdatedRequest(fn (Request $request) => $request->asForm()); + return $this->withUpdatedRequest(fn(Request $request) => $request->asForm()); } /** @@ -324,7 +325,7 @@ public function asForm(): static */ public function withUserAgent(string $userAgent): static { - return $this->withUpdatedRequest(fn (Request $request) => $request->withUserAgent($userAgent)); + return $this->withUpdatedRequest(fn(Request $request) => $request->withUserAgent($userAgent)); } /** @@ -332,7 +333,7 @@ public function withUserAgent(string $userAgent): static */ public function withHeaders(array $headers): static { - return $this->withUpdatedRequest(fn (Request $request) => $request->withHeaders($headers)); + return $this->withUpdatedRequest(fn(Request $request) => $request->withHeaders($headers)); } /** @@ -340,7 +341,7 @@ public function withHeaders(array $headers): static */ public function withToken(string $token, string $type = 'Bearer'): static { - return $this->withUpdatedRequest(fn (Request $request) => $request->withToken($token, $type)); + return $this->withUpdatedRequest(fn(Request $request) => $request->withToken($token, $type)); } /** @@ -348,7 +349,7 @@ public function withToken(string $token, string $type = 'Bearer'): static */ public function withBasicAuth(string $username, string $password): static { - return $this->withUpdatedRequest(fn (Request $request) => $request->withBasicAuth($username, $password)); + return $this->withUpdatedRequest(fn(Request $request) => $request->withBasicAuth($username, $password)); } /** @@ -356,7 +357,7 @@ public function withBasicAuth(string $username, string $password): static */ public function withDigestAuth(string $username, string $password): static { - return $this->withUpdatedRequest(fn (Request $request) => $request->withDigestAuth($username, $password)); + return $this->withUpdatedRequest(fn(Request $request) => $request->withDigestAuth($username, $password)); } /** @@ -364,7 +365,7 @@ public function withDigestAuth(string $username, string $password): static */ public function body(string $content): static { - return $this->withUpdatedRequest(fn (Request $request) => $request->body($content)); + return $this->withUpdatedRequest(fn(Request $request) => $request->body($content)); } /** @@ -372,7 +373,7 @@ public function body(string $content): static */ public function withJson(array $data): static { - return $this->withUpdatedRequest(fn (Request $request) => $request->withJson($data)); + return $this->withUpdatedRequest(fn(Request $request) => $request->withJson($data)); } /** @@ -380,7 +381,7 @@ public function withJson(array $data): static */ public function asXml(): static { - return $this->withUpdatedRequest(fn (Request $request) => $request->asXml()); + return $this->withUpdatedRequest(fn(Request $request) => $request->asXml()); } /** @@ -388,7 +389,7 @@ public function asXml(): static */ public function withXml(string|\SimpleXMLElement $xml): static { - return $this->withUpdatedRequest(fn (Request $request) => $request->withXml($xml)); + return $this->withUpdatedRequest(fn(Request $request) => $request->withXml($xml)); } /** @@ -396,7 +397,7 @@ public function withXml(string|\SimpleXMLElement $xml): static */ public function withForm(array $data): static { - return $this->withUpdatedRequest(fn (Request $request) => $request->withForm($data)); + return $this->withUpdatedRequest(fn(Request $request) => $request->withForm($data)); } /** @@ -404,7 +405,7 @@ public function withForm(array $data): static */ public function withMultipart(array $data): static { - return $this->withUpdatedRequest(fn (Request $request) => $request->withMultipart($data)); + return $this->withUpdatedRequest(fn(Request $request) => $request->withMultipart($data)); } /** @@ -412,7 +413,7 @@ public function withMultipart(array $data): static */ public function withCookie(string $name, string $value): static { - return $this->withUpdatedRequest(fn (Request $request) => $request->withCookie($name, $value)); + return $this->withUpdatedRequest(fn(Request $request) => $request->withCookie($name, $value)); } /** @@ -420,7 +421,7 @@ public function withCookie(string $name, string $value): static */ public function withCookies(array $cookies): static { - return $this->withUpdatedRequest(fn (Request $request) => $request->withCookies($cookies)); + return $this->withUpdatedRequest(fn(Request $request) => $request->withCookies($cookies)); } /** @@ -428,7 +429,7 @@ public function withCookies(array $cookies): static */ public function withCookieJar(): static { - return $this->withUpdatedRequest(fn (Request $request) => $request->withCookieJar()); + return $this->withUpdatedRequest(fn(Request $request) => $request->withCookieJar()); } /** @@ -436,7 +437,7 @@ public function withCookieJar(): static */ public function useCookieJar(CookieJarInterface $cookieJar): static { - return $this->withUpdatedRequest(fn (Request $request) => $request->useCookieJar($cookieJar)); + return $this->withUpdatedRequest(fn(Request $request) => $request->useCookieJar($cookieJar)); } /** @@ -444,7 +445,7 @@ public function useCookieJar(CookieJarInterface $cookieJar): static */ public function clearCookies(): static { - return $this->withUpdatedRequest(fn (Request $request) => $request->clearCookies()); + return $this->withUpdatedRequest(fn(Request $request) => $request->clearCookies()); } /** @@ -460,7 +461,7 @@ public function getCookieJar(): ?CookieJarInterface */ public function cookieWithAttributes(string $name, string $value, array $attributes = []): static { - return $this->withUpdatedRequest(fn (Request $request) => $request->cookieWithAttributes($name, $value, $attributes)); + return $this->withUpdatedRequest(fn(Request $request) => $request->cookieWithAttributes($name, $value, $attributes)); } /** @@ -701,7 +702,7 @@ public function withFile(string $name, mixed $file, ?string $filename = null, ?s throw new InvalidArgumentException('File must be a file path, UploadedFileInterface, StreamInterface, or resource.'); } - return $this->withUpdatedRequest(fn (Request $request) => $request->withMultipartEntry($name, $entry)); + return $this->withUpdatedRequest(fn(Request $request) => $request->withMultipartEntry($name, $entry)); } /** @@ -775,7 +776,7 @@ static function (RequestInterface $request, callable $next) use ($callback): Pro /** @var PromiseInterface $result */ /** @var PromiseInterface $chained */ $chained = $result->then( - static fn (mixed $resolved): PromiseInterface => $next(self::resolveRequest($resolved, true)) + static fn(mixed $resolved): PromiseInterface => $next(self::resolveRequest($resolved, true)) ); return $chained; @@ -805,7 +806,7 @@ static function (ResponseInterface $response) use ($callback): mixed { if ($result instanceof PromiseInterface) { /** @var PromiseInterface $result */ return $result->then( - static fn (mixed $resolved): ResponseInterface => self::resolveResponse($resolved, true) + static fn(mixed $resolved): ResponseInterface => self::resolveResponse($resolved, true) ); } @@ -922,14 +923,10 @@ public function send(string $method, string $url): PromiseInterface $expandedUrl = $this->expandUriTemplate($url); $initialRequest = $this->request ->withMethod($method) - ->withUri(new Uri($expandedUrl)) - ; + ->withUri(new Uri($expandedUrl)); - 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); } /** @@ -940,21 +937,16 @@ 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; + ->withUri(new Uri($expandedUrl)); - $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); } /** @@ -966,26 +958,21 @@ public function upload(string $url, string $source, ?callable $onProgress = null $method = $this->methodExplicitlySet ? $this->getMethod() : 'PUT'; $initialRequest = $this->request ->withMethod($method) - ->withUri(new Uri($expandedUrl)) - ; - - 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 - ); + ->withUri(new Uri($expandedUrl)); + + /** @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); } /** @@ -996,29 +983,21 @@ public function download(string $url, string $destination, ?callable $onProgress $expandedUrl = $this->expandUriTemplate($url); $initialRequest = $this->request ->withMethod($this->getMethod()) - ->withUri(new Uri($expandedUrl)) - ; - - /** @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 - ); + ->withUri(new Uri($expandedUrl)); + + /** @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); } /** @@ -1034,8 +1013,7 @@ public function sse(string $url): SSEBuilderInterface $initialRequest = $this->request ->withMethod($method) - ->withUri(new Uri($expandedUrl)) - ; + ->withUri(new Uri($expandedUrl)); $effectiveTimeout = $this->timeoutExplicitlySet ? $this->timeout : 0; @@ -1046,11 +1024,10 @@ 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); @@ -1273,4 +1250,30 @@ private function ensureCurlExtensionLoaded(): void ); } } + + /** + * 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/SSE/SSEConnector.php b/src/SSE/SSEConnector.php index 0eac7e3..50ff593 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,23 +44,21 @@ 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<\Hibla\HttpClient\SSE\SSEResponse> $pipelinePromise */ + $pipelinePromise = ($this->dispatcher)($this->request, $executor, true); return new CancelableSSEPromise($pipelinePromise); } -} +} \ No newline at end of file diff --git a/src/Utils/RedirectUriResolver.php b/src/Utils/RedirectUriResolver.php new file mode 100644 index 0000000..b4880c4 --- /dev/null +++ b/src/Utils/RedirectUriResolver.php @@ -0,0 +1,90 @@ +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; + } +} \ No newline at end of file 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'); + }); + }); }); From 611ac097944e771bc42c3ece9ae2a688a226069d Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Sun, 12 Apr 2026 15:51:01 +0800 Subject: [PATCH 2/6] Fix bug on authentication failure on digest auth redirect handling --- src/Builders/CurlOptionsBuilder.php | 1 - src/HttpClient.php | 4 ++-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/Builders/CurlOptionsBuilder.php b/src/Builders/CurlOptionsBuilder.php index da370bc..8723cec 100644 --- a/src/Builders/CurlOptionsBuilder.php +++ b/src/Builders/CurlOptionsBuilder.php @@ -30,7 +30,6 @@ public function build(ClientOptions $options): array CURLOPT_TIMEOUT => $options->timeout, CURLOPT_CONNECTTIMEOUT => $options->connectTimeout, CURLOPT_FOLLOWLOCATION => false, // Handled in User-land PHP - CURLOPT_MAXREDIRS => 0, // 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/HttpClient.php b/src/HttpClient.php index 07eb45e..e94c206 100644 --- a/src/HttpClient.php +++ b/src/HttpClient.php @@ -1160,7 +1160,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]; } @@ -1182,7 +1182,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', From ed727db42c5bff68b360c917bf849e772b5b99bf Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Sun, 12 Apr 2026 20:56:08 +0800 Subject: [PATCH 3/6] fix cancellation not propagating to the Loop curl request --- src/Handlers/InterceptorHandler.php | 2 ++ src/Handlers/RedirectHandler.php | 38 ++++++++++++++++++++--------- 2 files changed, 29 insertions(+), 11 deletions(-) diff --git a/src/Handlers/InterceptorHandler.php b/src/Handlers/InterceptorHandler.php index f6267a6..4d9b4af 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 index 90a2397..1090bf2 100644 --- a/src/Handlers/RedirectHandler.php +++ b/src/Handlers/RedirectHandler.php @@ -16,14 +16,14 @@ use function Hibla\await; /** - * Handles HTTP redirects recursively using a non-blocking fiber loop. + * Handles HTTP redirects using a non-blocking iterative loop. * * @internal */ final readonly class RedirectHandler { /** - * @param array $interceptors + * @param array $interceptors */ public function __construct( private InterceptorHandler $interceptorHandler, @@ -34,7 +34,7 @@ public function __construct( } /** - * Dispatches the request and automatically follows redirects up to the configured limit. + * Dispatches the request and follows redirects up to the configured limit. * * @template TResult * @@ -48,19 +48,27 @@ public function dispatch( callable $executor, bool $requireResponse ): PromiseInterface { - /** @var PromiseInterface */ - return async(function () use ($request, $executor, $requireResponse) { + /** @var PromiseInterface|null $currentPromise */ + $currentPromise = null; + + /** @var PromiseInterface $outerPromise */ + $outerPromise = async(function () use ($request, $executor, $requireResponse, &$currentPromise) { $redirectCount = 0; $currentRequest = $request; while (true) { + // Store the current inner promise so it can be cancelled from the outside + $currentPromise = $this->interceptorHandler->process( + request: $currentRequest, + interceptors: $this->interceptors, + executor: $executor, + requireResponse: $requireResponse + ); + /** @var TResult $response */ - $response = await($this->interceptorHandler->process( - $currentRequest, - $this->interceptors, - $executor, - $requireResponse - )); + $response = await($currentPromise); + + $currentPromise = null; $statusCode = 0; /** @var string|null $location */ @@ -129,5 +137,13 @@ public function dispatch( $redirectCount++; } }); + + $outerPromise->onCancel(function () use (&$currentPromise) { + if ($currentPromise instanceof PromiseInterface && ! $currentPromise->isSettled()) { + $currentPromise->cancelChain(); + } + }); + + return $outerPromise; } } \ No newline at end of file From e2c6d3f75618942851637fa1aa40231282792c0e Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Sun, 12 Apr 2026 22:22:28 +0800 Subject: [PATCH 4/6] Hardened security measures --- src/Handlers/InterceptorHandler.php | 2 +- src/Handlers/RedirectHandler.php | 25 +- src/HttpClient.php | 132 ++-- src/SSE/SSEConnector.php | 4 +- src/Utils/RedirectUriResolver.php | 5 +- src/Validators/UriValidator.php | 106 +++ tests/Feature/SecurityVulnerabilityTest.php | 830 ++++++++++++++++++++ tests/Unit/UriValidatorTest.php | 380 +++++++++ 8 files changed, 1402 insertions(+), 82 deletions(-) create mode 100644 src/Validators/UriValidator.php create mode 100644 tests/Feature/SecurityVulnerabilityTest.php create mode 100644 tests/Unit/UriValidatorTest.php diff --git a/src/Handlers/InterceptorHandler.php b/src/Handlers/InterceptorHandler.php index 4d9b4af..9a4c32b 100644 --- a/src/Handlers/InterceptorHandler.php +++ b/src/Handlers/InterceptorHandler.php @@ -14,7 +14,7 @@ /** * Handles the unified interceptor pipeline. - * + * * @internal */ class InterceptorHandler diff --git a/src/Handlers/RedirectHandler.php b/src/Handlers/RedirectHandler.php index 1090bf2..f42cd8a 100644 --- a/src/Handlers/RedirectHandler.php +++ b/src/Handlers/RedirectHandler.php @@ -10,13 +10,18 @@ use Hibla\HttpClient\Interfaces\SSEResponseInterface; use Hibla\HttpClient\Interfaces\StreamingResponseInterface; use Hibla\HttpClient\Utils\RedirectUriResolver; +use Hibla\HttpClient\Validators\UriValidator; use Hibla\Promise\Interfaces\PromiseInterface; use function Hibla\async; use function Hibla\await; /** - * Handles HTTP redirects using a non-blocking iterative loop. + * Handles HTTP redirects recursively using a non-blocking fiber loop. + * + * This handler wraps the execution pipeline and inspects the resolved responses. + * If a 3xx redirect is detected, it builds a new request, strips sensitive headers + * (if crossing domains), and feeds the request back through the interceptor pipeline. * * @internal */ @@ -34,7 +39,7 @@ public function __construct( } /** - * Dispatches the request and follows redirects up to the configured limit. + * Dispatches the request and automatically follows redirects up to the configured limit. * * @template TResult * @@ -57,7 +62,6 @@ public function dispatch( $currentRequest = $request; while (true) { - // Store the current inner promise so it can be cancelled from the outside $currentPromise = $this->interceptorHandler->process( request: $currentRequest, interceptors: $this->interceptors, @@ -67,7 +71,6 @@ public function dispatch( /** @var TResult $response */ $response = await($currentPromise); - $currentPromise = null; $statusCode = 0; @@ -97,10 +100,13 @@ public function dispatch( 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", @@ -118,18 +124,19 @@ public function dispatch( $newUri = RedirectUriResolver::resolve($currentRequest->getUri(), $location); - $isCrossDomain = \strtolower($currentRequest->getUri()->getHost()) !== \strtolower($newUri->getHost()) - || $currentRequest->getUri()->getPort() !== $newUri->getPort() - || $currentRequest->getUri()->getScheme() !== $newUri->getScheme(); + 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'); } @@ -138,7 +145,7 @@ public function dispatch( } }); - $outerPromise->onCancel(function () use (&$currentPromise) { + $outerPromise->onCancel(function () use (&$currentPromise): void { if ($currentPromise instanceof PromiseInterface && ! $currentPromise->isSettled()) { $currentPromise->cancelChain(); } @@ -146,4 +153,4 @@ public function dispatch( return $outerPromise; } -} \ No newline at end of file +} diff --git a/src/HttpClient.php b/src/HttpClient.php index e94c206..15be7d1 100644 --- a/src/HttpClient.php +++ b/src/HttpClient.php @@ -15,11 +15,12 @@ use Hibla\HttpClient\Interfaces\HttpClientInterface; use Hibla\HttpClient\Interfaces\RequestInterface; use Hibla\HttpClient\Interfaces\ResponseInterface; -use Hibla\HttpClient\Interfaces\StreamingResponseInterface; 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; @@ -115,7 +116,8 @@ class HttpClient implements HttpClientInterface public function __construct() { $this->request = (new Request()) - ->withUserAgent(self::defaultUserAgent()); + ->withUserAgent(self::defaultUserAgent()) + ; $this->interceptorHandler = new InterceptorHandler(); } @@ -202,7 +204,7 @@ public function getHeaderLine(string $name): string */ public function withHeader(string $name, $value): static { - return $this->withUpdatedRequest(fn(Request $request) => $request->withHeader($name, $value)); + return $this->withUpdatedRequest(fn (Request $request) => $request->withHeader($name, $value)); } /** @@ -210,7 +212,7 @@ public function withHeader(string $name, $value): static */ public function withAddedHeader(string $name, $value): static { - return $this->withUpdatedRequest(fn(Request $request) => $request->withAddedHeader($name, $value)); + return $this->withUpdatedRequest(fn (Request $request) => $request->withAddedHeader($name, $value)); } /** @@ -218,7 +220,7 @@ public function withAddedHeader(string $name, $value): static */ public function withoutHeader(string $name): static { - return $this->withUpdatedRequest(fn(Request $request) => $request->withoutHeader($name)); + return $this->withUpdatedRequest(fn (Request $request) => $request->withoutHeader($name)); } /** @@ -234,7 +236,7 @@ public function getBody(): StreamInterface */ public function withBody(StreamInterface $body): static { - return $this->withUpdatedRequest(fn(Request $request) => $request->withBody($body)); + return $this->withUpdatedRequest(fn (Request $request) => $request->withBody($body)); } /** @@ -250,7 +252,7 @@ public function getRequestTarget(): string */ public function withRequestTarget(string $requestTarget): static { - return $this->withUpdatedRequest(fn(Request $request) => $request->withRequestTarget($requestTarget)); + return $this->withUpdatedRequest(fn (Request $request) => $request->withRequestTarget($requestTarget)); } /** @@ -266,7 +268,7 @@ public function getMethod(): string */ public function withMethod(string $method): static { - $new = $this->withUpdatedRequest(fn(Request $request) => $request->withMethod($method)); + $new = $this->withUpdatedRequest(fn (Request $request) => $request->withMethod($method)); $new->methodExplicitlySet = true; return $new; @@ -285,7 +287,7 @@ public function getUri(): UriInterface */ public function withUri(UriInterface $uri, bool $preserveHost = false): static { - return $this->withUpdatedRequest(fn(Request $request) => $request->withUri($uri, $preserveHost)); + return $this->withUpdatedRequest(fn (Request $request) => $request->withUri($uri, $preserveHost)); } /** @@ -293,7 +295,7 @@ public function withUri(UriInterface $uri, bool $preserveHost = false): static */ public function contentType(string $type): static { - return $this->withUpdatedRequest(fn(Request $request) => $request->contentType($type)); + return $this->withUpdatedRequest(fn (Request $request) => $request->contentType($type)); } /** @@ -301,7 +303,7 @@ public function contentType(string $type): static */ public function accept(string $type): static { - return $this->withUpdatedRequest(fn(Request $request) => $request->accept($type)); + return $this->withUpdatedRequest(fn (Request $request) => $request->accept($type)); } /** @@ -309,7 +311,7 @@ public function accept(string $type): static */ public function asJson(): static { - return $this->withUpdatedRequest(fn(Request $request) => $request->asJson()); + return $this->withUpdatedRequest(fn (Request $request) => $request->asJson()); } /** @@ -317,7 +319,7 @@ public function asJson(): static */ public function asForm(): static { - return $this->withUpdatedRequest(fn(Request $request) => $request->asForm()); + return $this->withUpdatedRequest(fn (Request $request) => $request->asForm()); } /** @@ -325,7 +327,7 @@ public function asForm(): static */ public function withUserAgent(string $userAgent): static { - return $this->withUpdatedRequest(fn(Request $request) => $request->withUserAgent($userAgent)); + return $this->withUpdatedRequest(fn (Request $request) => $request->withUserAgent($userAgent)); } /** @@ -333,7 +335,7 @@ public function withUserAgent(string $userAgent): static */ public function withHeaders(array $headers): static { - return $this->withUpdatedRequest(fn(Request $request) => $request->withHeaders($headers)); + return $this->withUpdatedRequest(fn (Request $request) => $request->withHeaders($headers)); } /** @@ -341,7 +343,7 @@ public function withHeaders(array $headers): static */ public function withToken(string $token, string $type = 'Bearer'): static { - return $this->withUpdatedRequest(fn(Request $request) => $request->withToken($token, $type)); + return $this->withUpdatedRequest(fn (Request $request) => $request->withToken($token, $type)); } /** @@ -349,7 +351,7 @@ public function withToken(string $token, string $type = 'Bearer'): static */ public function withBasicAuth(string $username, string $password): static { - return $this->withUpdatedRequest(fn(Request $request) => $request->withBasicAuth($username, $password)); + return $this->withUpdatedRequest(fn (Request $request) => $request->withBasicAuth($username, $password)); } /** @@ -357,7 +359,7 @@ public function withBasicAuth(string $username, string $password): static */ public function withDigestAuth(string $username, string $password): static { - return $this->withUpdatedRequest(fn(Request $request) => $request->withDigestAuth($username, $password)); + return $this->withUpdatedRequest(fn (Request $request) => $request->withDigestAuth($username, $password)); } /** @@ -365,7 +367,7 @@ public function withDigestAuth(string $username, string $password): static */ public function body(string $content): static { - return $this->withUpdatedRequest(fn(Request $request) => $request->body($content)); + return $this->withUpdatedRequest(fn (Request $request) => $request->body($content)); } /** @@ -373,7 +375,7 @@ public function body(string $content): static */ public function withJson(array $data): static { - return $this->withUpdatedRequest(fn(Request $request) => $request->withJson($data)); + return $this->withUpdatedRequest(fn (Request $request) => $request->withJson($data)); } /** @@ -381,7 +383,7 @@ public function withJson(array $data): static */ public function asXml(): static { - return $this->withUpdatedRequest(fn(Request $request) => $request->asXml()); + return $this->withUpdatedRequest(fn (Request $request) => $request->asXml()); } /** @@ -389,7 +391,7 @@ public function asXml(): static */ public function withXml(string|\SimpleXMLElement $xml): static { - return $this->withUpdatedRequest(fn(Request $request) => $request->withXml($xml)); + return $this->withUpdatedRequest(fn (Request $request) => $request->withXml($xml)); } /** @@ -397,7 +399,7 @@ public function withXml(string|\SimpleXMLElement $xml): static */ public function withForm(array $data): static { - return $this->withUpdatedRequest(fn(Request $request) => $request->withForm($data)); + return $this->withUpdatedRequest(fn (Request $request) => $request->withForm($data)); } /** @@ -405,7 +407,7 @@ public function withForm(array $data): static */ public function withMultipart(array $data): static { - return $this->withUpdatedRequest(fn(Request $request) => $request->withMultipart($data)); + return $this->withUpdatedRequest(fn (Request $request) => $request->withMultipart($data)); } /** @@ -413,7 +415,7 @@ public function withMultipart(array $data): static */ public function withCookie(string $name, string $value): static { - return $this->withUpdatedRequest(fn(Request $request) => $request->withCookie($name, $value)); + return $this->withUpdatedRequest(fn (Request $request) => $request->withCookie($name, $value)); } /** @@ -421,7 +423,7 @@ public function withCookie(string $name, string $value): static */ public function withCookies(array $cookies): static { - return $this->withUpdatedRequest(fn(Request $request) => $request->withCookies($cookies)); + return $this->withUpdatedRequest(fn (Request $request) => $request->withCookies($cookies)); } /** @@ -429,7 +431,7 @@ public function withCookies(array $cookies): static */ public function withCookieJar(): static { - return $this->withUpdatedRequest(fn(Request $request) => $request->withCookieJar()); + return $this->withUpdatedRequest(fn (Request $request) => $request->withCookieJar()); } /** @@ -437,7 +439,7 @@ public function withCookieJar(): static */ public function useCookieJar(CookieJarInterface $cookieJar): static { - return $this->withUpdatedRequest(fn(Request $request) => $request->useCookieJar($cookieJar)); + return $this->withUpdatedRequest(fn (Request $request) => $request->useCookieJar($cookieJar)); } /** @@ -445,7 +447,7 @@ public function useCookieJar(CookieJarInterface $cookieJar): static */ public function clearCookies(): static { - return $this->withUpdatedRequest(fn(Request $request) => $request->clearCookies()); + return $this->withUpdatedRequest(fn (Request $request) => $request->clearCookies()); } /** @@ -461,7 +463,7 @@ public function getCookieJar(): ?CookieJarInterface */ public function cookieWithAttributes(string $name, string $value, array $attributes = []): static { - return $this->withUpdatedRequest(fn(Request $request) => $request->cookieWithAttributes($name, $value, $attributes)); + return $this->withUpdatedRequest(fn (Request $request) => $request->cookieWithAttributes($name, $value, $attributes)); } /** @@ -702,7 +704,7 @@ public function withFile(string $name, mixed $file, ?string $filename = null, ?s throw new InvalidArgumentException('File must be a file path, UploadedFileInterface, StreamInterface, or resource.'); } - return $this->withUpdatedRequest(fn(Request $request) => $request->withMultipartEntry($name, $entry)); + return $this->withUpdatedRequest(fn (Request $request) => $request->withMultipartEntry($name, $entry)); } /** @@ -776,7 +778,7 @@ static function (RequestInterface $request, callable $next) use ($callback): Pro /** @var PromiseInterface $result */ /** @var PromiseInterface $chained */ $chained = $result->then( - static fn(mixed $resolved): PromiseInterface => $next(self::resolveRequest($resolved, true)) + static fn (mixed $resolved): PromiseInterface => $next(self::resolveRequest($resolved, true)) ); return $chained; @@ -806,7 +808,7 @@ static function (ResponseInterface $response) use ($callback): mixed { if ($result instanceof PromiseInterface) { /** @var PromiseInterface $result */ return $result->then( - static fn(mixed $resolved): ResponseInterface => self::resolveResponse($resolved, true) + static fn (mixed $resolved): ResponseInterface => self::resolveResponse($resolved, true) ); } @@ -920,13 +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); /** @var PromiseInterface */ - return $this->dispatchWithRedirects($initialRequest, fn(RequestInterface $req): PromiseInterface => $this->executeRequest($req), true); + return $this->dispatchWithRedirects($initialRequest, fn (RequestInterface $req): PromiseInterface => $this->executeRequest($req), true); } /** @@ -934,10 +934,8 @@ 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)); + $uri = $this->createValidatedUri($url); + $initialRequest = $this->request->withMethod($this->getMethod())->withUri($uri); /** @var PromiseInterface */ return $this->dispatchWithRedirects($initialRequest, function (RequestInterface $processed) use ($onChunk): PromiseInterface { @@ -954,11 +952,9 @@ 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); /** @var PromiseInterface, protocol_version: string|null}> */ return $this->dispatchWithRedirects($initialRequest, function (RequestInterface $processed) use ($source, $onProgress): PromiseInterface { @@ -966,12 +962,7 @@ public function upload(string $url, string $source, ?callable $onProgress = null $clientOptions = $this->buildClientOptionsFromProcessed($processed, timeout: $effectiveTimeout); $options = $this->resolveTransportOptionsBuilder()->buildForUpload($clientOptions, $source); - return $this->resolveHandler()->upload( - (string) $processed->getUri(), - $source, - $options, - $onProgress, - ); + return $this->resolveHandler()->upload((string) $processed->getUri(), $source, $options, $onProgress); }, false); } @@ -980,10 +971,8 @@ 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}> */ return $this->dispatchWithRedirects($initialRequest, function (RequestInterface $processed) use ($destination, $onProgress): PromiseInterface { @@ -991,12 +980,7 @@ public function download(string $url, string $destination, ?callable $onProgress $clientOptions = $this->buildClientOptionsFromProcessed($processed, timeout: $effectiveTimeout); $options = $this->resolveTransportOptionsBuilder()->buildForDownload($clientOptions, $destination); - return $this->resolveHandler()->download( - (string) $processed->getUri(), - $destination, - $options, - $onProgress, - ); + return $this->resolveHandler()->download((string) $processed->getUri(), $destination, $options, $onProgress); }, false); } @@ -1005,15 +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; @@ -1030,7 +1011,7 @@ public function sse(string $url): SSEBuilderInterface $this->dispatchWithRedirects(...) ); - return new SSEBuilder($expandedUrl, $connector); + return new SSEBuilder((string)$uri, $connector); } /** @@ -1251,6 +1232,21 @@ 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. diff --git a/src/SSE/SSEConnector.php b/src/SSE/SSEConnector.php index 50ff593..6dc63e4 100644 --- a/src/SSE/SSEConnector.php +++ b/src/SSE/SSEConnector.php @@ -56,9 +56,9 @@ public function __invoke( ); }; - /** @var PromiseInterface<\Hibla\HttpClient\SSE\SSEResponse> $pipelinePromise */ + /** @var PromiseInterface $pipelinePromise */ $pipelinePromise = ($this->dispatcher)($this->request, $executor, true); return new CancelableSSEPromise($pipelinePromise); } -} \ No newline at end of file +} diff --git a/src/Utils/RedirectUriResolver.php b/src/Utils/RedirectUriResolver.php index b4880c4..8c4ed62 100644 --- a/src/Utils/RedirectUriResolver.php +++ b/src/Utils/RedirectUriResolver.php @@ -32,7 +32,8 @@ public static function resolve(UriInterface $base, string $location): UriInterfa } $newUri = $base->withQuery($locationUri->getQuery()) - ->withFragment($locationUri->getFragment()); + ->withFragment($locationUri->getFragment()) + ; if ($locationUri->getPath() === '') { return $newUri; @@ -87,4 +88,4 @@ private static function removeDotSegments(string $path): string return $newPath; } -} \ No newline at end of file +} 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/Feature/SecurityVulnerabilityTest.php b/tests/Feature/SecurityVulnerabilityTest.php new file mode 100644 index 0000000..bff3b16 --- /dev/null +++ b/tests/Feature/SecurityVulnerabilityTest.php @@ -0,0 +1,830 @@ +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(); + }); + }); + + // ───────────────────────────────────────────────────────────────────────────── + // DNS Rebinding — Partial Application-Layer Mitigations + // Full prevention requires network-level controls. These tests verify that + // the mitigations available at the HTTP client layer (scheme downgrade + // stripping, host-change stripping) limit the blast radius of a rebind. + // ───────────────────────────────────────────────────────────────────────────── + describe('DNS Rebinding — Partial Application-Layer Mitigations', function () { + + it('strips credentials when a rebind-forced scheme downgrade is detected in a redirect', function () { + // After rebinding, the attacker's server (now on 127.0.0.1) responds + // with a Location downgrading https → http. The scheme change is a + // detectable signal that strips credentials. + $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 () { + // The rebind causes evil.com to resolve to 127.0.0.1 and return a + // Location pointing there explicitly. The host change is detectable. + $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.'); + }); +}); diff --git a/tests/Unit/UriValidatorTest.php b/tests/Unit/UriValidatorTest.php new file mode 100644 index 0000000..a146f2c --- /dev/null +++ b/tests/Unit/UriValidatorTest.php @@ -0,0 +1,380 @@ + 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(); + }); + }); + }); + + describe('DNS Rebinding (Known Limitation — Partial Mitigations Only)', function () { + it('documents that full DNS rebinding prevention requires network-level controls', function () { + // DNS rebinding works in two phases: + // + // Phase 1 — validation: evil.com resolves to 1.2.3.4 (public IP, passes checks). + // Phase 2 — connection: DNS TTL expires; evil.com now resolves to 127.0.0.1. + // The HTTP client connects to localhost despite the + // "safe" hostname, bypassing all host-based filtering. + // + // Reliable mitigations live outside the HTTP client: + // • Network egress firewall rules blocking RFC 1918 ranges on outbound traffic. + // • DNS-level controls: RPZ (Response Policy Zones) or split-horizon DNS. + // • Pinning the resolved IP at validation time and re-verifying before connect + // (requires a custom DNS resolver integration, not standard in libcurl). + // + // What the HTTP client CAN do (tested below): + // Strip credentials on scheme downgrade (https → http). + // Strip credentials on any detectable origin change in Location headers. + // These reduce the blast radius but do not prevent the connection itself. + expect(true)->toBeTrue(); + })->todo('DNS rebinding requires network-level controls outside the HTTP client layer likely using hibla DNS resolver.'); + + it('strips credentials when a rebind forces a scheme downgrade as part of the redirect chain', function () { + $a = new Uri('https://evil.com/phase-one'); + $b = new Uri('http://evil.com/phase-two'); + + expect(UriValidator::isCrossDomain($a, $b))->toBeTrue(); + }); + }); +}); From 19207268d876998d3323686927d2374027e04980 Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Sun, 12 Apr 2026 22:59:48 +0800 Subject: [PATCH 5/6] updated documentation in test --- tests/Feature/SecurityVulnerabilityTest.php | 15 ++--------- tests/Unit/UriValidatorTest.php | 30 --------------------- 2 files changed, 2 insertions(+), 43 deletions(-) diff --git a/tests/Feature/SecurityVulnerabilityTest.php b/tests/Feature/SecurityVulnerabilityTest.php index bff3b16..7d71f88 100644 --- a/tests/Feature/SecurityVulnerabilityTest.php +++ b/tests/Feature/SecurityVulnerabilityTest.php @@ -755,18 +755,9 @@ }); }); - // ───────────────────────────────────────────────────────────────────────────── - // DNS Rebinding — Partial Application-Layer Mitigations - // Full prevention requires network-level controls. These tests verify that - // the mitigations available at the HTTP client layer (scheme downgrade - // stripping, host-change stripping) limit the blast radius of a rebind. - // ───────────────────────────────────────────────────────────────────────────── describe('DNS Rebinding — Partial Application-Layer Mitigations', function () { it('strips credentials when a rebind-forced scheme downgrade is detected in a redirect', function () { - // After rebinding, the attacker's server (now on 127.0.0.1) responds - // with a Location downgrading https → http. The scheme change is a - // detectable signal that strips credentials. $nextHopRequest = null; $client = Http::client() @@ -791,8 +782,6 @@ }); it('strips credentials when a rebind redirect pivots to a loopback address', function () { - // The rebind causes evil.com to resolve to 127.0.0.1 and return a - // Location pointing there explicitly. The host change is detectable. $nextHopRequest = null; $client = Http::client() @@ -819,12 +808,12 @@ 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 + // 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.'); + })->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/Unit/UriValidatorTest.php b/tests/Unit/UriValidatorTest.php index a146f2c..6bd848b 100644 --- a/tests/Unit/UriValidatorTest.php +++ b/tests/Unit/UriValidatorTest.php @@ -347,34 +347,4 @@ }); }); }); - - describe('DNS Rebinding (Known Limitation — Partial Mitigations Only)', function () { - it('documents that full DNS rebinding prevention requires network-level controls', function () { - // DNS rebinding works in two phases: - // - // Phase 1 — validation: evil.com resolves to 1.2.3.4 (public IP, passes checks). - // Phase 2 — connection: DNS TTL expires; evil.com now resolves to 127.0.0.1. - // The HTTP client connects to localhost despite the - // "safe" hostname, bypassing all host-based filtering. - // - // Reliable mitigations live outside the HTTP client: - // • Network egress firewall rules blocking RFC 1918 ranges on outbound traffic. - // • DNS-level controls: RPZ (Response Policy Zones) or split-horizon DNS. - // • Pinning the resolved IP at validation time and re-verifying before connect - // (requires a custom DNS resolver integration, not standard in libcurl). - // - // What the HTTP client CAN do (tested below): - // Strip credentials on scheme downgrade (https → http). - // Strip credentials on any detectable origin change in Location headers. - // These reduce the blast radius but do not prevent the connection itself. - expect(true)->toBeTrue(); - })->todo('DNS rebinding requires network-level controls outside the HTTP client layer likely using hibla DNS resolver.'); - - it('strips credentials when a rebind forces a scheme downgrade as part of the redirect chain', function () { - $a = new Uri('https://evil.com/phase-one'); - $b = new Uri('http://evil.com/phase-two'); - - expect(UriValidator::isCrossDomain($a, $b))->toBeTrue(); - }); - }); }); From 7c7dbcb3fbdd1384ed70e865b1262fbbc4e8491e Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Sun, 12 Apr 2026 23:51:50 +0800 Subject: [PATCH 6/6] updated default http protocol version to 2.0 and updated Request class to support constructor parameter for psr7 concrete Request compatible class --- src/Message.php | 2 +- src/Request.php | 113 +++++++-- tests/PSR7/RequestTest.php | 491 +++++++++++++++++++++++++++++++++++++ 3 files changed, 586 insertions(+), 20 deletions(-) create mode 100644 tests/PSR7/RequestTest.php 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/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()); + }); +});