diff --git a/src/http/src/Request.php b/src/http/src/Request.php index c8881d966..87f4de1db 100644 --- a/src/http/src/Request.php +++ b/src/http/src/Request.php @@ -6,6 +6,7 @@ use ArrayAccess; use Closure; +use Hypervel\Context\RequestContext; use Hypervel\Contracts\Support\Arrayable; use Hypervel\Session\SymfonySessionDecorator; use Hypervel\Support\Arr; @@ -16,8 +17,12 @@ use Hypervel\Support\Uri; use Override; use RuntimeException; +use Symfony\Component\HttpFoundation\Exception\ConflictingHeadersException; use Symfony\Component\HttpFoundation\Exception\SessionNotFoundException; +use Symfony\Component\HttpFoundation\Exception\SuspiciousOperationException; +use Symfony\Component\HttpFoundation\HeaderUtils; use Symfony\Component\HttpFoundation\InputBag; +use Symfony\Component\HttpFoundation\IpUtils; use Symfony\Component\HttpFoundation\Request as SymfonyRequest; use Symfony\Component\HttpFoundation\Session\SessionInterface; @@ -39,6 +44,28 @@ class Request extends SymfonyRequest implements Arrayable, ArrayAccess use Conditionable; use Macroable; + /** + * Forwarded-header parameter mapping. + */ + protected const FORWARDED_PARAMS = [ + self::HEADER_X_FORWARDED_FOR => 'for', + self::HEADER_X_FORWARDED_HOST => 'host', + self::HEADER_X_FORWARDED_PROTO => 'proto', + self::HEADER_X_FORWARDED_PORT => 'host', + ]; + + /** + * Mapping of trusted-header bitmask flags to header names. + */ + protected const TRUSTED_HEADERS = [ + self::HEADER_FORWARDED => 'FORWARDED', + self::HEADER_X_FORWARDED_FOR => 'X_FORWARDED_FOR', + self::HEADER_X_FORWARDED_HOST => 'X_FORWARDED_HOST', + self::HEADER_X_FORWARDED_PROTO => 'X_FORWARDED_PROTO', + self::HEADER_X_FORWARDED_PORT => 'X_FORWARDED_PORT', + self::HEADER_X_FORWARDED_PREFIX => 'X_FORWARDED_PREFIX', + ]; + /** * The decoded JSON content for the request. */ @@ -66,6 +93,67 @@ class Request extends SymfonyRequest implements Arrayable, ArrayAccess */ protected ?string $cachedAcceptHeader = null; + /** + * Trusted proxy IP addresses / CIDR ranges for the current request. + * + * Stored per-instance instead of on Symfony's process-global statics so + * concurrent Swoole coroutines don't share trusted request configuration. + * + * @var string[] + */ + protected array $trustedProxiesValue = []; + + /** + * Bitmask of trusted forwarded-headers for the current request. + */ + protected int $trustedHeaderSetValue = -1; + + /** + * Compiled trusted-host regex patterns for the current request. + * + * @var string[] + */ + protected array $trustedHostPatternsValue = []; + + /** + * Memoized cache of host strings that matched a trusted pattern. + * + * @var string[] + */ + protected array $trustedHostsValue = []; + + /** + * Memoized cache of parsed trusted-header values for this request. + * + * @var array + */ + protected array $trustedValuesCacheValue = []; + + /** + * One-shot flag preventing duplicate "Suspicious Host" exceptions per request. + */ + protected bool $isHostValidValue = true; + + /** + * One-shot flag preventing duplicate "ConflictingHeaders" exceptions per request. + */ + protected bool $isForwardedValidValue = true; + + /** + * Initialize the request data. + */ + #[Override] + public function initialize(array $query = [], array $request = [], array $attributes = [], array $cookies = [], array $files = [], array $server = [], $content = null): void + { + parent::initialize($query, $request, $attributes, $cookies, $files, $server, $content); + + $this->trustedProxiesValue = []; + $this->trustedHeaderSetValue = -1; + $this->trustedHostPatternsValue = []; + $this->trustedHostsValue = []; + $this->resetTrustedRequestCaches(); + } + /** * Create a new HTTP request from PHP superglobals. * @@ -76,6 +164,92 @@ public static function createFromGlobals(): static throw new RuntimeException('Request::createFromGlobals() is not supported in Hypervel. Requests are created from Swoole request objects.'); } + /** + * Set the trusted proxies on the current request. + */ + public static function setTrustedProxies(array $proxies, int $trustedHeaderSet): void + { + // Keep Symfony's static API, but write to the current coroutine request + // so concurrent requests never share proxy trust configuration. + $request = RequestContext::getOrNull(); + + if (! $request instanceof self) { + return; + } + + if (false !== $i = array_search('REMOTE_ADDR', $proxies, true)) { + if (null !== $remote = $request->server->get('REMOTE_ADDR')) { + $proxies[$i] = $remote; + } else { + unset($proxies[$i]); + $proxies = array_values($proxies); + } + } + + if (false !== ($i = array_search('PRIVATE_SUBNETS', $proxies, true)) + || false !== ($i = array_search('private_ranges', $proxies, true))) { + unset($proxies[$i]); + $proxies = array_merge($proxies, IpUtils::PRIVATE_SUBNETS); + } + + $request->trustedProxiesValue = $proxies; + $request->trustedHeaderSetValue = $trustedHeaderSet; + $request->resetTrustedRequestCaches(); + } + + /** + * Get the trusted proxies for the current request. + * + * @return string[] + */ + public static function getTrustedProxies(): array + { + $request = RequestContext::getOrNull(); + + return $request instanceof self ? $request->trustedProxiesValue : []; + } + + /** + * Get the trusted-header bitmask for the current request. + */ + public static function getTrustedHeaderSet(): int + { + $request = RequestContext::getOrNull(); + + return $request instanceof self ? $request->trustedHeaderSetValue : -1; + } + + /** + * Set the trusted host patterns for the current request. + */ + public static function setTrustedHosts(array $hostPatterns): void + { + $request = RequestContext::getOrNull(); + + if (! $request instanceof self) { + return; + } + + $request->trustedHostPatternsValue = array_map( + fn ($hostPattern) => sprintf('{%s}i', $hostPattern), + $hostPatterns, + ); + $request->trustedHostsValue = []; + $request->resetTrustedRequestCaches(); + } + + /** + * Get the trusted host patterns for the current request. + * + * @return string[] + */ + public static function getTrustedHosts(): array + { + $request = RequestContext::getOrNull(); + + return $request instanceof self ? $request->trustedHostPatternsValue : []; + } + /** * Return the Request instance. * @@ -294,6 +468,137 @@ public function ips(): array return $this->getClientIps(); } + /** + * Get the client IP addresses. + */ + #[Override] + public function getClientIps(): array + { + $ip = $this->server->get('REMOTE_ADDR'); + + if (! $this->isFromTrustedProxy()) { + return [$ip]; + } + + return $this->getTrustedValues(self::HEADER_X_FORWARDED_FOR, $ip) ?: [$ip]; + } + + /** + * Return the root URL from which this request is executed. + */ + #[Override] + public function getBaseUrl(): string + { + $trustedPrefix = ''; + + if ($this->isFromTrustedProxy() + && $trustedPrefixValues = $this->getTrustedValues(self::HEADER_X_FORWARDED_PREFIX)) { + $trustedPrefix = rtrim($trustedPrefixValues[0], '/'); + } + + return $trustedPrefix . $this->getBaseUrlReal(); + } + + /** + * Return the port on which the request is made. + */ + #[Override] + public function getPort(): int|string|null + { + if ($this->isFromTrustedProxy() && $host = $this->getTrustedValues(self::HEADER_X_FORWARDED_PORT)) { + $host = $host[0]; + } elseif ($this->isFromTrustedProxy() && $host = $this->getTrustedValues(self::HEADER_X_FORWARDED_HOST)) { + $host = $host[0]; + } elseif (! $host = $this->headers->get('HOST')) { + return $this->server->get('SERVER_PORT'); + } + + if ($host[0] === '[') { + $pos = strpos($host, ':', strrpos($host, ']')); + } else { + $pos = strrpos($host, ':'); + } + + if ($pos !== false && $port = substr($host, $pos + 1)) { + return (int) $port; + } + + return $this->getScheme() === 'https' ? 443 : 80; + } + + /** + * Determine whether the request is secure. + */ + #[Override] + public function isSecure(): bool + { + if ($this->isFromTrustedProxy() + && $proto = $this->getTrustedValues(self::HEADER_X_FORWARDED_PROTO)) { + return in_array(strtolower($proto[0]), ['https', 'on', 'ssl', '1'], true); + } + + $https = $this->server->get('HTTPS'); + + return $https && (! is_string($https) || strtolower($https) !== 'off'); + } + + /** + * Return the host name. + */ + #[Override] + public function getHost(): string + { + if ($this->isFromTrustedProxy() && $host = $this->getTrustedValues(self::HEADER_X_FORWARDED_HOST)) { + $host = $host[0]; + } else { + $host = $this->headers->get('HOST') ?: $this->server->get('SERVER_NAME') ?: $this->server->get('SERVER_ADDR', ''); + } + + $host = strtolower(preg_replace('/:\d+$/', '', trim($host))); + + if ($host && ! static::isHostValid($host)) { + if (! $this->isHostValidValue) { + return ''; + } + $this->isHostValidValue = false; + + throw new SuspiciousOperationException(sprintf('Invalid Host "%s".', $host)); + } + + if (count($this->trustedHostPatternsValue) > 0) { + if (in_array($host, $this->trustedHostsValue, true)) { + return $host; + } + + foreach ($this->trustedHostPatternsValue as $pattern) { + if (preg_match($pattern, $host)) { + $this->trustedHostsValue[] = $host; + + return $host; + } + } + + if (! $this->isHostValidValue) { + return ''; + } + $this->isHostValidValue = false; + + throw new SuspiciousOperationException(sprintf('Untrusted Host "%s".', $host)); + } + + return $host; + } + + /** + * Determine whether this request originated from a trusted proxy. + */ + #[Override] + public function isFromTrustedProxy(): bool + { + return $this->trustedProxiesValue + && IpUtils::checkIp($this->server->get('REMOTE_ADDR', ''), $this->trustedProxiesValue); + } + /** * Get the client user agent. */ @@ -447,6 +752,8 @@ public static function createFrom(self $from, ?self $to = null): static $request->setRouteResolver($from->getRouteResolver()); + $request->copyTrustedStateFrom($from); + /** @var static $request */ return $request; } @@ -469,6 +776,10 @@ public static function createFromBase(SymfonyRequest $request): static $newRequest->request = $newRequest->json(); } + if ($request instanceof self) { + $newRequest->copyTrustedStateFrom($request); + } + return $newRequest; } @@ -478,6 +789,186 @@ public function duplicate(?array $query = null, ?array $request = null, ?array $ return parent::duplicate($query, $request, $attributes, $cookies, $this->filterFiles($files), $server); } + /** + * Clone the current request. + */ + #[Override] + public function __clone() + { + parent::__clone(); + + // Symfony's duplicate() clones the request internally, so this covers + // both direct clone calls and duplicate() without duplicating reset code. + $this->resetTrustedRequestCaches(); + } + + /** + * Copy trusted request configuration from another request. + */ + protected function copyTrustedStateFrom(self $from): void + { + $this->trustedProxiesValue = $from->trustedProxiesValue; + $this->trustedHeaderSetValue = $from->trustedHeaderSetValue; + $this->trustedHostPatternsValue = $from->trustedHostPatternsValue; + $this->trustedHostsValue = $from->trustedHostsValue; + + // Copy configuration only. Parsed forwarded values and one-shot + // exception flags belong to this distinct request object's lifecycle. + $this->resetTrustedRequestCaches(); + } + + /** + * Reset the trusted-values cache and one-shot exception flags. + */ + protected function resetTrustedRequestCaches(): void + { + $this->trustedValuesCacheValue = []; + $this->isHostValidValue = true; + $this->isForwardedValidValue = true; + } + + /** + * Return the real base URL without the trusted reverse proxy prefix. + */ + protected function getBaseUrlReal(): string + { + // Symfony keeps this helper private, but getBaseUrl() needs the same + // unprefixed value before adding any trusted X-Forwarded-Prefix. + return $this->baseUrl ??= $this->prepareBaseUrl(); + } + + /** + * Parse the trusted forwarded-header values for the requested type. + */ + protected function getTrustedValues(int $type, ?string $ip = null): array + { + // Header values are part of the key; trusted-proxy/header config changes + // clear this cache in the setters because they affect filtering too. + $cacheKey = $type . "\0" + . (($this->trustedHeaderSetValue & $type) ? $this->headers->get(self::TRUSTED_HEADERS[$type]) : ''); + $cacheKey .= "\0" . $ip . "\0" . $this->headers->get(self::TRUSTED_HEADERS[self::HEADER_FORWARDED]); + + if (isset($this->trustedValuesCacheValue[$cacheKey])) { + return $this->trustedValuesCacheValue[$cacheKey]; + } + + $clientValues = []; + $forwardedValues = []; + + if (($this->trustedHeaderSetValue & $type) + && $this->headers->has(self::TRUSTED_HEADERS[$type])) { + foreach (explode(',', $this->headers->get(self::TRUSTED_HEADERS[$type])) as $value) { + $clientValues[] = ($type === self::HEADER_X_FORWARDED_PORT ? '0.0.0.0:' : '') . trim($value); + } + } + + if (($this->trustedHeaderSetValue & self::HEADER_FORWARDED) + && isset(self::FORWARDED_PARAMS[$type]) + && $this->headers->has(self::TRUSTED_HEADERS[self::HEADER_FORWARDED])) { + $forwarded = $this->headers->get(self::TRUSTED_HEADERS[self::HEADER_FORWARDED]); + $parts = HeaderUtils::split($forwarded, ',;='); + $param = self::FORWARDED_PARAMS[$type]; + + foreach ($parts as $subParts) { + if (null === $value = HeaderUtils::combine($subParts)[$param] ?? null) { + continue; + } + + if ($type === self::HEADER_X_FORWARDED_PORT) { + if (str_ends_with($value, ']') || false === $value = strrchr($value, ':')) { + $value = $this->isSecure() ? ':443' : ':80'; + } + $value = '0.0.0.0' . $value; + } + + $forwardedValues[] = $value; + } + } + + if ($ip !== null) { + $clientValues = $this->normalizeAndFilterClientIps($clientValues, $ip); + $forwardedValues = $this->normalizeAndFilterClientIps($forwardedValues, $ip); + } + + if ($forwardedValues === $clientValues || ! $clientValues) { + return $this->trustedValuesCacheValue[$cacheKey] = $forwardedValues; + } + + if (! $forwardedValues) { + return $this->trustedValuesCacheValue[$cacheKey] = $clientValues; + } + + if (! $this->isForwardedValidValue) { + return $this->trustedValuesCacheValue[$cacheKey] = $ip !== null + ? ['0.0.0.0', $ip] + : []; + } + $this->isForwardedValidValue = false; + + throw new ConflictingHeadersException(sprintf( + 'The request has both a trusted "%s" header and a trusted "%s" header, conflicting with each other.' + . ' You should either configure your proxy to remove one of them,' + . ' or configure your project to distrust the offending one.', + self::TRUSTED_HEADERS[self::HEADER_FORWARDED], + self::TRUSTED_HEADERS[$type], + )); + } + + /** + * Normalize and filter trusted client IPs. + */ + protected function normalizeAndFilterClientIps(array $clientIps, string $ip): array + { + if (! $clientIps) { + return []; + } + + $clientIps[] = $ip; + $firstTrustedIp = null; + + foreach ($clientIps as $key => $clientIp) { + if (strpos($clientIp, '.')) { + $index = strpos($clientIp, ':'); + if ($index) { + $clientIps[$key] = $clientIp = substr($clientIp, 0, $index); + } + } elseif (str_starts_with($clientIp, '[')) { + $index = strpos($clientIp, ']', 1); + $clientIps[$key] = $clientIp = substr($clientIp, 1, $index - 1); + } + + if (! filter_var($clientIp, FILTER_VALIDATE_IP)) { + unset($clientIps[$key]); + + continue; + } + + if (IpUtils::checkIp($clientIp, $this->trustedProxiesValue)) { + unset($clientIps[$key]); + $firstTrustedIp ??= $clientIp; + } + } + + return $clientIps ? array_reverse($clientIps) : [$firstTrustedIp]; + } + + /** + * Validate a host string per Symfony's URL-spec rules. + */ + protected static function isHostValid(string $host): bool + { + if ($host[0] === '[') { + return $host[-1] === ']' + && filter_var(substr($host, 1, -1), FILTER_VALIDATE_IP, FILTER_FLAG_IPV6); + } + + if (preg_match('/\.[0-9]++\.?$/D', $host)) { + return filter_var($host, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 | FILTER_NULL_ON_FAILURE) !== null; + } + + return preg_replace('/[-a-zA-Z0-9_]++\.?/', '', $host) === ''; + } + /** * Filter the given array of files, removing any empty values. */ diff --git a/tests/Http/HttpRequestTrustedStateCoroutineTest.php b/tests/Http/HttpRequestTrustedStateCoroutineTest.php new file mode 100644 index 000000000..41c6297b4 --- /dev/null +++ b/tests/Http/HttpRequestTrustedStateCoroutineTest.php @@ -0,0 +1,146 @@ + '10.0.0.1']); + RequestContext::set($request); + + Request::setTrustedProxies(['10.0.0.1'], Request::HEADER_X_FORWARDED_FOR); + usleep(5000); + + return [ + 'trusted' => Request::getTrustedProxies(), + 'isFrom' => $request->isFromTrustedProxy(), + ]; + }, + function () { + usleep(2500); + + $request = Request::create('http://b.example.com/', 'GET', [], [], [], ['REMOTE_ADDR' => '20.0.0.1']); + RequestContext::set($request); + + Request::setTrustedProxies(['20.0.0.1'], Request::HEADER_X_FORWARDED_FOR); + usleep(5000); + + return [ + 'trusted' => Request::getTrustedProxies(), + 'isFrom' => $request->isFromTrustedProxy(), + ]; + }, + ]); + + $this->assertSame(['10.0.0.1'], $resultA['trusted']); + $this->assertTrue($resultA['isFrom']); + $this->assertSame(['20.0.0.1'], $resultB['trusted']); + $this->assertTrue($resultB['isFrom']); + } + + public function testConcurrentClientIpResolutionIsIsolated() + { + [$clientA, $clientB] = parallel([ + function () { + $request = Request::create('http://a.example.com/', 'GET', [], [], [], [ + 'REMOTE_ADDR' => '10.0.0.1', + 'HTTP_X_FORWARDED_FOR' => '9.9.9.9', + ]); + RequestContext::set($request); + Request::setTrustedProxies(['10.0.0.1'], Request::HEADER_X_FORWARDED_FOR); + usleep(5000); + + return $request->ip(); + }, + function () { + usleep(2500); + + $request = Request::create('http://b.example.com/', 'GET', [], [], [], [ + 'REMOTE_ADDR' => '20.0.0.1', + 'HTTP_X_FORWARDED_FOR' => '8.8.8.8', + ]); + RequestContext::set($request); + Request::setTrustedProxies(['20.0.0.1'], Request::HEADER_X_FORWARDED_FOR); + usleep(5000); + + return $request->ip(); + }, + ]); + + $this->assertSame('9.9.9.9', $clientA); + $this->assertSame('8.8.8.8', $clientB); + } + + public function testConcurrentHostResolutionIsIsolated() + { + [$hostA, $hostB] = parallel([ + function () { + $request = Request::create('http://a.com/'); + RequestContext::set($request); + Request::setTrustedHosts(['^a\.com$']); + usleep(5000); + + return $request->getHost(); + }, + function () { + usleep(2500); + + $request = Request::create('http://b.com/'); + RequestContext::set($request); + Request::setTrustedHosts(['^b\.com$']); + usleep(5000); + + return $request->getHost(); + }, + ]); + + $this->assertSame('a.com', $hostA); + $this->assertSame('b.com', $hostB); + } + + public function testHostValidityFlagsAreIsolated() + { + [$resultA, $resultB] = parallel([ + function () { + $request = Request::create('http://evil.com/'); + RequestContext::set($request); + Request::setTrustedHosts(['^a\.com$']); + + try { + $request->getHost(); + } catch (SuspiciousOperationException) { + usleep(5000); + + return $request->getHost(); + } + + return 'unexpected'; + }, + function () { + usleep(2500); + + $request = Request::create('http://b.com/'); + RequestContext::set($request); + Request::setTrustedHosts(['^b\.com$']); + usleep(5000); + + return $request->getHost(); + }, + ]); + + $this->assertSame('', $resultA); + $this->assertSame('b.com', $resultB); + } +} diff --git a/tests/Http/HttpRequestTrustedStateTest.php b/tests/Http/HttpRequestTrustedStateTest.php new file mode 100644 index 000000000..89331edc9 --- /dev/null +++ b/tests/Http/HttpRequestTrustedStateTest.php @@ -0,0 +1,410 @@ + '1.2.3.4']); + + $this->assertSame(['1.2.3.4'], $request->getClientIps()); + } + + public function testGetClientIpsWithTrustedProxyHonorsXForwardedFor() + { + $request = $this->trustedRequest( + ['REMOTE_ADDR' => '10.0.0.1', 'HTTP_X_FORWARDED_FOR' => '9.9.9.9'], + ['10.0.0.1'], + Request::HEADER_X_FORWARDED_FOR + ); + + $this->assertSame(['9.9.9.9'], $request->getClientIps()); + $this->assertSame('9.9.9.9', $request->getClientIp()); + } + + public function testGetClientIpsIgnoresXForwardedForFromUntrustedProxy() + { + $request = $this->trustedRequest( + ['REMOTE_ADDR' => '20.0.0.1', 'HTTP_X_FORWARDED_FOR' => '9.9.9.9'], + ['10.0.0.1'], + Request::HEADER_X_FORWARDED_FOR + ); + + $this->assertSame(['20.0.0.1'], $request->getClientIps()); + } + + public function testIsFromTrustedProxyHandlesCidrRanges() + { + $request = $this->trustedRequest( + ['REMOTE_ADDR' => '10.0.0.55'], + ['10.0.0.0/24'], + Request::HEADER_X_FORWARDED_FOR + ); + + $this->assertTrue($request->isFromTrustedProxy()); + } + + public function testGetHostWithoutTrustReadsHttpHost() + { + $request = Request::create('http://example.com/'); + + $this->assertSame('example.com', $request->getHost()); + } + + public function testGetHostWithTrustedProxyHonorsXForwardedHost() + { + $request = $this->trustedRequest( + ['REMOTE_ADDR' => '10.0.0.1', 'HTTP_X_FORWARDED_HOST' => 'real.com'], + ['10.0.0.1'], + Request::HEADER_X_FORWARDED_HOST + ); + + $this->assertSame('real.com', $request->getHost()); + } + + public function testGetHostThrowsOnUntrustedHostWhenPatternsConfigured() + { + $request = Request::create('http://evil.com/'); + RequestContext::set($request); + Request::setTrustedHosts(['^example\.com$']); + + $this->expectException(SuspiciousOperationException::class); + + $request->getHost(); + } + + public function testGetHostThrowsOnlyOncePerRequest() + { + $request = Request::create('http://evil.com/'); + RequestContext::set($request); + Request::setTrustedHosts(['^example\.com$']); + + try { + $request->getHost(); + $this->fail('Expected first host read to throw.'); + } catch (SuspiciousOperationException) { + $this->assertSame('', $request->getHost()); + } + } + + public function testGetHostHonorsValidWildcardPattern() + { + $request = Request::create('http://api.example.com/'); + RequestContext::set($request); + Request::setTrustedHosts(['^.+\.example\.com$']); + + $this->assertSame('api.example.com', $request->getHost()); + } + + public function testIsSecureFromTrustedProxyXForwardedProto() + { + $request = $this->trustedRequest( + ['REMOTE_ADDR' => '10.0.0.1', 'HTTP_X_FORWARDED_PROTO' => 'https'], + ['10.0.0.1'], + Request::HEADER_X_FORWARDED_PROTO + ); + + $this->assertTrue($request->isSecure()); + } + + public function testIsSecureIgnoresXForwardedProtoFromUntrustedProxy() + { + $request = $this->trustedRequest( + ['REMOTE_ADDR' => '20.0.0.1', 'HTTP_X_FORWARDED_PROTO' => 'https'], + ['10.0.0.1'], + Request::HEADER_X_FORWARDED_PROTO + ); + + $this->assertFalse($request->isSecure()); + } + + public function testGetPortFromXForwardedPort() + { + $request = $this->trustedRequest( + ['REMOTE_ADDR' => '10.0.0.1', 'HTTP_X_FORWARDED_PORT' => '8443'], + ['10.0.0.1'], + Request::HEADER_X_FORWARDED_PORT + ); + + $this->assertSame(8443, $request->getPort()); + } + + public function testGetPortFromXForwardedHostWithPort() + { + $request = $this->trustedRequest( + ['REMOTE_ADDR' => '10.0.0.1', 'HTTP_X_FORWARDED_HOST' => 'real.com:8080'], + ['10.0.0.1'], + Request::HEADER_X_FORWARDED_HOST + ); + + $this->assertSame(8080, $request->getPort()); + } + + public function testGetBaseUrlIncludesXForwardedPrefix() + { + $request = $this->trustedRequest( + ['REMOTE_ADDR' => '10.0.0.1', 'HTTP_X_FORWARDED_PREFIX' => '/app'], + ['10.0.0.1'], + Request::HEADER_X_FORWARDED_PREFIX, + '/users' + ); + + $this->assertSame('/app', $request->getBaseUrl()); + } + + public function testGetBaseUrlIgnoresXForwardedPrefixFromUntrustedProxy() + { + $request = $this->trustedRequest( + ['REMOTE_ADDR' => '20.0.0.1', 'HTTP_X_FORWARDED_PREFIX' => '/app'], + ['10.0.0.1'], + Request::HEADER_X_FORWARDED_PREFIX, + '/users' + ); + + $this->assertSame('', $request->getBaseUrl()); + } + + public function testConflictingForwardedHeaderThrowsOnce() + { + $request = $this->trustedRequest( + [ + 'REMOTE_ADDR' => '10.0.0.1', + 'HTTP_FORWARDED' => 'for=8.8.8.8', + 'HTTP_X_FORWARDED_FOR' => '9.9.9.9', + ], + ['10.0.0.1'], + Request::HEADER_FORWARDED | Request::HEADER_X_FORWARDED_FOR + ); + + try { + $request->getClientIps(); + $this->fail('Expected conflicting forwarded headers to throw.'); + } catch (ConflictingHeadersException) { + $this->assertSame(['0.0.0.0', '10.0.0.1'], $request->getClientIps()); + } + } + + public function testSetTrustedProxiesResolvesRemoteAddrSentinel() + { + $request = Request::create('/', 'GET', [], [], [], ['REMOTE_ADDR' => '5.5.5.5']); + RequestContext::set($request); + + Request::setTrustedProxies(['REMOTE_ADDR'], Request::HEADER_X_FORWARDED_FOR); + + $this->assertSame(['5.5.5.5'], Request::getTrustedProxies()); + } + + public function testSetTrustedProxiesExpandsPrivateSubnetsSentinel() + { + $request = Request::create('/'); + RequestContext::set($request); + + Request::setTrustedProxies(['PRIVATE_SUBNETS'], Request::HEADER_X_FORWARDED_FOR); + + $this->assertSame(IpUtils::PRIVATE_SUBNETS, Request::getTrustedProxies()); + } + + public function testSetTrustedHostsCompilesRegexPatterns() + { + $request = Request::create('/'); + RequestContext::set($request); + + Request::setTrustedHosts(['^example\.com$']); + + $this->assertSame(['{^example\.com$}i'], Request::getTrustedHosts()); + } + + public function testCreateFromPreservesTrustedRequestConfiguration() + { + $source = $this->trustedRequest( + [ + 'REMOTE_ADDR' => '10.0.0.1', + 'HTTP_X_FORWARDED_FOR' => '9.9.9.9', + 'HTTP_X_FORWARDED_HOST' => 'api.example.com', + 'HTTP_X_FORWARDED_PREFIX' => '/app', + ], + ['10.0.0.1'], + Request::HEADER_X_FORWARDED_FOR | Request::HEADER_X_FORWARDED_HOST | Request::HEADER_X_FORWARDED_PREFIX, + '/users' + ); + Request::setTrustedHosts(['^api\.example\.com$']); + + $copy = Request::createFrom($source); + + $this->assertSame('9.9.9.9', $copy->ip()); + $this->assertSame('api.example.com', $copy->host()); + $this->assertSame('/app', $copy->getBaseUrl()); + } + + public function testCreateFromBasePreservesTrustedRequestConfigurationForHypervelRequests() + { + $source = $this->trustedRequest( + ['REMOTE_ADDR' => '10.0.0.1', 'HTTP_X_FORWARDED_FOR' => '9.9.9.9'], + ['10.0.0.1'], + Request::HEADER_X_FORWARDED_FOR + ); + + $copy = Request::createFromBase($source); + + $this->assertSame('9.9.9.9', $copy->ip()); + } + + public function testInitializeResetsTrustedRequestState() + { + $request = $this->trustedRequest( + ['REMOTE_ADDR' => '10.0.0.1', 'HTTP_X_FORWARDED_FOR' => '9.9.9.9'], + ['10.0.0.1'], + Request::HEADER_X_FORWARDED_FOR + ); + + $request->initialize(server: ['REMOTE_ADDR' => '10.0.0.1', 'HTTP_X_FORWARDED_FOR' => '8.8.8.8']); + + $this->assertSame([], Request::getTrustedProxies()); + $this->assertSame(-1, Request::getTrustedHeaderSet()); + $this->assertSame(['10.0.0.1'], $request->getClientIps()); + } + + public function testClonePreservesConfigurationButResetsOneShotFlags() + { + $request = Request::create('http://evil.com/'); + RequestContext::set($request); + Request::setTrustedHosts(['^example\.com$']); + + try { + $request->getHost(); + $this->fail('Expected original request host read to throw.'); + } catch (SuspiciousOperationException) { + $clone = clone $request; + } + + $this->expectException(SuspiciousOperationException::class); + + $clone->getHost(); + } + + public function testDuplicatePreservesConfigurationThroughCloneLifecycle() + { + $request = $this->trustedRequest( + ['REMOTE_ADDR' => '10.0.0.1', 'HTTP_X_FORWARDED_FOR' => '9.9.9.9, 10.0.0.2'], + ['10.0.0.1', '10.0.0.2'], + Request::HEADER_X_FORWARDED_FOR + ); + $this->assertSame(['9.9.9.9'], $request->getClientIps()); + + $this->assertNotSame([], $this->trustedValuesCache($request)); + + $duplicate = $request->duplicate(); + + $this->assertSame([], $this->trustedValuesCache($duplicate)); + $this->assertSame(['9.9.9.9'], $duplicate->getClientIps()); + } + + public function testSetTrustedProxiesClearsTrustedValuesCache() + { + $request = $this->trustedRequest( + ['REMOTE_ADDR' => '10.0.0.1', 'HTTP_X_FORWARDED_FOR' => '9.9.9.9, 10.0.0.2'], + ['10.0.0.1', '10.0.0.2'], + Request::HEADER_X_FORWARDED_FOR + ); + $this->assertSame(['9.9.9.9'], $request->getClientIps()); + + Request::setTrustedProxies(['10.0.0.1'], Request::HEADER_X_FORWARDED_FOR); + + $this->assertSame(['10.0.0.2', '9.9.9.9'], $request->getClientIps()); + } + + public function testSetTrustedHostsClearsTrustedHostCacheAndFlags() + { + $request = Request::create('http://evil.com/'); + RequestContext::set($request); + Request::setTrustedHosts(['^example\.com$']); + + try { + $request->getHost(); + $this->fail('Expected first host read to throw.'); + } catch (SuspiciousOperationException) { + Request::setTrustedHosts(['^evil\.com$']); + } + + $this->assertSame('evil.com', $request->getHost()); + } + + public function testStaticSettersAreNoOpWithoutCurrentRequest() + { + RequestContext::forget(); + Request::setTrustedProxies(['10.0.0.1'], Request::HEADER_X_FORWARDED_FOR); + Request::setTrustedHosts(['^example\.com$']); + + $request = Request::create('http://evil.com/', 'GET', [], [], [], [ + 'REMOTE_ADDR' => '10.0.0.1', + 'HTTP_X_FORWARDED_FOR' => '9.9.9.9', + ]); + + $this->assertSame(['10.0.0.1'], $request->getClientIps()); + $this->assertSame('evil.com', $request->getHost()); + } + + public function testStaticGettersReturnDefaultsWithoutCurrentRequest() + { + RequestContext::forget(); + + $this->assertSame([], Request::getTrustedProxies()); + $this->assertSame(-1, Request::getTrustedHeaderSet()); + $this->assertSame([], Request::getTrustedHosts()); + } + + public function testSingleRequestMatchesSymfonyTrustedProxyBehavior() + { + $server = [ + 'REMOTE_ADDR' => '10.0.0.1', + 'HTTP_X_FORWARDED_FOR' => '9.9.9.9, 10.0.0.2', + 'HTTP_X_FORWARDED_HOST' => 'api.example.com', + 'HTTP_X_FORWARDED_PROTO' => 'https', + 'HTTP_X_FORWARDED_PORT' => '8443', + ]; + $headers = Request::HEADER_X_FORWARDED_FOR + | Request::HEADER_X_FORWARDED_HOST + | Request::HEADER_X_FORWARDED_PROTO + | Request::HEADER_X_FORWARDED_PORT; + + try { + $symfony = SymfonyRequest::create('http://internal.test/users', 'GET', [], [], [], $server); + SymfonyRequest::setTrustedProxies(['10.0.0.1', '10.0.0.2'], $headers); + + $hypervel = $this->trustedRequest($server, ['10.0.0.1', '10.0.0.2'], $headers, '/users'); + + $this->assertSame($symfony->getClientIps(), $hypervel->getClientIps()); + $this->assertSame($symfony->getHost(), $hypervel->getHost()); + $this->assertSame($symfony->getPort(), $hypervel->getPort()); + $this->assertSame($symfony->isSecure(), $hypervel->isSecure()); + } finally { + SymfonyRequest::setTrustedProxies([], -1); + } + } + + private function trustedRequest(array $server, array $proxies, int $headers, string $uri = '/'): Request + { + $request = Request::create($uri, 'GET', [], [], [], $server); + RequestContext::set($request); + Request::setTrustedProxies($proxies, $headers); + + return $request; + } + + private function trustedValuesCache(Request $request): array + { + return (new ReflectionProperty(Request::class, 'trustedValuesCacheValue'))->getValue($request); + } +} diff --git a/tests/Inertia/ResponseTest.php b/tests/Inertia/ResponseTest.php index fc9b99cd0..5dc7a7197 100644 --- a/tests/Inertia/ResponseTest.php +++ b/tests/Inertia/ResponseTest.php @@ -4,6 +4,7 @@ namespace Hypervel\Tests\Inertia; +use Hypervel\Context\RequestContext; use Hypervel\Contracts\Support\Arrayable; use Hypervel\Http\JsonResponse; use Hypervel\Http\Request; @@ -1635,11 +1636,11 @@ public function testResponsableWithInvalidKey(): void public function testThePageUrlIsPrefixedWithTheProxyPrefix(): void { - Request::setTrustedProxies(['1.2.3.4'], Request::HEADER_X_FORWARDED_PREFIX); - $request = Request::create('/user/123', 'GET'); $request->server->set('REMOTE_ADDR', '1.2.3.4'); $request->headers->set('X_FORWARDED_PREFIX', '/sub/directory'); + RequestContext::set($request); + Request::setTrustedProxies(['1.2.3.4'], Request::HEADER_X_FORWARDED_PREFIX); $user = ['name' => 'Jonathan']; $response = new Response('User/Edit', [], ['user' => $user], 'app', '123'); diff --git a/tests/Integration/Http/Middleware/TrustHostsTest.php b/tests/Integration/Http/Middleware/TrustHostsTest.php new file mode 100644 index 000000000..3df8975a5 --- /dev/null +++ b/tests/Integration/Http/Middleware/TrustHostsTest.php @@ -0,0 +1,70 @@ + $request->getHost()) + ->middleware(AlwaysTrustHosts::class); + } + + protected function tearDown(): void + { + AlwaysTrustHosts::flushState(); + + parent::tearDown(); + } + + public function testRequestSucceedsWithTrustedHostPattern() + { + AlwaysTrustHosts::at(['^example\.com$'], subdomains: false); + + $this->call('GET', 'http://example.com/host') + ->assertOk() + ->assertContent('example.com'); + } + + public function testRequestFailsWithUntrustedHost() + { + AlwaysTrustHosts::at(['^example\.com$'], subdomains: false); + $this->withoutExceptionHandling(); + $this->expectException(SuspiciousOperationException::class); + + $this->call('GET', 'http://evil.com/host'); + } + + public function testDynamicTrustHostsClosureRunsPerRequest() + { + AlwaysTrustHosts::at(fn () => ['^' . preg_quote(request()->headers->get('HOST'), '/') . '$'], subdomains: false); + + $this->call('GET', 'http://a.example.com/host') + ->assertOk() + ->assertContent('a.example.com'); + + $this->call('GET', 'http://b.example.com/host') + ->assertOk() + ->assertContent('b.example.com'); + } +} + +class AlwaysTrustHosts extends TrustHosts +{ + protected function shouldSpecifyTrustedHosts(): bool + { + return true; + } +} diff --git a/tests/Integration/Http/Middleware/TrustProxiesTest.php b/tests/Integration/Http/Middleware/TrustProxiesTest.php new file mode 100644 index 000000000..a8b9845e2 --- /dev/null +++ b/tests/Integration/Http/Middleware/TrustProxiesTest.php @@ -0,0 +1,131 @@ + $request->ip()) + ->middleware(TrustProxies::class); + + Route::get('/slow-whoami', function (Request $request) { + usleep((int) $request->server->get('HTTP_X_SLEEP_US', 0)); + + return $request->ip(); + })->middleware(TrustProxies::class); + + Route::get('/form-request-ip', fn (TrustProxiesFormRequest $request) => [ + 'ip' => TrustProxiesFormRequest::$ip, + 'routeIp' => $request->ip(), + ])->middleware(TrustProxies::class); + } + + protected function tearDown(): void + { + TrustProxies::flushState(); + TrustProxiesFormRequest::$ip = null; + + parent::tearDown(); + } + + public function testIpReflectsXForwardedForFromTrustedProxy() + { + TrustProxies::at(['10.0.0.1']); + + $this->call('GET', '/whoami', server: [ + 'REMOTE_ADDR' => '10.0.0.1', + 'HTTP_X_FORWARDED_FOR' => '9.9.9.9', + ])->assertOk()->assertContent('9.9.9.9'); + } + + public function testIpIgnoresXForwardedForWhenProxyUntrusted() + { + TrustProxies::at(['10.0.0.1']); + + $this->call('GET', '/whoami', server: [ + 'REMOTE_ADDR' => '30.0.0.1', + 'HTTP_X_FORWARDED_FOR' => '9.9.9.9', + ])->assertOk()->assertContent('30.0.0.1'); + } + + public function testWildcardTrustsCallingProxy() + { + TrustProxies::at('*'); + + $this->call('GET', '/whoami', server: [ + 'REMOTE_ADDR' => '10.0.0.1', + 'HTTP_X_FORWARDED_FOR' => '9.9.9.9', + ])->assertOk()->assertContent('9.9.9.9'); + } + + public function testFormRequestSeesForwardedIpAfterTrustProxiesMiddleware() + { + TrustProxies::at(['10.0.0.1']); + + $response = $this->call('GET', '/form-request-ip', server: [ + 'REMOTE_ADDR' => '10.0.0.1', + 'HTTP_X_FORWARDED_FOR' => '9.9.9.9', + ]); + + $response->assertOk(); + $this->assertSame('9.9.9.9', $response->json('ip')); + $this->assertSame('9.9.9.9', $response->json('routeIp')); + } + + public function testConcurrentRequestsThroughMiddlewareKeepTrustedProxyStateIsolated() + { + TrustProxies::at('*'); + + [$responseA, $responseB] = parallel([ + fn () => $this->call('GET', '/slow-whoami', server: [ + 'REMOTE_ADDR' => '10.0.0.1', + 'HTTP_X_FORWARDED_FOR' => '9.9.9.9', + 'HTTP_X_SLEEP_US' => '10000', + ]), + function () { + usleep(2500); + + return $this->call('GET', '/slow-whoami', server: [ + 'REMOTE_ADDR' => '20.0.0.1', + 'HTTP_X_FORWARDED_FOR' => '8.8.8.8', + ]); + }, + ]); + + $responseA->assertOk()->assertContent('9.9.9.9'); + $responseB->assertOk()->assertContent('8.8.8.8'); + } +} + +class TrustProxiesFormRequest extends FormRequest +{ + public static ?string $ip = null; + + public function authorize(): bool + { + static::$ip = $this->ip(); + + return true; + } + + public function rules(): array + { + return []; + } +} diff --git a/tests/Routing/RoutingUrlGeneratorTest.php b/tests/Routing/RoutingUrlGeneratorTest.php index 15e5942c5..ff7a096e6 100755 --- a/tests/Routing/RoutingUrlGeneratorTest.php +++ b/tests/Routing/RoutingUrlGeneratorTest.php @@ -4,6 +4,7 @@ namespace Hypervel\Tests\Routing\RoutingUrlGeneratorTest; +use Hypervel\Context\RequestContext; use Hypervel\Contracts\Routing\UrlRoutable; use Hypervel\Database\Eloquent\Model; use Hypervel\Http\Request; @@ -542,11 +543,13 @@ public function testHttpsRoutesWithDomains() public function testRoutesWithDomainsThroughProxy() { + $request = Request::create('http://www.foo.com/', 'GET', [], [], [], ['REMOTE_ADDR' => '10.0.0.1', 'HTTP_X_FORWARDED_PORT' => '80']); + RequestContext::set($request); Request::setTrustedProxies(['10.0.0.1'], SymfonyRequest::HEADER_X_FORWARDED_FOR | SymfonyRequest::HEADER_X_FORWARDED_HOST | SymfonyRequest::HEADER_X_FORWARDED_PORT | SymfonyRequest::HEADER_X_FORWARDED_PROTO); $url = new UrlGenerator( $routes = new RouteCollection, - Request::create('http://www.foo.com/', 'GET', [], [], [], ['REMOTE_ADDR' => '10.0.0.1', 'HTTP_X_FORWARDED_PORT' => '80']) + $request ); $route = new Route(['GET'], 'foo/bar', ['as' => 'foo', 'domain' => 'sub.foo.com']);