Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
96.36% covered (success)
96.36%
53 / 55
83.33% covered (warning)
83.33%
5 / 6
CRAP
0.00% covered (danger)
0.00%
0 / 1
RateLimiterMiddleware
96.30% covered (success)
96.30%
52 / 54
83.33% covered (warning)
83.33%
5 / 6
16
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 createWithCache
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 process
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
3
 resolveClientIp
83.33% covered (warning)
83.33%
10 / 12
0.00% covered (danger)
0.00%
0 / 1
5.12
 extractTrustedForwardedIp
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
5
 createRateLimitExceededResponse
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3declare(strict_types=1);
4
5/** @license For full copyright and license information, please see the LICENSE.md file. */
6
7namespace App\Core\Security\Middleware;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use App\Core\Audit\Application\Service\SecurityAuditLogger;
12use App\Shared\Infrastructure\Http\ApiResponseTrait;
13use Psr\Http\Message\ResponseFactoryInterface;
14use Psr\Http\Message\ResponseInterface;
15use Psr\Http\Message\ServerRequestInterface;
16use Psr\Http\Server\MiddlewareInterface;
17use Psr\Http\Server\RequestHandlerInterface;
18use Yiisoft\Yii\RateLimiter\Counter;
19use Yiisoft\Yii\RateLimiter\Storage\SimpleCacheStorage;
20use Yiisoft\Yii\RateLimiter\Storage\StorageInterface;
21
22/**
23 * Rate Limiter Middleware.
24 *
25 * Protects application endpoints from brute-force and denial-of-service attacks
26 * using Yiisoft RateLimiter Counter and SimpleCacheStorage.
27 *
28 * @package App\Core\Security\Middleware
29 */
30final readonly class RateLimiterMiddleware implements MiddlewareInterface
31{
32    use ApiResponseTrait;
33
34    private Counter $counter;
35
36    /**
37     * RateLimiterMiddleware constructor.
38     *
39     * @param ResponseFactoryInterface  $responseFactory PSR-17 response factory.
40     * @param StorageInterface          $storage         Rate limiter cache storage.
41     * @param int                       $limit           Maximum number of requests in the period.
42     * @param int                       $periodInSeconds Time window period in seconds.
43     * @param SecurityAuditLogger|null  $securityLogger  Optional security audit logger.
44     */
45    public function __construct(
46        private ResponseFactoryInterface $responseFactory,
47        StorageInterface $storage,
48        private int $limit = 120,
49        int $periodInSeconds = 60,
50        private ?SecurityAuditLogger $securityLogger = null,
51    ) {
52        $this->counter = new Counter($storage, $this->limit, $periodInSeconds);
53    }
54
55    /**
56     * Creates a standard RateLimiterMiddleware with SimpleCacheStorage.
57     *
58     * @param ResponseFactoryInterface $responseFactory PSR-17 response factory.
59     * @param mixed                    $cache           PSR-16 SimpleCache instance.
60     * @param int                      $limit           Max requests allowed.
61     * @param int                      $periodSeconds   Window in seconds.
62     * @param SecurityAuditLogger|null $securityLogger  Optional security audit logger.
63     * @return self New instance.
64     */
65    public static function createWithCache(
66        ResponseFactoryInterface $responseFactory,
67        mixed $cache,
68        int $limit = 120,
69        int $periodSeconds = 60,
70        ?SecurityAuditLogger $securityLogger = null
71    ): self {
72        $storage = new SimpleCacheStorage($cache);
73
74        return new self($responseFactory, $storage, $limit, $periodSeconds, $securityLogger);
75    }
76
77    /**
78     * Processes an incoming server request and enforces rate limit.
79     *
80     * @param ServerRequestInterface  $request Server request.
81     * @param RequestHandlerInterface $handler Request handler.
82     * @return ResponseInterface PSR-7 response.
83     */
84    public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
85    {
86        $clientIp = $this->resolveClientIp($request);
87        $state = $this->counter->hit($clientIp);
88
89        if ($state->isLimitReached()) {
90            $reqId = $request->getHeaderLine('X-Request-ID');
91            $this->securityLogger?->logRateLimitExceeded(
92                $clientIp,
93                (string) $request->getUri(),
94                $reqId !== '' ? $reqId : null
95            );
96
97            return $this->createRateLimitExceededResponse($state->getResetTime());
98        }
99
100        $response = $handler->handle($request);
101        $resetTime = $state->getResetTime();
102        $remaining = $state->getRemaining();
103
104        return $response
105            ->withHeader('X-RateLimit-Limit', (string) $this->limit)
106            ->withHeader('X-RateLimit-Remaining', (string) $remaining)
107            ->withHeader('X-RateLimit-Reset', (string) $resetTime)
108            ->withHeader('RateLimit-Limit', (string) $this->limit)
109            ->withHeader('RateLimit-Remaining', (string) $remaining)
110            ->withHeader('RateLimit-Reset', (string) max(0, $resetTime - time()));
111    }
112
113    /** @var string Default fallback IP address. */
114    private const DEFAULT_IP = '127.0.0.1';
115
116    /** @var array<int, string> Default trusted reverse proxy IP addresses. */
117    private const DEFAULT_TRUSTED_PROXIES = [self::DEFAULT_IP, '::1'];
118
119    /**
120     * Resolves the client IP address from server request securely.
121     * Only trusts forwarding headers if the immediate remote address is a trusted proxy.
122     *
123     * @param ServerRequestInterface $request Incoming HTTP request.
124     * @return string Validated client IP address.
125     */
126    private function resolveClientIp(ServerRequestInterface $request): string
127    {
128        $serverParams = $request->getServerParams();
129        $remoteAddr = (string) ($serverParams['REMOTE_ADDR'] ?? self::DEFAULT_IP);
130        $fallback = filter_var($remoteAddr, FILTER_VALIDATE_IP) !== false ? $remoteAddr : self::DEFAULT_IP;
131
132        $trustedEnv = (string) ($_ENV['TRUSTED_PROXIES'] ?? getenv('TRUSTED_PROXIES') ?: '');
133        $trustedProxies = self::DEFAULT_TRUSTED_PROXIES;
134        if ($trustedEnv !== '') {
135            $extra = array_filter(array_map('trim', explode(',', $trustedEnv)));
136            $trustedProxies = array_values(array_unique(array_merge($trustedProxies, $extra)));
137        }
138
139        if (!in_array($remoteAddr, $trustedProxies, true)) {
140            return $fallback;
141        }
142
143        $proxyIp = $this->extractTrustedForwardedIp($request);
144
145        return $proxyIp ?? $fallback;
146    }
147
148    private function extractTrustedForwardedIp(ServerRequestInterface $request): ?string
149    {
150        $realIp = trim($request->getHeaderLine('X-Real-IP'));
151        if (filter_var($realIp, FILTER_VALIDATE_IP) !== false) {
152            return $realIp;
153        }
154
155        $forwarded = $request->getHeaderLine('X-Forwarded-For');
156        if ($forwarded !== '') {
157            $parts = array_filter(array_map('trim', explode(',', $forwarded)));
158            $lastClientIp = end($parts) ?: '';
159            if (filter_var($lastClientIp, FILTER_VALIDATE_IP) !== false) {
160                return $lastClientIp;
161            }
162        }
163
164        return null;
165    }
166
167    /**
168     * Generates a 429 Too Many Requests response with standard JSON payload.
169     */
170    private function createRateLimitExceededResponse(int $resetTime): ResponseInterface
171    {
172        $response = $this->jsonError(
173            $this->responseFactory,
174            'Rate limit exceeded. Please try again later.',
175            429
176        );
177
178        return $response
179            ->withHeader('Retry-After', (string) max(1, $resetTime - time()))
180            ->withHeader('X-RateLimit-Remaining', '0')
181            ->withHeader('X-RateLimit-Reset', (string) $resetTime);
182    }
183}