diff --git a/src/Auth/Middlewares/Authenticated.php b/src/Auth/Middlewares/Authenticated.php index c75c82de..8fec53be 100644 --- a/src/Auth/Middlewares/Authenticated.php +++ b/src/Auth/Middlewares/Authenticated.php @@ -16,7 +16,7 @@ use Phenix\Facades\Config; use Phenix\Facades\Event; use Phenix\Http\Constants\HttpStatus; -use Phenix\Http\IpAddress; +use Phenix\Http\Ip; use Phenix\Http\Request as HttpRequest; class Authenticated implements Middleware @@ -34,7 +34,7 @@ public function handleRequest(Request $request, RequestHandler $next): Response /** @var AuthenticationManager $auth */ $auth = App::make(AuthenticationManager::class); - $clientIp = IpAddress::hash($request); + $clientIp = Ip::make($request)->hash(); if (! $token || ! $auth->validate($token)) { Event::emitAsync(new FailedTokenValidation( diff --git a/src/Auth/Middlewares/TokenRateLimit.php b/src/Auth/Middlewares/TokenRateLimit.php index 1e423277..86e59a75 100644 --- a/src/Auth/Middlewares/TokenRateLimit.php +++ b/src/Auth/Middlewares/TokenRateLimit.php @@ -12,7 +12,7 @@ use Phenix\Auth\AuthenticationManager; use Phenix\Facades\Config; use Phenix\Http\Constants\HttpStatus; -use Phenix\Http\IpAddress; +use Phenix\Http\Ip; use function str_starts_with; @@ -29,7 +29,7 @@ public function handleRequest(Request $request, RequestHandler $next): Response /** @var AuthenticationManager $auth */ $auth = App::make(AuthenticationManager::class); - $clientIp = IpAddress::hash($request); + $clientIp = Ip::make($request)->hash(); $attemptLimit = (int) (Config::get('auth.tokens.rate_limit.attempts', 5)); $windowSeconds = (int) (Config::get('auth.tokens.rate_limit.window', 300)); diff --git a/src/Cache/RateLimit/Middlewares/RateLimiter.php b/src/Cache/RateLimit/Middlewares/RateLimiter.php index 3e9a3964..d39433a1 100644 --- a/src/Cache/RateLimit/Middlewares/RateLimiter.php +++ b/src/Cache/RateLimit/Middlewares/RateLimiter.php @@ -12,7 +12,7 @@ use Phenix\Cache\RateLimit\RateLimitManager; use Phenix\Facades\Config; use Phenix\Http\Constants\HttpStatus; -use Phenix\Http\IpAddress; +use Phenix\Http\Ip; class RateLimiter implements Middleware { @@ -29,7 +29,7 @@ public function handleRequest(Request $request, RequestHandler $next): Response return $next->handleRequest($request); } - $clientIp = IpAddress::hash($request); + $clientIp = Ip::make($request)->hash(); $current = $this->rateLimiter->increment($clientIp); $perMinuteLimit = (int) Config::get('cache.rate_limit.per_minute', 60); diff --git a/src/Http/Ip.php b/src/Http/Ip.php new file mode 100644 index 00000000..22a031cf --- /dev/null +++ b/src/Http/Ip.php @@ -0,0 +1,102 @@ +address = $request->getClient()->getRemoteAddress()->toString(); + + if ($forwardingHeader = $request->getHeader('X-Forwarded-For')) { + $parts = array_map(static fn ($v) => trim($v), explode(',', $forwardingHeader)); + $this->forwardingAddresses = $parts; + } + } + + public static function make(Request $request): self + { + $ip = new self($request); + $ip->parse(); + + return $ip; + } + + public function address(): string + { + return $this->address; + } + + public function host(): string + { + return $this->host; + } + + public function port(): int|null + { + return $this->port; + } + + public function isForwarded(): bool + { + return ! empty($this->forwardingAddresses); + } + + public function forwardingAddresses(): array + { + return $this->forwardingAddresses; + } + + public function hash(): string + { + return hash('sha256', $this->host); + } + + protected function parse(): void + { + $address = trim($this->address); + + if (preg_match('/^\[(?[^\]]+)\](?::(?\d+))?$/', $address, $m) === 1) { + $this->host = $m['addr']; + $this->port = isset($m['port']) ? (int) $m['port'] : null; + + return; + } + + if (filter_var($address, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) { + $this->host = $address; + $this->port = null; + + return; + } + + if (str_contains($address, ':')) { + [$maybeHost, $maybePort] = explode(':', $address, 2); + + if ( + filter_var($maybeHost, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) || + filter_var($maybeHost, FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME) + ) { + $this->host = $maybeHost; + $this->port = is_numeric($maybePort) ? (int) $maybePort : null; + + return; + } + } + + $this->host = $address; + $this->port = null; + } +} diff --git a/src/Http/IpAddress.php b/src/Http/IpAddress.php deleted file mode 100644 index 5598ca96..00000000 --- a/src/Http/IpAddress.php +++ /dev/null @@ -1,66 +0,0 @@ -getHeader('X-Forwarded-For'); - - if ($xff && $ip = self::getFromHeader($xff)) { - return $ip; - } - - return (string) $request->getClient()->getRemoteAddress(); - } - - public static function hash(Request $request): string - { - $ip = self::parse($request); - - $normalized = self::normalize($ip); - - return hash('sha256', $normalized); - } - - private static function getFromHeader(string $header): string - { - $parts = explode(',', $header)[0] ?? ''; - - return trim($parts); - } - - private static function normalize(string $ip): string - { - if (preg_match('/^\[(?[^\]]+)\](?::\d+)?$/', $ip, $m) === 1) { - return $m['addr']; - } - - $normalized = $ip; - - if (filter_var($normalized, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) { - return $normalized; - } - - if (str_contains($normalized, ':')) { - $parts = explode(':', $normalized); - $maybeIpv4 = $parts[0]; - - if (filter_var($maybeIpv4, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) { - $normalized = $maybeIpv4; - } - } - - return $normalized; - } -} diff --git a/src/Http/Request.php b/src/Http/Request.php index fcf8be2b..82042965 100644 --- a/src/Http/Request.php +++ b/src/Http/Request.php @@ -147,9 +147,9 @@ public function session(string|null $key = null, array|string|int|null $default return $this->session; } - public function ip(): string|null + public function ip(): Ip { - return IpAddress::parse($this->request); + return Ip::make($this->request); } public function toArray(): array diff --git a/tests/Unit/Http/IpAddressTest.php b/tests/Unit/Http/IpAddressTest.php index 22b2a18c..7e35b3d3 100644 --- a/tests/Unit/Http/IpAddressTest.php +++ b/tests/Unit/Http/IpAddressTest.php @@ -4,19 +4,46 @@ use Amp\Http\Server\Driver\Client; use Amp\Http\Server\Request as ServerRequest; +use Amp\Socket\SocketAddress; +use Amp\Socket\SocketAddressType; use League\Uri\Http; use Phenix\Http\Constants\HttpMethod; -use Phenix\Http\IpAddress; +use Phenix\Http\Ip; use Phenix\Util\URL; it('generate ip hash from request', function (string $ip, $expected): void { $client = $this->createMock(Client::class); + $client->method('getRemoteAddress')->willReturn( + new class ($ip) implements SocketAddress { + public function __construct(private string $address) + { + } + + public function toString(): string + { + return $this->address; + } + + public function getType(): SocketAddressType + { + return SocketAddressType::Internet; + } + + public function __toString(): string + { + return $this->address; + } + } + ); + $uri = Http::new(URL::build('posts/7/comments/22')); $request = new ServerRequest($client, HttpMethod::GET->value, $uri); - $request->setHeader('X-Forwarded-For', $ip); + $ip = Ip::make($request); - expect(IpAddress::hash($request))->toBe($expected); + expect($ip->hash())->toBe($expected); + expect($ip->isForwarded())->toBeFalse(); + expect($ip->forwardingAddresses())->toBe([]); })->with([ ['192.168.1.1', hash('sha256', '192.168.1.1')], ['192.168.1.1:8080', hash('sha256', '192.168.1.1')], @@ -25,7 +52,204 @@ ['[2001:db8::1]:443', hash('sha256', '2001:db8::1')], ['::1', hash('sha256', '::1')], ['2001:db8::7334', hash('sha256', '2001:db8::7334')], - ['203.0.113.1, 198.51.100.2', hash('sha256', '203.0.113.1')], - [' 192.168.0.1:8080 , 10.0.0.2', hash('sha256', '192.168.0.1')], + ['203.0.113.1', hash('sha256', '203.0.113.1')], + [' 192.168.0.1:8080', hash('sha256', '192.168.0.1')], ['::ffff:192.168.0.1', hash('sha256', '::ffff:192.168.0.1')], ]); + +it('parses host and port from remote address IPv6 bracket with port', function (): void { + $client = $this->createMock(Client::class); + $client->method('getRemoteAddress')->willReturn( + new class ('[2001:db8::1]:443') implements SocketAddress { + public function __construct(private string $address) + { + } + + public function toString(): string + { + return $this->address; + } + + public function getType(): SocketAddressType + { + return SocketAddressType::Internet; + } + + public function __toString(): string + { + return $this->address; + } + } + ); + + $request = new ServerRequest($client, HttpMethod::GET->value, Http::new(URL::build('/'))); + $ip = Ip::make($request); + + expect($ip->address())->toBe('[2001:db8::1]:443'); + expect($ip->host())->toBe('2001:db8::1'); + expect($ip->port())->toBe(443); + expect($ip->isForwarded())->toBeFalse(); + expect($ip->forwardingAddresses())->toBe([]); +}); + +it('parses host only from raw IPv6 without port', function (): void { + $client = $this->createMock(Client::class); + $client->method('getRemoteAddress')->willReturn( + new class ('2001:db8::2') implements SocketAddress { + public function __construct(private string $address) + { + } + + public function toString(): string + { + return $this->address; + } + + public function getType(): SocketAddressType + { + return SocketAddressType::Internet; + } + + public function __toString(): string + { + return $this->address; + } + } + ); + + $request = new ServerRequest($client, HttpMethod::GET->value, Http::new(URL::build('/'))); + $ip = Ip::make($request); + + expect($ip->host())->toBe('2001:db8::2'); + expect($ip->port())->toBeNull(); +}); + +it('parses host and port from IPv4 with port', function (): void { + $client = $this->createMock(Client::class); + $client->method('getRemoteAddress')->willReturn( + new class ('192.168.0.1:8080') implements SocketAddress { + public function __construct(private string $address) + { + } + + public function toString(): string + { + return $this->address; + } + + public function getType(): SocketAddressType + { + return SocketAddressType::Internet; + } + + public function __toString(): string + { + return $this->address; + } + } + ); + + $request = new ServerRequest($client, HttpMethod::GET->value, Http::new(URL::build('/'))); + $ip = Ip::make($request); + + expect($ip->host())->toBe('192.168.0.1'); + expect($ip->port())->toBe(8080); +}); + +it('parses host only from hostname with port', function (): void { + $client = $this->createMock(Client::class); + $client->method('getRemoteAddress')->willReturn( + new class ('localhost:3000') implements SocketAddress { + public function __construct(private string $address) + { + } + + public function toString(): string + { + return $this->address; + } + + public function getType(): SocketAddressType + { + return SocketAddressType::Internet; + } + + public function __toString(): string + { + return $this->address; + } + } + ); + + $request = new ServerRequest($client, HttpMethod::GET->value, Http::new(URL::build('/'))); + $ip = Ip::make($request); + + expect($ip->host())->toBe('localhost'); + expect($ip->port())->toBe(3000); +}); + +it('parses host only from hostname without port', function (): void { + $client = $this->createMock(Client::class); + $client->method('getRemoteAddress')->willReturn( + new class ('example.com') implements SocketAddress { + public function __construct(private string $address) + { + } + + public function toString(): string + { + return $this->address; + } + + public function getType(): SocketAddressType + { + return SocketAddressType::Internet; + } + + public function __toString(): string + { + return $this->address; + } + } + ); + + $request = new ServerRequest($client, HttpMethod::GET->value, Http::new(URL::build('/'))); + $ip = Ip::make($request); + + expect($ip->host())->toBe('example.com'); + expect($ip->port())->toBeNull(); +}); + +it('sets forwarding info from X-Forwarded-For header', function (): void { + $client = $this->createMock(Client::class); + $client->method('getRemoteAddress')->willReturn( + new class ('10.0.0.1:1234') implements SocketAddress { + public function __construct(private string $address) + { + } + + public function toString(): string + { + return $this->address; + } + + public function getType(): SocketAddressType + { + return SocketAddressType::Internet; + } + + public function __toString(): string + { + return $this->address; + } + } + ); + + $request = new ServerRequest($client, HttpMethod::GET->value, Http::new(URL::build('/'))); + $request->setHeader('X-Forwarded-For', '203.0.113.1, 198.51.100.2'); + + $ip = Ip::make($request); + + expect($ip->isForwarded())->toBeTrue(); + expect($ip->forwardingAddresses())->toBe(['203.0.113.1', '198.51.100.2']); +}); diff --git a/tests/Unit/Http/RequestTest.php b/tests/Unit/Http/RequestTest.php index f26f67a2..126e55e5 100644 --- a/tests/Unit/Http/RequestTest.php +++ b/tests/Unit/Http/RequestTest.php @@ -11,6 +11,7 @@ use Amp\Http\Server\Trailers; use League\Uri\Http; use Phenix\Http\Constants\HttpMethod; +use Phenix\Http\Ip; use Phenix\Http\Request; use Phenix\Util\URL; use Psr\Http\Message\UriInterface; @@ -25,7 +26,7 @@ $formRequest = new Request($request); - expect($formRequest->ip())->toBeEmpty(); + expect($formRequest->ip())->toBeInstanceOf(Ip::class); expect($formRequest->route('post'))->toBe('7'); expect($formRequest->route('comment'))->toBe('22'); expect($formRequest->route()->integer('post'))->toBe(7);