From 31d9f2c6d4769320c5b88442454ccce694e94255 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Fri, 7 Nov 2025 15:33:49 -0500 Subject: [PATCH 01/29] refactor: rename class --- src/App.php | 4 ++-- .../{SessionMiddleware.php => SessionMiddlewareFactory.php} | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) rename src/Session/{SessionMiddleware.php => SessionMiddlewareFactory.php} (97%) diff --git a/src/App.php b/src/App.php index 50b34b1a..82bea80e 100644 --- a/src/App.php +++ b/src/App.php @@ -22,7 +22,7 @@ use Phenix\Facades\Route; use Phenix\Logging\LoggerFactory; use Phenix\Runtime\Log; -use Phenix\Session\SessionMiddleware; +use Phenix\Session\SessionMiddlewareFactory; class App implements AppContract, Makeable { @@ -169,7 +169,7 @@ private function setRouter(): void /** @var array $globalMiddlewares */ $globalMiddlewares = array_map(fn (string $middleware) => new $middleware(), $middlewares['global']); - $globalMiddlewares[] = SessionMiddleware::make($this->host); + $globalMiddlewares[] = SessionMiddlewareFactory::make($this->host); $this->router = Middleware\stackMiddleware($router, ...$globalMiddlewares); } diff --git a/src/Session/SessionMiddleware.php b/src/Session/SessionMiddlewareFactory.php similarity index 97% rename from src/Session/SessionMiddleware.php rename to src/Session/SessionMiddlewareFactory.php index ac79211b..7999cdd4 100644 --- a/src/Session/SessionMiddleware.php +++ b/src/Session/SessionMiddlewareFactory.php @@ -12,7 +12,7 @@ use Phenix\Database\Constants\Connection; use Phenix\Session\Constants\Driver; -class SessionMiddleware +class SessionMiddlewareFactory { public static function make(string $host): Middleware { From a47423bbd211aa34d869760519294bdde91df601 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Fri, 7 Nov 2025 19:28:18 -0500 Subject: [PATCH 02/29] refactor: ensure middleware is only instantiated if item is a string --- src/Routing/RouteBuilder.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Routing/RouteBuilder.php b/src/Routing/RouteBuilder.php index 54278fdf..fbadb5b2 100644 --- a/src/Routing/RouteBuilder.php +++ b/src/Routing/RouteBuilder.php @@ -9,6 +9,8 @@ use Phenix\Http\Constants\HttpMethod; use Phenix\Http\Requests\ClosureRequestHandler; +use function is_string; + class RouteBuilder implements Arrayable { protected string|null $baseName = null; @@ -47,7 +49,7 @@ public function name(string $name): self public function middleware(array|string $middleware): self { foreach ((array) $middleware as $item) { - $this->pushMiddleware(new $item()); + $this->pushMiddleware(is_string($item) ? new $item() : $item); } return $this; From aa33bf689c6e18bf22c8d9749fd631313a438de7 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Mon, 10 Nov 2025 12:13:37 -0500 Subject: [PATCH 03/29] feat: add kelunik/rate-limit dependency to composer.json --- composer.json | 1 + 1 file changed, 1 insertion(+) diff --git a/composer.json b/composer.json index eaae33d4..c9c7782d 100644 --- a/composer.json +++ b/composer.json @@ -38,6 +38,7 @@ "amphp/socket": "^2.1.0", "egulias/email-validator": "^4.0", "fakerphp/faker": "^1.23", + "kelunik/rate-limit": "^3.0", "league/container": "^4.2", "nesbot/carbon": "^3.0", "phenixphp/http-cors": "^0.1.0", From 5f6431d62f349ffc076d45dc2a10de272f0babe1 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Wed, 3 Dec 2025 18:13:10 -0500 Subject: [PATCH 04/29] feat: basic rate limiter --- src/Cache/CacheServiceProvider.php | 7 ++ src/Cache/RateLimit/Config.php | 37 ++++++ src/Cache/RateLimit/LocalRateLimit.php | 75 ++++++++++++ .../RateLimit/Middlewares/RateLimiter.php | 108 ++++++++++++++++++ src/Cache/RateLimit/RateLimitFactory.php | 36 ++++++ src/Cache/RateLimit/RateLimitManager.php | 55 +++++++++ src/Http/IpAddress.php | 8 +- tests/fixtures/application/config/app.php | 4 +- tests/fixtures/application/config/cache.php | 7 ++ 9 files changed, 329 insertions(+), 8 deletions(-) create mode 100644 src/Cache/RateLimit/Config.php create mode 100644 src/Cache/RateLimit/LocalRateLimit.php create mode 100644 src/Cache/RateLimit/Middlewares/RateLimiter.php create mode 100644 src/Cache/RateLimit/RateLimitFactory.php create mode 100644 src/Cache/RateLimit/RateLimitManager.php diff --git a/src/Cache/CacheServiceProvider.php b/src/Cache/CacheServiceProvider.php index 7a44a50c..1d9f0703 100644 --- a/src/Cache/CacheServiceProvider.php +++ b/src/Cache/CacheServiceProvider.php @@ -6,6 +6,7 @@ use Phenix\Cache\Console\CacheClear; use Phenix\Providers\ServiceProvider; +use Phenix\Cache\RateLimit\RateLimitManager; class CacheServiceProvider extends ServiceProvider { @@ -13,6 +14,7 @@ public function provides(string $id): bool { $this->provided = [ CacheManager::class, + RateLimitManager::class, ]; return $this->isProvided($id); @@ -22,6 +24,11 @@ public function register(): void { $this->bind(CacheManager::class) ->setShared(true); + + $this->bind( + RateLimitManager::class, + fn (): RateLimitManager => new RateLimitManager() + )->setShared(true); } public function boot(): void diff --git a/src/Cache/RateLimit/Config.php b/src/Cache/RateLimit/Config.php new file mode 100644 index 00000000..cca8cc9c --- /dev/null +++ b/src/Cache/RateLimit/Config.php @@ -0,0 +1,37 @@ +config = Configuration::get('cache.rate_limit', []); + } + + public function default(): string + { + return $this->config['driver'] ?? 'local'; + } + + public function perMinute(): int + { + return (int) ($this->config['per_minute'] ?? 60); + } + + public function connection(): string + { + return $this->config['connection'] ?? 'default'; + } + + public function ttl(): int + { + return 60; + } +} diff --git a/src/Cache/RateLimit/LocalRateLimit.php b/src/Cache/RateLimit/LocalRateLimit.php new file mode 100644 index 00000000..eeb0f928 --- /dev/null +++ b/src/Cache/RateLimit/LocalRateLimit.php @@ -0,0 +1,75 @@ +store = $store; + $this->ttl = $ttl; + } + + public function get(string $id): int + { + $data = $this->store->get($id); + + if ($data === null) { + return 0; + } + + return (int) ($data['count'] ?? 0); + } + + public function increment(string $id): int + { + $currentTime = time(); + $data = $this->store->get($id); + + if ($data === null) { + $data = [ + 'count' => 1, + 'expires_at' => $currentTime + $this->ttl, + ]; + + $this->store->set($id, $data, Date::now()->addSeconds($this->ttl)); + + return 1; + } + + $data['count'] = ((int) ($data['count'] ?? 0)) + 1; + $data['expires_at'] = $currentTime + $this->ttl; + + $this->store->set($id, $data, Date::now()->addSeconds($this->ttl)); + + return (int) $data['count']; + } + + public function getTtl(string $id): int + { + $data = $this->store->get($id); + + if ($data === null || ! isset($data['expires_at'])) { + return $this->ttl; + } + + $ttl = ((int) $data['expires_at']) - time(); + + return max(0, $ttl); + } + + public function clear(): void + { + $this->store->clear(); + } +} diff --git a/src/Cache/RateLimit/Middlewares/RateLimiter.php b/src/Cache/RateLimit/Middlewares/RateLimiter.php new file mode 100644 index 00000000..cfe33c9f --- /dev/null +++ b/src/Cache/RateLimit/Middlewares/RateLimiter.php @@ -0,0 +1,108 @@ +handleRequest($request); + } + + /** @var RateLimitManager $rateLimiter */ + $rateLimiter = App::make(RateLimitManager::class); + + $key = $this->resolveKey($request) ?? 'guest'; + $current = $rateLimiter->increment($key); + + if ($current > Config::get('cache.rate_limit.per_minute', 60)) { + return $this->createRateLimitExceededResponse($key); + } + + $response = $next->handleRequest($request); + + return $this->addRateLimitHeaders($rateLimiter, $request, $response, $current, $key); + } + + protected function resolveKey(Request $request): string|null + { + $user = $this->user($request); + + if ($user) { + return (string) $user->getKey(); + } + + $ip = IpAddress::parse($request); + + return $ip !== null ? $ip : $this->getSessionId($request); + } + + protected function user(Request $request): User|null + { + $key = Config::get('auth.users.model', User::class); + + return $request->hasAttribute($key) ? $request->getAttribute($key) : null; + } + + protected function getSessionId(Request $request): string|null + { + $session = null; + + if ($request->hasAttribute(ServerSession::class)) { + $session = new Session($request->getAttribute(ServerSession::class)); + } + + return $session?->getId(); + } + + protected function createRateLimitExceededResponse(string $key): Response + { + $retryAfter = $this->rateLimit->getTtl($key); + + return new Response( + status: HttpStatus::TOO_MANY_REQUESTS->value, + headers: [ + 'retry-after' => (string) $retryAfter, + 'content-type' => 'application/json', + ], + body: json_encode([ + 'error' => 'Too Many Requests', + 'message' => 'Rate limit exceeded. Please try again later.', + 'retry_after' => $retryAfter, + ]) + ); + } + + protected function addRateLimitHeaders(RateLimitManager $rateLimiter, Request $request, Response $response, int $current, string $key): Response + { + $remaining = max(0, Config::get('cache.rate_limit.per_minute', 60) - $current); + $resetTime = time() + $rateLimiter->getTtl($key); + + if ($this->user($request)) { + $response->addHeader('x-ratelimit-limit', (string) Config::get('cache.rate_limit.per_minute', 60)); + $response->addHeader('x-ratelimit-remaining', (string) $remaining); + $response->addHeader('x-ratelimit-reset', (string) $resetTime); + $response->addHeader('x-ratelimit-reset-after', (string) $rateLimiter->getTtl($key)); + } + + return $response; + } +} diff --git a/src/Cache/RateLimit/RateLimitFactory.php b/src/Cache/RateLimit/RateLimitFactory.php new file mode 100644 index 00000000..e3f8f57d --- /dev/null +++ b/src/Cache/RateLimit/RateLimitFactory.php @@ -0,0 +1,36 @@ +client(); + + return new RedisRateLimit($clientWrapper->getClient(), $ttl); + } + + public static function local(int $ttl): RateLimit + { + /** @var LocalStore $store */ + $store = Cache::store(Store::LOCAL); + + return new LocalRateLimit($store, $ttl); + } + + public static function withPrefix(RateLimit $rateLimit, string $prefix): RateLimit + { + return new PrefixRateLimit($rateLimit, $prefix); + } +} diff --git a/src/Cache/RateLimit/RateLimitManager.php b/src/Cache/RateLimit/RateLimitManager.php new file mode 100644 index 00000000..534d8804 --- /dev/null +++ b/src/Cache/RateLimit/RateLimitManager.php @@ -0,0 +1,55 @@ +config = $config ?? new Config(); + } + + public function get(string $key): int + { + return $this->limiter()->get($key); + } + + public function increment(string $key): int + { + return $this->limiter()->increment($key); + } + + public function getTtl(string $key): int + { + return $this->limiter()->getTtl($key); + } + + public function prefixed(string $prefix): self + { + $this->rateLimiters[$this->config->default()] = RateLimitFactory::withPrefix($this->limiter(), $prefix); + + return $this; + } + + protected function limiter(): RateLimit + { + return $this->rateLimiters[$this->config->default()] ??= $this->resolveDriver(); + } + + protected function resolveDriver(): RateLimit + { + return match ($this->config->default()) { + 'redis' => RateLimitFactory::redis($this->config->ttl(), $this->config->connection()), + 'local' => RateLimitFactory::local($this->config->ttl()), + default => RateLimitFactory::local($this->config->ttl()), + }; + } +} diff --git a/src/Http/IpAddress.php b/src/Http/IpAddress.php index 7fef34df..23de547f 100644 --- a/src/Http/IpAddress.php +++ b/src/Http/IpAddress.php @@ -21,13 +21,7 @@ public static function parse(Request $request): string|null return $ip; } - $ip = (string) $request->getClient()->getRemoteAddress(); - - if ($ip !== '') { - return explode(':', $ip)[0] ?? null; - } - - return null; + return (string) $request->getClient()->getRemoteAddress() ?? null; } private static function getFromHeader(string $header): string diff --git a/tests/fixtures/application/config/app.php b/tests/fixtures/application/config/app.php index 91f68d83..0d6e2ff2 100644 --- a/tests/fixtures/application/config/app.php +++ b/tests/fixtures/application/config/app.php @@ -17,7 +17,9 @@ \Phenix\Http\Middlewares\HandleCors::class, \Phenix\Http\Middlewares\TokenRateLimit::class, ], - 'router' => [], + 'router' => [ + \Phenix\Cache\RateLimit\Middlewares\RateLimiter::class, + ], ], 'providers' => [ \Phenix\Console\CommandsServiceProvider::class, diff --git a/tests/fixtures/application/config/cache.php b/tests/fixtures/application/config/cache.php index 321ebb0b..55d77c50 100644 --- a/tests/fixtures/application/config/cache.php +++ b/tests/fixtures/application/config/cache.php @@ -45,4 +45,11 @@ | unless a specific TTL is provided when setting a cache item. */ 'ttl' => env('CACHE_TTL', static fn (): int => 60), + + 'rate_limit' => [ + 'enabled' => env('RATE_LIMIT_ENABLED', static fn (): bool => true), + 'driver' => env('RATE_LIMIT_DRIVER', static fn (): string => 'local'), + 'per_minute' => env('RATE_LIMIT_PER_MINUTE', static fn (): int => 60), + 'connection' => env('RATE_LIMIT_REDIS_CONNECTION', static fn (): string => 'default'), + ], ]; From 9c81ead69da5ad173c1c83793575b8470a03d22a Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Wed, 3 Dec 2025 18:19:28 -0500 Subject: [PATCH 05/29] fix: ensure correct expiration handling in rate limit increment --- src/Cache/RateLimit/LocalRateLimit.php | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/Cache/RateLimit/LocalRateLimit.php b/src/Cache/RateLimit/LocalRateLimit.php index eeb0f928..af54af7a 100644 --- a/src/Cache/RateLimit/LocalRateLimit.php +++ b/src/Cache/RateLimit/LocalRateLimit.php @@ -48,9 +48,13 @@ public function increment(string $id): int } $data['count'] = ((int) ($data['count'] ?? 0)) + 1; - $data['expires_at'] = $currentTime + $this->ttl; - $this->store->set($id, $data, Date::now()->addSeconds($this->ttl)); + if (! isset($data['expires_at'])) { + $data['expires_at'] = $currentTime + $this->ttl; + } + + $remainingTtl = max(0, ((int) $data['expires_at']) - $currentTime); + $this->store->set($id, $data, Date::now()->addSeconds($remainingTtl)); return (int) $data['count']; } From 93addf964d4bdd82b37d7da7cff6a4b705531091 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Wed, 3 Dec 2025 18:19:35 -0500 Subject: [PATCH 06/29] fix: correct parameters for rate limit exceeded response handling --- src/Cache/RateLimit/Middlewares/RateLimiter.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Cache/RateLimit/Middlewares/RateLimiter.php b/src/Cache/RateLimit/Middlewares/RateLimiter.php index cfe33c9f..361a30aa 100644 --- a/src/Cache/RateLimit/Middlewares/RateLimiter.php +++ b/src/Cache/RateLimit/Middlewares/RateLimiter.php @@ -34,7 +34,7 @@ public function handleRequest(Request $request, RequestHandler $next): Response $current = $rateLimiter->increment($key); if ($current > Config::get('cache.rate_limit.per_minute', 60)) { - return $this->createRateLimitExceededResponse($key); + return $this->createRateLimitExceededResponse($rateLimiter, $key); } $response = $next->handleRequest($request); @@ -73,9 +73,9 @@ protected function getSessionId(Request $request): string|null return $session?->getId(); } - protected function createRateLimitExceededResponse(string $key): Response + protected function createRateLimitExceededResponse(RateLimitManager $rateLimiter, string $key): Response { - $retryAfter = $this->rateLimit->getTtl($key); + $retryAfter = $rateLimiter->getTtl($key); return new Response( status: HttpStatus::TOO_MANY_REQUESTS->value, From bb0cf45f7ac33008e6bd7b8d1840b6741c3e0159 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Wed, 3 Dec 2025 19:39:42 -0500 Subject: [PATCH 07/29] refactor: streamline rate limiter by removing redundant variable declarations --- .../RateLimit/Middlewares/RateLimiter.php | 46 ++++++++++--------- 1 file changed, 24 insertions(+), 22 deletions(-) diff --git a/src/Cache/RateLimit/Middlewares/RateLimiter.php b/src/Cache/RateLimit/Middlewares/RateLimiter.php index 361a30aa..2be44862 100644 --- a/src/Cache/RateLimit/Middlewares/RateLimiter.php +++ b/src/Cache/RateLimit/Middlewares/RateLimiter.php @@ -4,42 +4,44 @@ namespace Phenix\Cache\RateLimit\Middlewares; +use Amp\Http\Server\Middleware; +use Amp\Http\Server\Request; +use Amp\Http\Server\RequestHandler; +use Amp\Http\Server\Response; +use Amp\Http\Server\Session\Session as ServerSession; use Phenix\App; use Phenix\Auth\User; -use Phenix\Http\Session; +use Phenix\Cache\RateLimit\RateLimitManager; use Phenix\Facades\Config; -use Phenix\Http\IpAddress; -use Amp\Http\Server\Request; -use Amp\Http\Server\Response; -use Amp\Http\Server\Middleware; -use Amp\Http\Server\RequestHandler; use Phenix\Http\Constants\HttpStatus; -use Phenix\Cache\RateLimit\RateLimitManager; -use Amp\Http\Server\Session\Session as ServerSession; +use Phenix\Http\IpAddress; +use Phenix\Http\Session; class RateLimiter implements Middleware { - public function handleRequest(Request $request, RequestHandler $next): Response + protected RateLimitManager $rateLimiter; + + public function __construct() { - $config = Config::get('cache.rate_limit', []); + $this->rateLimiter = App::make(RateLimitManager::class); + } - if (!Config::get('cache.rate_limit.enabled', false)) { + public function handleRequest(Request $request, RequestHandler $next): Response + { + if (! Config::get('cache.rate_limit.enabled', false)) { return $next->handleRequest($request); } - /** @var RateLimitManager $rateLimiter */ - $rateLimiter = App::make(RateLimitManager::class); - $key = $this->resolveKey($request) ?? 'guest'; - $current = $rateLimiter->increment($key); + $current = $this->rateLimiter->increment($key); if ($current > Config::get('cache.rate_limit.per_minute', 60)) { - return $this->createRateLimitExceededResponse($rateLimiter, $key); + return $this->createRateLimitExceededResponse($key); } $response = $next->handleRequest($request); - return $this->addRateLimitHeaders($rateLimiter, $request, $response, $current, $key); + return $this->addRateLimitHeaders($request, $response, $current, $key); } protected function resolveKey(Request $request): string|null @@ -73,9 +75,9 @@ protected function getSessionId(Request $request): string|null return $session?->getId(); } - protected function createRateLimitExceededResponse(RateLimitManager $rateLimiter, string $key): Response + protected function createRateLimitExceededResponse(string $key): Response { - $retryAfter = $rateLimiter->getTtl($key); + $retryAfter = $this->rateLimiter->getTtl($key); return new Response( status: HttpStatus::TOO_MANY_REQUESTS->value, @@ -91,16 +93,16 @@ protected function createRateLimitExceededResponse(RateLimitManager $rateLimiter ); } - protected function addRateLimitHeaders(RateLimitManager $rateLimiter, Request $request, Response $response, int $current, string $key): Response + protected function addRateLimitHeaders(Request $request, Response $response, int $current, string $key): Response { $remaining = max(0, Config::get('cache.rate_limit.per_minute', 60) - $current); - $resetTime = time() + $rateLimiter->getTtl($key); + $resetTime = time() + $this->rateLimiter->getTtl($key); if ($this->user($request)) { $response->addHeader('x-ratelimit-limit', (string) Config::get('cache.rate_limit.per_minute', 60)); $response->addHeader('x-ratelimit-remaining', (string) $remaining); $response->addHeader('x-ratelimit-reset', (string) $resetTime); - $response->addHeader('x-ratelimit-reset-after', (string) $rateLimiter->getTtl($key)); + $response->addHeader('x-ratelimit-reset-after', (string) $this->rateLimiter->getTtl($key)); } return $response; From c5eeea1ec73f07daa1ca6619a09cd09d31e6e175 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Wed, 3 Dec 2025 19:39:59 -0500 Subject: [PATCH 08/29] style: php cs --- src/Cache/CacheServiceProvider.php | 2 +- src/Cache/RateLimit/RateLimitFactory.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Cache/CacheServiceProvider.php b/src/Cache/CacheServiceProvider.php index 1d9f0703..ab962dcf 100644 --- a/src/Cache/CacheServiceProvider.php +++ b/src/Cache/CacheServiceProvider.php @@ -5,8 +5,8 @@ namespace Phenix\Cache; use Phenix\Cache\Console\CacheClear; -use Phenix\Providers\ServiceProvider; use Phenix\Cache\RateLimit\RateLimitManager; +use Phenix\Providers\ServiceProvider; class CacheServiceProvider extends ServiceProvider { diff --git a/src/Cache/RateLimit/RateLimitFactory.php b/src/Cache/RateLimit/RateLimitFactory.php index e3f8f57d..11f7c20d 100644 --- a/src/Cache/RateLimit/RateLimitFactory.php +++ b/src/Cache/RateLimit/RateLimitFactory.php @@ -4,9 +4,9 @@ namespace Phenix\Cache\RateLimit; +use Kelunik\RateLimit\PrefixRateLimit; use Kelunik\RateLimit\RateLimit; use Kelunik\RateLimit\RedisRateLimit; -use Kelunik\RateLimit\PrefixRateLimit; use Phenix\Cache\Constants\Store; use Phenix\Cache\Stores\LocalStore; use Phenix\Facades\Cache; From f2ef78fe9df39d4214d9546a1a255294fe6ffe67 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Wed, 3 Dec 2025 19:40:15 -0500 Subject: [PATCH 09/29] test: add unit tests for LocalRateLimit and RateLimitManager functionality --- .../Cache/RateLimit/LocalRateLimitTest.php | 57 +++++++++++++++++++ .../Cache/RateLimit/RateLimitManagerTest.php | 24 ++++++++ 2 files changed, 81 insertions(+) create mode 100644 tests/Unit/Cache/RateLimit/LocalRateLimitTest.php create mode 100644 tests/Unit/Cache/RateLimit/RateLimitManagerTest.php diff --git a/tests/Unit/Cache/RateLimit/LocalRateLimitTest.php b/tests/Unit/Cache/RateLimit/LocalRateLimitTest.php new file mode 100644 index 00000000..879c181a --- /dev/null +++ b/tests/Unit/Cache/RateLimit/LocalRateLimitTest.php @@ -0,0 +1,57 @@ +get('test'))->toBe(0); + expect($rateLimit->increment('test'))->toBe(1); + expect($rateLimit->increment('test'))->toBe(2); + expect($rateLimit->get('test'))->toBe(2); +}); + +it('can get time to live for rate limit', function (): void { + $store = new LocalStore(new LocalCache()); + $rateLimit = new LocalRateLimit($store, 60); + + $rateLimit->increment('test'); + + $ttl = $rateLimit->getTtl('test'); + + expect($ttl)->toBeGreaterThan(50); + expect($ttl)->toBeLessThanOrEqual(60); +}); + +it('cleans up expired entries', function (): void { + $store = new LocalStore(new LocalCache()); + $rateLimit = new LocalRateLimit($store, 1); // 1 second TTL + + $rateLimit->increment('test'); + expect($rateLimit->get('test'))->toBe(1); + + sleep(2); // Wait for expiration + + expect($rateLimit->get('test'))->toBe(0); +}); + +it('can reset rate limit entries', function (): void { + $store = new LocalStore(new LocalCache()); + $rateLimit = new LocalRateLimit($store, 60); + + $rateLimit->increment('test1'); + $rateLimit->increment('test2'); + + expect($rateLimit->get('test1'))->toBe(1); + expect($rateLimit->get('test2'))->toBe(1); + + $rateLimit->clear(); + + expect($rateLimit->get('test1'))->toBe(0); + expect($rateLimit->get('test2'))->toBe(0); +}); diff --git a/tests/Unit/Cache/RateLimit/RateLimitManagerTest.php b/tests/Unit/Cache/RateLimit/RateLimitManagerTest.php new file mode 100644 index 00000000..88d7752d --- /dev/null +++ b/tests/Unit/Cache/RateLimit/RateLimitManagerTest.php @@ -0,0 +1,24 @@ +get('unit:test'))->toBe(0); + expect($manager->increment('unit:test'))->toBe(1); + expect($manager->get('unit:test'))->toBe(1); + expect($manager->getTtl('unit:test'))->toBeGreaterThan(0); +}); + +it('can apply prefix to keys', function (): void { + $manager = (new RateLimitManager())->prefixed('api:'); + + $manager->increment('users'); + + $plain = new RateLimitManager(); + + expect($plain->get('users'))->toBe(0); +}); From f46bdd686ce208f0a58d2facba27312335b857d9 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Thu, 4 Dec 2025 15:58:22 -0500 Subject: [PATCH 10/29] refactor: update identifier handling in rate limiter to improve clarity and consistency --- src/Cache/RateLimit/Middlewares/RateLimiter.php | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/Cache/RateLimit/Middlewares/RateLimiter.php b/src/Cache/RateLimit/Middlewares/RateLimiter.php index 2be44862..1605790d 100644 --- a/src/Cache/RateLimit/Middlewares/RateLimiter.php +++ b/src/Cache/RateLimit/Middlewares/RateLimiter.php @@ -32,19 +32,19 @@ public function handleRequest(Request $request, RequestHandler $next): Response return $next->handleRequest($request); } - $key = $this->resolveKey($request) ?? 'guest'; - $current = $this->rateLimiter->increment($key); + $identifier = $this->resolveClientId($request) ?? 'guest'; + $current = $this->rateLimiter->increment($identifier); if ($current > Config::get('cache.rate_limit.per_minute', 60)) { - return $this->createRateLimitExceededResponse($key); + return $this->createRateLimitExceededResponse($identifier); } $response = $next->handleRequest($request); - return $this->addRateLimitHeaders($request, $response, $current, $key); + return $this->addRateLimitHeaders($request, $response, $current, $identifier); } - protected function resolveKey(Request $request): string|null + protected function resolveClientId(Request $request): string|null { $user = $this->user($request); @@ -54,7 +54,7 @@ protected function resolveKey(Request $request): string|null $ip = IpAddress::parse($request); - return $ip !== null ? $ip : $this->getSessionId($request); + return $ip !== null ? parse_url($ip, PHP_URL_HOST) : $this->getSessionId($request); } protected function user(Request $request): User|null From 393b1038e6eb9335f51998cd1b1a61b30c955b76 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Fri, 5 Dec 2025 09:50:00 -0500 Subject: [PATCH 11/29] refactor: enhance rate limiter logic and improve response headers --- .../RateLimit/Middlewares/RateLimiter.php | 71 +++++++------------ src/Http/IpAddress.php | 4 +- tests/Feature/RouteMiddlewareTest.php | 59 +++++++++++++-- 3 files changed, 83 insertions(+), 51 deletions(-) diff --git a/src/Cache/RateLimit/Middlewares/RateLimiter.php b/src/Cache/RateLimit/Middlewares/RateLimiter.php index 1605790d..6756933e 100644 --- a/src/Cache/RateLimit/Middlewares/RateLimiter.php +++ b/src/Cache/RateLimit/Middlewares/RateLimiter.php @@ -8,14 +8,12 @@ use Amp\Http\Server\Request; use Amp\Http\Server\RequestHandler; use Amp\Http\Server\Response; -use Amp\Http\Server\Session\Session as ServerSession; use Phenix\App; -use Phenix\Auth\User; use Phenix\Cache\RateLimit\RateLimitManager; +use Phenix\Crypto\Bin2Base64; use Phenix\Facades\Config; use Phenix\Http\Constants\HttpStatus; use Phenix\Http\IpAddress; -use Phenix\Http\Session; class RateLimiter implements Middleware { @@ -32,52 +30,50 @@ public function handleRequest(Request $request, RequestHandler $next): Response return $next->handleRequest($request); } - $identifier = $this->resolveClientId($request) ?? 'guest'; + $identifier = $this->getIpHash($request) ?? 'guest'; $current = $this->rateLimiter->increment($identifier); - if ($current > Config::get('cache.rate_limit.per_minute', 60)) { - return $this->createRateLimitExceededResponse($identifier); + $perMinuteLimit = Config::get('cache.rate_limit.per_minute', 60); + + if ($current > $perMinuteLimit) { + return $this->rateLimitExceededResponse($identifier); } $response = $next->handleRequest($request); + $remaining = max(0, $perMinuteLimit - $current); + $resetTime = time() + $this->rateLimiter->getTtl($identifier); + + $response->addHeader('x-ratelimit-limit', (string) $perMinuteLimit); + $response->addHeader('x-ratelimit-remaining', (string) $remaining); + $response->addHeader('x-ratelimit-reset', (string) $resetTime); + $response->addHeader('x-ratelimit-reset-after', (string) $this->rateLimiter->getTtl($identifier)); - return $this->addRateLimitHeaders($request, $response, $current, $identifier); + return $response; } - protected function resolveClientId(Request $request): string|null + protected function getIpHash(Request $request): string|null { - $user = $this->user($request); - - if ($user) { - return (string) $user->getKey(); - } - $ip = IpAddress::parse($request); + $host = parse_url($ip, PHP_URL_HOST); - return $ip !== null ? parse_url($ip, PHP_URL_HOST) : $this->getSessionId($request); - } - - protected function user(Request $request): User|null - { - $key = Config::get('auth.users.model', User::class); + if (! $host) { + return null; + } - return $request->hasAttribute($key) ? $request->getAttribute($key) : null; - } + $encodedKey = Config::get('app.key'); - protected function getSessionId(Request $request): string|null - { - $session = null; + if ($encodedKey) { + $decodedKey = Bin2Base64::decode($encodedKey); - if ($request->hasAttribute(ServerSession::class)) { - $session = new Session($request->getAttribute(ServerSession::class)); + return hash_hmac('sha256', $host, $decodedKey); } - return $session?->getId(); + return hash('sha256', $host); } - protected function createRateLimitExceededResponse(string $key): Response + protected function rateLimitExceededResponse(string $identifier): Response { - $retryAfter = $this->rateLimiter->getTtl($key); + $retryAfter = $this->rateLimiter->getTtl($identifier); return new Response( status: HttpStatus::TOO_MANY_REQUESTS->value, @@ -92,19 +88,4 @@ protected function createRateLimitExceededResponse(string $key): Response ]) ); } - - protected function addRateLimitHeaders(Request $request, Response $response, int $current, string $key): Response - { - $remaining = max(0, Config::get('cache.rate_limit.per_minute', 60) - $current); - $resetTime = time() + $this->rateLimiter->getTtl($key); - - if ($this->user($request)) { - $response->addHeader('x-ratelimit-limit', (string) Config::get('cache.rate_limit.per_minute', 60)); - $response->addHeader('x-ratelimit-remaining', (string) $remaining); - $response->addHeader('x-ratelimit-reset', (string) $resetTime); - $response->addHeader('x-ratelimit-reset-after', (string) $this->rateLimiter->getTtl($key)); - } - - return $response; - } } diff --git a/src/Http/IpAddress.php b/src/Http/IpAddress.php index 23de547f..f26f19ad 100644 --- a/src/Http/IpAddress.php +++ b/src/Http/IpAddress.php @@ -13,7 +13,7 @@ private function __construct() // Prevent instantiation } - public static function parse(Request $request): string|null + public static function parse(Request $request): string { $xff = $request->getHeader('X-Forwarded-For'); @@ -21,7 +21,7 @@ public static function parse(Request $request): string|null return $ip; } - return (string) $request->getClient()->getRemoteAddress() ?? null; + return (string) $request->getClient()->getRemoteAddress(); } private static function getFromHeader(string $header): string diff --git a/tests/Feature/RouteMiddlewareTest.php b/tests/Feature/RouteMiddlewareTest.php index b01fdef6..587fa76a 100644 --- a/tests/Feature/RouteMiddlewareTest.php +++ b/tests/Feature/RouteMiddlewareTest.php @@ -2,24 +2,75 @@ declare(strict_types=1); -use Amp\Http\Server\Response; use Phenix\Facades\Config; use Phenix\Facades\Route; +use Phenix\Http\Constants\HttpStatus; +use Phenix\Http\Response; use Tests\Unit\Routing\AcceptJsonResponses; -afterEach(function () { +use function Amp\delay; + +afterEach(function (): void { $this->app->stop(); }); -it('sets a middleware for all routes', function () { +it('sets a middleware for all routes', function (): void { Config::set('app.middlewares.router', [ AcceptJsonResponses::class, ]); - Route::get('/', fn () => new Response(body: 'Hello')); + Route::get('/', fn (): Response => response()->plain('Ok')); $this->app->run(); $this->get(path: '/', headers: ['Accept' => 'text/html']) ->assertNotAcceptable(); }); + +it('skips rate limiting when disabled', function (): void { + Config::set('cache.rate_limit.enabled', false); + Config::set('cache.rate_limit.per_minute', 1); + + Route::get('/', fn (): Response => response()->plain('Ok')); + + $this->app->run(); + + $this->get(path: '/') + ->assertOk(); + + $this->get(path: '/') + ->assertOk(); +}); + +it('returns 429 when rate limit exceeded', function (): void { + Config::set('cache.rate_limit.per_minute', 1); + + Route::get('/', fn (): Response => response()->plain('Ok')); + + $this->app->run(); + + $this->get(path: '/') + ->assertOk(); + + $this->get(path: '/') + ->assertStatusCode(HttpStatus::TOO_MANY_REQUESTS); +}); + +it('resets rate limit after time window', function (): void { + Config::set('cache.rate_limit.per_minute', 1); + + Route::get('/', fn (): Response => response()->plain('Ok')); + + $this->app->run(); + + $this->get(path: '/') + ->assertOk(); + + $this->get(path: '/') + ->assertStatusCode(HttpStatus::TOO_MANY_REQUESTS); + + delay(61); // Wait for the rate limit window to expire + + $this->get(path: '/') + ->assertOk(); +}); From 5adc8ce439f78479c7b3741472d5edc74ca7f0fe Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Fri, 5 Dec 2025 09:50:11 -0500 Subject: [PATCH 12/29] refactor: improve client identifier handling in Authenticated middleware --- src/Http/Middlewares/Authenticated.php | 8 +++++++- src/Http/Middlewares/TokenRateLimit.php | 6 +++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/src/Http/Middlewares/Authenticated.php b/src/Http/Middlewares/Authenticated.php index da6feb16..d6771b02 100644 --- a/src/Http/Middlewares/Authenticated.php +++ b/src/Http/Middlewares/Authenticated.php @@ -23,6 +23,8 @@ class Authenticated implements Middleware { public function handleRequest(Request $request, RequestHandler $next): Response { + dump(__CLASS__ . ' invoked'); + $authorizationHeader = $request->getHeader('Authorization'); if (! $this->hasToken($authorizationHeader)) { @@ -34,7 +36,11 @@ public function handleRequest(Request $request, RequestHandler $next): Response /** @var AuthenticationManager $auth */ $auth = App::make(AuthenticationManager::class); - $clientIdentifier = IpAddress::parse($request) ?? 'unknown'; + $clientIdentifier = 'unknown'; + + if ($ip = IpAddress::parse($request)) { + $clientIdentifier = parse_url($ip, PHP_URL_HOST) ?? 'unknown'; + } if (! $token || ! $auth->validate($token)) { Event::emitAsync(new FailedTokenValidation( diff --git a/src/Http/Middlewares/TokenRateLimit.php b/src/Http/Middlewares/TokenRateLimit.php index 8bc8f94b..e67adf4a 100644 --- a/src/Http/Middlewares/TokenRateLimit.php +++ b/src/Http/Middlewares/TokenRateLimit.php @@ -29,7 +29,11 @@ public function handleRequest(Request $request, RequestHandler $next): Response /** @var AuthenticationManager $auth */ $auth = App::make(AuthenticationManager::class); - $clientIdentifier = IpAddress::parse($request) ?? 'unknown'; + $clientIdentifier = 'unknown'; + + if ($ip = IpAddress::parse($request)) { + $clientIdentifier = parse_url($ip, PHP_URL_HOST) ?? 'unknown'; + } $attemptLimit = (int) (Config::get('auth.tokens.rate_limit.attempts', 5)); $windowSeconds = (int) (Config::get('auth.tokens.rate_limit.window', 300)); From ee88c94974a55d0853ceb231ade0715723ee2fd6 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Fri, 5 Dec 2025 10:13:06 -0500 Subject: [PATCH 13/29] refactor: update client identifier handling to use hashed IP addresses in authentication and rate limiting --- src/Auth/AuthenticationManager.php | 18 +++++++-------- .../RateLimit/Middlewares/RateLimiter.php | 23 +------------------ src/Http/IpAddress.php | 22 ++++++++++++++++++ src/Http/Middlewares/Authenticated.php | 16 +++++-------- src/Http/Middlewares/TokenRateLimit.php | 8 ++----- 5 files changed, 40 insertions(+), 47 deletions(-) diff --git a/src/Auth/AuthenticationManager.php b/src/Auth/AuthenticationManager.php index b965451c..9bfe99c9 100644 --- a/src/Auth/AuthenticationManager.php +++ b/src/Auth/AuthenticationManager.php @@ -60,35 +60,35 @@ public function validate(string $token): bool return true; } - public function increaseAttempts(string $clientIdentifier): void + public function increaseAttempts(string $clientIp): void { - $key = $this->getAttemptKey($clientIdentifier); + $key = $this->getAttemptKey($clientIp); Cache::set( $key, - $this->getAttempts($clientIdentifier) + 1, + $this->getAttempts($clientIp) + 1, Date::now()->addSeconds( (int) (Config::get('auth.tokens.rate_limit.window', 300)) ) ); } - public function getAttempts(string $clientIdentifier): int + public function getAttempts(string $clientIp): int { - $key = $this->getAttemptKey($clientIdentifier); + $key = $this->getAttemptKey($clientIp); return (int) Cache::get($key, fn (): int => 0); } - public function resetAttempts(string $clientIdentifier): void + public function resetAttempts(string $clientIp): void { - $key = $this->getAttemptKey($clientIdentifier); + $key = $this->getAttemptKey($clientIp); Cache::delete($key); } - protected function getAttemptKey(string $clientIdentifier): string + protected function getAttemptKey(string $clientIp): string { - return sprintf('auth:token_attempts:%s', $clientIdentifier); + return sprintf('auth:token_attempts:%s', $clientIp); } } diff --git a/src/Cache/RateLimit/Middlewares/RateLimiter.php b/src/Cache/RateLimit/Middlewares/RateLimiter.php index 6756933e..7feb1c3f 100644 --- a/src/Cache/RateLimit/Middlewares/RateLimiter.php +++ b/src/Cache/RateLimit/Middlewares/RateLimiter.php @@ -10,7 +10,6 @@ use Amp\Http\Server\Response; use Phenix\App; use Phenix\Cache\RateLimit\RateLimitManager; -use Phenix\Crypto\Bin2Base64; use Phenix\Facades\Config; use Phenix\Http\Constants\HttpStatus; use Phenix\Http\IpAddress; @@ -30,7 +29,7 @@ public function handleRequest(Request $request, RequestHandler $next): Response return $next->handleRequest($request); } - $identifier = $this->getIpHash($request) ?? 'guest'; + $identifier = IpAddress::hash($request) ?? 'guest'; $current = $this->rateLimiter->increment($identifier); $perMinuteLimit = Config::get('cache.rate_limit.per_minute', 60); @@ -51,26 +50,6 @@ public function handleRequest(Request $request, RequestHandler $next): Response return $response; } - protected function getIpHash(Request $request): string|null - { - $ip = IpAddress::parse($request); - $host = parse_url($ip, PHP_URL_HOST); - - if (! $host) { - return null; - } - - $encodedKey = Config::get('app.key'); - - if ($encodedKey) { - $decodedKey = Bin2Base64::decode($encodedKey); - - return hash_hmac('sha256', $host, $decodedKey); - } - - return hash('sha256', $host); - } - protected function rateLimitExceededResponse(string $identifier): Response { $retryAfter = $this->rateLimiter->getTtl($identifier); diff --git a/src/Http/IpAddress.php b/src/Http/IpAddress.php index f26f19ad..ce89b4df 100644 --- a/src/Http/IpAddress.php +++ b/src/Http/IpAddress.php @@ -4,7 +4,9 @@ namespace Phenix\Http; +use Phenix\Facades\Config; use Amp\Http\Server\Request; +use Phenix\Crypto\Bin2Base64; final class IpAddress { @@ -24,6 +26,26 @@ public static function parse(Request $request): string return (string) $request->getClient()->getRemoteAddress(); } + public static function hash(Request $request): string + { + $ip = self::parse($request); + $host = parse_url($ip, PHP_URL_HOST); + + if ($host === null) { + return $ip; + } + + $encodedKey = Config::get('app.key'); + + if ($encodedKey) { + $decodedKey = Bin2Base64::decode($encodedKey); + + return hash_hmac('sha256', $host, $decodedKey); + } + + return hash('sha256', $host); + } + private static function getFromHeader(string $header): string { $parts = explode(',', $header)[0] ?? ''; diff --git a/src/Http/Middlewares/Authenticated.php b/src/Http/Middlewares/Authenticated.php index d6771b02..2fa4392a 100644 --- a/src/Http/Middlewares/Authenticated.php +++ b/src/Http/Middlewares/Authenticated.php @@ -36,22 +36,18 @@ public function handleRequest(Request $request, RequestHandler $next): Response /** @var AuthenticationManager $auth */ $auth = App::make(AuthenticationManager::class); - $clientIdentifier = 'unknown'; - - if ($ip = IpAddress::parse($request)) { - $clientIdentifier = parse_url($ip, PHP_URL_HOST) ?? 'unknown'; - } + $clientIp = IpAddress::hash($request); if (! $token || ! $auth->validate($token)) { Event::emitAsync(new FailedTokenValidation( request: new HttpRequest($request), - clientIp: $clientIdentifier, + clientIp: $clientIp, reason: $token ? 'validation_failed' : 'invalid_format', attemptedToken: $token, - attemptCount: $auth->getAttempts($clientIdentifier) + attemptCount: $auth->getAttempts($clientIp) )); - $auth->increaseAttempts($clientIdentifier); + $auth->increaseAttempts($clientIp); return $this->unauthorized(); } @@ -59,10 +55,10 @@ public function handleRequest(Request $request, RequestHandler $next): Response Event::emitAsync(new TokenValidated( token: $auth->user()?->currentAccessToken(), request: new HttpRequest($request), - clientIp: $clientIdentifier + clientIp: $clientIp )); - $auth->resetAttempts($clientIdentifier); + $auth->resetAttempts($clientIp); $request->setAttribute(Config::get('auth.users.model', User::class), $auth->user()); diff --git a/src/Http/Middlewares/TokenRateLimit.php b/src/Http/Middlewares/TokenRateLimit.php index e67adf4a..27edb658 100644 --- a/src/Http/Middlewares/TokenRateLimit.php +++ b/src/Http/Middlewares/TokenRateLimit.php @@ -29,16 +29,12 @@ public function handleRequest(Request $request, RequestHandler $next): Response /** @var AuthenticationManager $auth */ $auth = App::make(AuthenticationManager::class); - $clientIdentifier = 'unknown'; - - if ($ip = IpAddress::parse($request)) { - $clientIdentifier = parse_url($ip, PHP_URL_HOST) ?? 'unknown'; - } + $clientIp = IpAddress::hash($request); $attemptLimit = (int) (Config::get('auth.tokens.rate_limit.attempts', 5)); $windowSeconds = (int) (Config::get('auth.tokens.rate_limit.window', 300)); - if ($auth->getAttempts($clientIdentifier) >= $attemptLimit) { + if ($auth->getAttempts($clientIp) >= $attemptLimit) { return response()->json( content: ['error' => 'Too many token validation attempts'], status: HttpStatus::TOO_MANY_REQUESTS, From 7bb2b32c2a51e7fdee2c8e50b42b5db02490a2d9 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Fri, 5 Dec 2025 10:22:34 -0500 Subject: [PATCH 14/29] refactor: move authenticated middleware from http to auth --- src/{Http => Auth}/Middlewares/Authenticated.php | 4 +--- tests/Feature/AuthenticationTest.php | 2 +- 2 files changed, 2 insertions(+), 4 deletions(-) rename src/{Http => Auth}/Middlewares/Authenticated.php (97%) diff --git a/src/Http/Middlewares/Authenticated.php b/src/Auth/Middlewares/Authenticated.php similarity index 97% rename from src/Http/Middlewares/Authenticated.php rename to src/Auth/Middlewares/Authenticated.php index 2fa4392a..c75c82de 100644 --- a/src/Http/Middlewares/Authenticated.php +++ b/src/Auth/Middlewares/Authenticated.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Phenix\Http\Middlewares; +namespace Phenix\Auth\Middlewares; use Amp\Http\Server\Middleware; use Amp\Http\Server\Request; @@ -23,8 +23,6 @@ class Authenticated implements Middleware { public function handleRequest(Request $request, RequestHandler $next): Response { - dump(__CLASS__ . ' invoked'); - $authorizationHeader = $request->getHeader('Authorization'); if (! $this->hasToken($authorizationHeader)) { diff --git a/tests/Feature/AuthenticationTest.php b/tests/Feature/AuthenticationTest.php index a04324b5..5d9abce1 100644 --- a/tests/Feature/AuthenticationTest.php +++ b/tests/Feature/AuthenticationTest.php @@ -14,7 +14,7 @@ use Phenix\Facades\Event; use Phenix\Facades\Route; use Phenix\Http\Constants\HttpStatus; -use Phenix\Http\Middlewares\Authenticated; +use Phenix\Auth\Middlewares\Authenticated; use Phenix\Http\Request; use Phenix\Http\Response; use Phenix\Util\Date; From e10dbab858c9b117d4631d85f87629b391483136 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Fri, 5 Dec 2025 10:24:52 -0500 Subject: [PATCH 15/29] refactor: move token rate limit from http to auth folder --- src/{Http => Auth}/Middlewares/TokenRateLimit.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename src/{Http => Auth}/Middlewares/TokenRateLimit.php (97%) diff --git a/src/Http/Middlewares/TokenRateLimit.php b/src/Auth/Middlewares/TokenRateLimit.php similarity index 97% rename from src/Http/Middlewares/TokenRateLimit.php rename to src/Auth/Middlewares/TokenRateLimit.php index 27edb658..1e423277 100644 --- a/src/Http/Middlewares/TokenRateLimit.php +++ b/src/Auth/Middlewares/TokenRateLimit.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Phenix\Http\Middlewares; +namespace Phenix\Auth\Middlewares; use Amp\Http\Server\Middleware; use Amp\Http\Server\Request; From 67e7798e382fa4771d4fcabdcfa8e315558b4cee Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Fri, 5 Dec 2025 10:25:00 -0500 Subject: [PATCH 16/29] refactor: reorder middlewares for improved rate limiting structure --- tests/fixtures/application/config/app.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/fixtures/application/config/app.php b/tests/fixtures/application/config/app.php index 0d6e2ff2..bbbca526 100644 --- a/tests/fixtures/application/config/app.php +++ b/tests/fixtures/application/config/app.php @@ -15,10 +15,10 @@ 'middlewares' => [ 'global' => [ \Phenix\Http\Middlewares\HandleCors::class, - \Phenix\Http\Middlewares\TokenRateLimit::class, + \Phenix\Cache\RateLimit\Middlewares\RateLimiter::class, + \Phenix\Auth\Middlewares\TokenRateLimit::class, ], 'router' => [ - \Phenix\Cache\RateLimit\Middlewares\RateLimiter::class, ], ], 'providers' => [ From d9a876b7d3dce4c0dd11eb9c04ab050f0a409644 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Fri, 5 Dec 2025 11:14:46 -0500 Subject: [PATCH 17/29] refactor: unify identifier handling in rate limiting to use client IP --- src/Cache/RateLimit/Middlewares/RateLimiter.php | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/Cache/RateLimit/Middlewares/RateLimiter.php b/src/Cache/RateLimit/Middlewares/RateLimiter.php index 7feb1c3f..6686b091 100644 --- a/src/Cache/RateLimit/Middlewares/RateLimiter.php +++ b/src/Cache/RateLimit/Middlewares/RateLimiter.php @@ -29,23 +29,23 @@ public function handleRequest(Request $request, RequestHandler $next): Response return $next->handleRequest($request); } - $identifier = IpAddress::hash($request) ?? 'guest'; - $current = $this->rateLimiter->increment($identifier); + $clientIp = IpAddress::hash($request); + $current = $this->rateLimiter->increment($clientIp); $perMinuteLimit = Config::get('cache.rate_limit.per_minute', 60); if ($current > $perMinuteLimit) { - return $this->rateLimitExceededResponse($identifier); + return $this->rateLimitExceededResponse($clientIp); } $response = $next->handleRequest($request); $remaining = max(0, $perMinuteLimit - $current); - $resetTime = time() + $this->rateLimiter->getTtl($identifier); + $resetTime = time() + $this->rateLimiter->getTtl($clientIp); $response->addHeader('x-ratelimit-limit', (string) $perMinuteLimit); $response->addHeader('x-ratelimit-remaining', (string) $remaining); $response->addHeader('x-ratelimit-reset', (string) $resetTime); - $response->addHeader('x-ratelimit-reset-after', (string) $this->rateLimiter->getTtl($identifier)); + $response->addHeader('x-ratelimit-reset-after', (string) $this->rateLimiter->getTtl($clientIp)); return $response; } From 6991a3d65500ad539a453b2aa493e33a15b67bec Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Fri, 5 Dec 2025 11:15:01 -0500 Subject: [PATCH 18/29] style: php cs --- src/Http/IpAddress.php | 2 +- tests/Feature/AuthenticationTest.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Http/IpAddress.php b/src/Http/IpAddress.php index ce89b4df..457693ab 100644 --- a/src/Http/IpAddress.php +++ b/src/Http/IpAddress.php @@ -4,9 +4,9 @@ namespace Phenix\Http; -use Phenix\Facades\Config; use Amp\Http\Server\Request; use Phenix\Crypto\Bin2Base64; +use Phenix\Facades\Config; final class IpAddress { diff --git a/tests/Feature/AuthenticationTest.php b/tests/Feature/AuthenticationTest.php index 5d9abce1..752d752e 100644 --- a/tests/Feature/AuthenticationTest.php +++ b/tests/Feature/AuthenticationTest.php @@ -8,13 +8,13 @@ use Phenix\Auth\Events\TokenCreated; use Phenix\Auth\Events\TokenRefreshCompleted; use Phenix\Auth\Events\TokenValidated; +use Phenix\Auth\Middlewares\Authenticated; use Phenix\Auth\PersonalAccessToken; use Phenix\Auth\User; use Phenix\Database\Constants\Connection; use Phenix\Facades\Event; use Phenix\Facades\Route; use Phenix\Http\Constants\HttpStatus; -use Phenix\Auth\Middlewares\Authenticated; use Phenix\Http\Request; use Phenix\Http\Response; use Phenix\Util\Date; From 24a07ebd3d3c8b7bfc0205a3eedd4a58cdfb8bac Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Fri, 5 Dec 2025 11:38:46 -0500 Subject: [PATCH 19/29] refactor: cast per minute limit to integer for consistency --- src/Cache/RateLimit/Middlewares/RateLimiter.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Cache/RateLimit/Middlewares/RateLimiter.php b/src/Cache/RateLimit/Middlewares/RateLimiter.php index 6686b091..3e9a3964 100644 --- a/src/Cache/RateLimit/Middlewares/RateLimiter.php +++ b/src/Cache/RateLimit/Middlewares/RateLimiter.php @@ -32,7 +32,7 @@ public function handleRequest(Request $request, RequestHandler $next): Response $clientIp = IpAddress::hash($request); $current = $this->rateLimiter->increment($clientIp); - $perMinuteLimit = Config::get('cache.rate_limit.per_minute', 60); + $perMinuteLimit = (int) Config::get('cache.rate_limit.per_minute', 60); if ($current > $perMinuteLimit) { return $this->rateLimitExceededResponse($clientIp); From e48afd557a7d684c0c63464d8df69531d0acaa32 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Fri, 5 Dec 2025 11:49:59 -0500 Subject: [PATCH 20/29] refactor: update expectation for IP address in RequestTest to use empty check --- tests/Unit/Http/RequestTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Unit/Http/RequestTest.php b/tests/Unit/Http/RequestTest.php index 25154571..f26f67a2 100644 --- a/tests/Unit/Http/RequestTest.php +++ b/tests/Unit/Http/RequestTest.php @@ -25,7 +25,7 @@ $formRequest = new Request($request); - expect($formRequest->ip())->toBeNull(); + expect($formRequest->ip())->toBeEmpty(); expect($formRequest->route('post'))->toBe('7'); expect($formRequest->route('comment'))->toBe('22'); expect($formRequest->route()->integer('post'))->toBe(7); From a662a98ffdebd00eeb2841b2e8ef366a7a26e2ec Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Fri, 5 Dec 2025 11:50:30 -0500 Subject: [PATCH 21/29] test: increase delay in ParallelQueueTest to ensure task completion --- tests/Unit/Queue/ParallelQueueTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Unit/Queue/ParallelQueueTest.php b/tests/Unit/Queue/ParallelQueueTest.php index 02a22103..492e1be0 100644 --- a/tests/Unit/Queue/ParallelQueueTest.php +++ b/tests/Unit/Queue/ParallelQueueTest.php @@ -283,7 +283,7 @@ $this->assertGreaterThan(0, $parallelQueue->size()); // Wait for tasks to be processed and completed - delay(6.0); // Wait long enough for tasks to complete and cleanup + delay(10.0); // Wait long enough for tasks to complete and cleanup // Verify processing was disabled after all tasks completed $this->assertFalse($parallelQueue->isProcessing()); From 40097f8492139176dfd0e5958b0edabb8b17ac77 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Fri, 5 Dec 2025 14:14:26 -0500 Subject: [PATCH 22/29] tests(feature): move test to dedicated directory --- tests/Feature/Cache/LocalRateLimitTest.php | 62 ++++++++++++++++++++++ tests/Feature/RouteMiddlewareTest.php | 51 ------------------ 2 files changed, 62 insertions(+), 51 deletions(-) create mode 100644 tests/Feature/Cache/LocalRateLimitTest.php diff --git a/tests/Feature/Cache/LocalRateLimitTest.php b/tests/Feature/Cache/LocalRateLimitTest.php new file mode 100644 index 00000000..11c9258e --- /dev/null +++ b/tests/Feature/Cache/LocalRateLimitTest.php @@ -0,0 +1,62 @@ +app->stop(); +}); + +it('skips rate limiting when disabled', function (): void { + Config::set('cache.rate_limit.enabled', false); + Config::set('cache.rate_limit.per_minute', 1); + + Route::get('/', fn (): Response => response()->plain('Ok')); + + $this->app->run(); + + $this->get(path: '/') + ->assertOk(); + + $this->get(path: '/') + ->assertOk(); +}); + +it('returns 429 when rate limit exceeded', function (): void { + Config::set('cache.rate_limit.per_minute', 1); + + Route::get('/', fn (): Response => response()->plain('Ok')); + + $this->app->run(); + + $this->get(path: '/') + ->assertOk(); + + $this->get(path: '/') + ->assertStatusCode(HttpStatus::TOO_MANY_REQUESTS); +}); + +it('resets rate limit after time window', function (): void { + Config::set('cache.rate_limit.per_minute', 1); + + Route::get('/', fn (): Response => response()->plain('Ok')); + + $this->app->run(); + + $this->get(path: '/') + ->assertOk(); + + $this->get(path: '/') + ->assertStatusCode(HttpStatus::TOO_MANY_REQUESTS); + + delay(61); // Wait for the rate limit window to expire + + $this->get(path: '/') + ->assertOk(); +}); diff --git a/tests/Feature/RouteMiddlewareTest.php b/tests/Feature/RouteMiddlewareTest.php index 587fa76a..8e27e642 100644 --- a/tests/Feature/RouteMiddlewareTest.php +++ b/tests/Feature/RouteMiddlewareTest.php @@ -4,12 +4,9 @@ use Phenix\Facades\Config; use Phenix\Facades\Route; -use Phenix\Http\Constants\HttpStatus; use Phenix\Http\Response; use Tests\Unit\Routing\AcceptJsonResponses; -use function Amp\delay; - afterEach(function (): void { $this->app->stop(); }); @@ -26,51 +23,3 @@ $this->get(path: '/', headers: ['Accept' => 'text/html']) ->assertNotAcceptable(); }); - -it('skips rate limiting when disabled', function (): void { - Config::set('cache.rate_limit.enabled', false); - Config::set('cache.rate_limit.per_minute', 1); - - Route::get('/', fn (): Response => response()->plain('Ok')); - - $this->app->run(); - - $this->get(path: '/') - ->assertOk(); - - $this->get(path: '/') - ->assertOk(); -}); - -it('returns 429 when rate limit exceeded', function (): void { - Config::set('cache.rate_limit.per_minute', 1); - - Route::get('/', fn (): Response => response()->plain('Ok')); - - $this->app->run(); - - $this->get(path: '/') - ->assertOk(); - - $this->get(path: '/') - ->assertStatusCode(HttpStatus::TOO_MANY_REQUESTS); -}); - -it('resets rate limit after time window', function (): void { - Config::set('cache.rate_limit.per_minute', 1); - - Route::get('/', fn (): Response => response()->plain('Ok')); - - $this->app->run(); - - $this->get(path: '/') - ->assertOk(); - - $this->get(path: '/') - ->assertStatusCode(HttpStatus::TOO_MANY_REQUESTS); - - delay(61); // Wait for the rate limit window to expire - - $this->get(path: '/') - ->assertOk(); -}); From e664ec514000169a48b0d614a90f3e9a2291ae56 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Fri, 5 Dec 2025 14:21:23 -0500 Subject: [PATCH 23/29] refactor: rename 'driver' to 'store' in RateLimit configuration --- src/Cache/RateLimit/Config.php | 2 +- src/Cache/RateLimit/RateLimitManager.php | 4 ++-- tests/fixtures/application/config/cache.php | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Cache/RateLimit/Config.php b/src/Cache/RateLimit/Config.php index cca8cc9c..01d52df7 100644 --- a/src/Cache/RateLimit/Config.php +++ b/src/Cache/RateLimit/Config.php @@ -17,7 +17,7 @@ public function __construct() public function default(): string { - return $this->config['driver'] ?? 'local'; + return $this->config['store'] ?? 'local'; } public function perMinute(): int diff --git a/src/Cache/RateLimit/RateLimitManager.php b/src/Cache/RateLimit/RateLimitManager.php index 534d8804..93d0138d 100644 --- a/src/Cache/RateLimit/RateLimitManager.php +++ b/src/Cache/RateLimit/RateLimitManager.php @@ -41,10 +41,10 @@ public function prefixed(string $prefix): self protected function limiter(): RateLimit { - return $this->rateLimiters[$this->config->default()] ??= $this->resolveDriver(); + return $this->rateLimiters[$this->config->default()] ??= $this->resolveStore(); } - protected function resolveDriver(): RateLimit + protected function resolveStore(): RateLimit { return match ($this->config->default()) { 'redis' => RateLimitFactory::redis($this->config->ttl(), $this->config->connection()), diff --git a/tests/fixtures/application/config/cache.php b/tests/fixtures/application/config/cache.php index 55d77c50..fa507c98 100644 --- a/tests/fixtures/application/config/cache.php +++ b/tests/fixtures/application/config/cache.php @@ -48,7 +48,7 @@ 'rate_limit' => [ 'enabled' => env('RATE_LIMIT_ENABLED', static fn (): bool => true), - 'driver' => env('RATE_LIMIT_DRIVER', static fn (): string => 'local'), + 'store' => env('RATE_LIMIT_STORE', static fn (): string => 'local'), 'per_minute' => env('RATE_LIMIT_PER_MINUTE', static fn (): int => 60), 'connection' => env('RATE_LIMIT_REDIS_CONNECTION', static fn (): string => 'default'), ], From 02fb86a28dddbb261feb9bba652251d2ecf5d64c Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Fri, 5 Dec 2025 16:18:21 -0500 Subject: [PATCH 24/29] test: add RedisRateLimitTest to verify rate limit factory instantiation --- .../Cache/RateLimit/RedisRateLimitTest.php | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 tests/Unit/Cache/RateLimit/RedisRateLimitTest.php diff --git a/tests/Unit/Cache/RateLimit/RedisRateLimitTest.php b/tests/Unit/Cache/RateLimit/RedisRateLimitTest.php new file mode 100644 index 00000000..c238bdca --- /dev/null +++ b/tests/Unit/Cache/RateLimit/RedisRateLimitTest.php @@ -0,0 +1,19 @@ +value); + Config::set('cache.rate_limit.store', Store::REDIS->value); +}); + +it('call redis rate limit factory', function (): void { + $manager = new RateLimitManager(); + + expect($manager->limiter())->toBeInstanceOf(RedisRateLimit::class); +}); From 4383871c41e0031679cff1025d5c65635b85e051 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Fri, 5 Dec 2025 16:18:43 -0500 Subject: [PATCH 25/29] refactor: remove unused perMinute method from RateLimit Config --- src/Cache/RateLimit/Config.php | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/Cache/RateLimit/Config.php b/src/Cache/RateLimit/Config.php index 01d52df7..b8c74f82 100644 --- a/src/Cache/RateLimit/Config.php +++ b/src/Cache/RateLimit/Config.php @@ -20,11 +20,6 @@ public function default(): string return $this->config['store'] ?? 'local'; } - public function perMinute(): int - { - return (int) ($this->config['per_minute'] ?? 60); - } - public function connection(): string { return $this->config['connection'] ?? 'default'; From e46b8acc3fa021919524cdc0d7d7dc9b9c5da264 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Fri, 5 Dec 2025 16:18:55 -0500 Subject: [PATCH 26/29] refactor: move limiter method to improve clarity in RateLimitManager --- src/Cache/RateLimit/RateLimitManager.php | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/Cache/RateLimit/RateLimitManager.php b/src/Cache/RateLimit/RateLimitManager.php index 93d0138d..f184d52c 100644 --- a/src/Cache/RateLimit/RateLimitManager.php +++ b/src/Cache/RateLimit/RateLimitManager.php @@ -32,6 +32,11 @@ public function getTtl(string $key): int return $this->limiter()->getTtl($key); } + public function limiter(): RateLimit + { + return $this->rateLimiters[$this->config->default()] ??= $this->resolveStore(); + } + public function prefixed(string $prefix): self { $this->rateLimiters[$this->config->default()] = RateLimitFactory::withPrefix($this->limiter(), $prefix); @@ -39,11 +44,6 @@ public function prefixed(string $prefix): self return $this; } - protected function limiter(): RateLimit - { - return $this->rateLimiters[$this->config->default()] ??= $this->resolveStore(); - } - protected function resolveStore(): RateLimit { return match ($this->config->default()) { From b7901dbfc7bd11143911ef8bd660343ca406db42 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Fri, 5 Dec 2025 16:47:36 -0500 Subject: [PATCH 27/29] test: add tests for setting expires_at and default ttl in LocalRateLimit --- .../Cache/RateLimit/LocalRateLimitTest.php | 49 ++++++++++++++++++- 1 file changed, 48 insertions(+), 1 deletion(-) diff --git a/tests/Unit/Cache/RateLimit/LocalRateLimitTest.php b/tests/Unit/Cache/RateLimit/LocalRateLimitTest.php index 879c181a..aafb1b08 100644 --- a/tests/Unit/Cache/RateLimit/LocalRateLimitTest.php +++ b/tests/Unit/Cache/RateLimit/LocalRateLimitTest.php @@ -5,6 +5,9 @@ use Amp\Cache\LocalCache; use Phenix\Cache\RateLimit\LocalRateLimit; use Phenix\Cache\Stores\LocalStore; +use Phenix\Util\Date; + +use function Amp\delay; it('can increment rate limit counter', function (): void { $store = new LocalStore(new LocalCache()); @@ -16,6 +19,33 @@ expect($rateLimit->get('test'))->toBe(2); }); +it('sets expires_at when missing on existing entry', function (): void { + $store = $this->getMockBuilder(LocalStore::class) + ->disableOriginalConstructor() + ->getMock(); + + $store->expects($this->once()) + ->method('get') + ->with('user:1') + ->willReturn(['count' => 0]); + + $store->expects($this->once()) + ->method('set') + ->with( + 'user:1', + $this->callback(function (array $data): bool { + return isset($data['expires_at']) && (int) ($data['count'] ?? 0) === 1; + }), + $this->isInstanceOf(Date::class) + ); + + $rateLimit = new LocalRateLimit($store, 60); + + $count = $rateLimit->increment('user:1'); + + expect($count)->toBe(1); +}); + it('can get time to live for rate limit', function (): void { $store = new LocalStore(new LocalCache()); $rateLimit = new LocalRateLimit($store, 60); @@ -28,6 +58,23 @@ expect($ttl)->toBeLessThanOrEqual(60); }); +it('returns default ttl when expires_at missing', function (): void { + $store = $this->getMockBuilder(LocalStore::class) + ->disableOriginalConstructor() + ->getMock(); + + $store->expects($this->once()) + ->method('get') + ->with('user:2') + ->willReturn(['count' => 1]); + + $rateLimit = new LocalRateLimit($store, 60); + + $ttl = $rateLimit->getTtl('user:2'); + + expect($ttl)->toBe(60); +}); + it('cleans up expired entries', function (): void { $store = new LocalStore(new LocalCache()); $rateLimit = new LocalRateLimit($store, 1); // 1 second TTL @@ -35,7 +82,7 @@ $rateLimit->increment('test'); expect($rateLimit->get('test'))->toBe(1); - sleep(2); // Wait for expiration + delay(2); // Wait for expiration expect($rateLimit->get('test'))->toBe(0); }); From efe8c0edb68f03ccf709408f5d8c56db3363cb36 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Fri, 5 Dec 2025 18:19:38 -0500 Subject: [PATCH 28/29] refactor: remove HMAC hashing from IpAddress::hash method --- src/Http/IpAddress.php | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/src/Http/IpAddress.php b/src/Http/IpAddress.php index 457693ab..09413032 100644 --- a/src/Http/IpAddress.php +++ b/src/Http/IpAddress.php @@ -5,8 +5,6 @@ namespace Phenix\Http; use Amp\Http\Server\Request; -use Phenix\Crypto\Bin2Base64; -use Phenix\Facades\Config; final class IpAddress { @@ -35,14 +33,6 @@ public static function hash(Request $request): string return $ip; } - $encodedKey = Config::get('app.key'); - - if ($encodedKey) { - $decodedKey = Bin2Base64::decode($encodedKey); - - return hash_hmac('sha256', $host, $decodedKey); - } - return hash('sha256', $host); } From 303712e57d59db37d37a897ae04a2e7ef3bbf5c5 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Fri, 5 Dec 2025 18:53:21 -0500 Subject: [PATCH 29/29] refactor: simplify IP hashing logic in IpAddress class and add unit tests --- src/Http/IpAddress.php | 31 ++++++++++++++++++++++++++----- tests/Unit/Http/IpAddressTest.php | 31 +++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 5 deletions(-) create mode 100644 tests/Unit/Http/IpAddressTest.php diff --git a/src/Http/IpAddress.php b/src/Http/IpAddress.php index 09413032..5598ca96 100644 --- a/src/Http/IpAddress.php +++ b/src/Http/IpAddress.php @@ -27,13 +27,10 @@ public static function parse(Request $request): string public static function hash(Request $request): string { $ip = self::parse($request); - $host = parse_url($ip, PHP_URL_HOST); - if ($host === null) { - return $ip; - } + $normalized = self::normalize($ip); - return hash('sha256', $host); + return hash('sha256', $normalized); } private static function getFromHeader(string $header): string @@ -42,4 +39,28 @@ private static function getFromHeader(string $header): string 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/tests/Unit/Http/IpAddressTest.php b/tests/Unit/Http/IpAddressTest.php new file mode 100644 index 00000000..22b2a18c --- /dev/null +++ b/tests/Unit/Http/IpAddressTest.php @@ -0,0 +1,31 @@ +createMock(Client::class); + $uri = Http::new(URL::build('posts/7/comments/22')); + $request = new ServerRequest($client, HttpMethod::GET->value, $uri); + + $request->setHeader('X-Forwarded-For', $ip); + + expect(IpAddress::hash($request))->toBe($expected); +})->with([ + ['192.168.1.1', hash('sha256', '192.168.1.1')], + ['192.168.1.1:8080', hash('sha256', '192.168.1.1')], + ['2001:0db8:85a3:0000:0000:8a2e:0370:7334', hash('sha256', '2001:0db8:85a3:0000:0000:8a2e:0370:7334')], + ['fe80::1ff:fe23:4567:890a', hash('sha256', 'fe80::1ff:fe23:4567:890a')], + ['[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')], + ['::ffff:192.168.0.1', hash('sha256', '::ffff:192.168.0.1')], +]);