From 0644e189db2a7a16e6f5ad6732a5320917452254 Mon Sep 17 00:00:00 2001 From: OStefan2001 Date: Tue, 13 Oct 2026 18:29:01 +0300 Subject: [PATCH 01/11] ip address middleware --- config/autoload/ip-address.global.php | 10 + config/pipeline.php | 2 + src/App/src/ConfigProvider.php | 2 + .../src/Handler/PostCreateContactHandler.php | 3 +- .../src/Middleware/IpAddressMiddleware.php | 193 +++++++++++++++++ .../Handler/PostCreateContactHandlerTest.php | 3 +- .../Middleware/IpAddressMiddlewareTest.php | 203 ++++++++++++++++++ 7 files changed, 414 insertions(+), 2 deletions(-) create mode 100644 config/autoload/ip-address.global.php create mode 100644 src/App/src/Middleware/IpAddressMiddleware.php create mode 100644 test/Unit/App/Middleware/IpAddressMiddlewareTest.php diff --git a/config/autoload/ip-address.global.php b/config/autoload/ip-address.global.php new file mode 100644 index 00000000..cb8fece9 --- /dev/null +++ b/config/autoload/ip-address.global.php @@ -0,0 +1,10 @@ + [ + 'trusted_proxies' => [], + 'headers' => ['Forwarded', 'X-Forwarded-For', 'X-Real-IP'], + ], +]; diff --git a/config/pipeline.php b/config/pipeline.php index 6640698a..f77c99ff 100644 --- a/config/pipeline.php +++ b/config/pipeline.php @@ -4,6 +4,7 @@ use Dot\ErrorHandler\ErrorHandlerInterface; use Dot\ResponseHeader\Middleware\ResponseHeaderMiddleware; +use Light\App\Middleware\IpAddressMiddleware; use Mezzio\Application; use Mezzio\Handler\NotFoundHandler; use Mezzio\Helper\ServerUrlMiddleware; @@ -19,6 +20,7 @@ // all Exceptions. $app->pipe(ErrorHandlerInterface::class); $app->pipe(ServerUrlMiddleware::class); + $app->pipe(IpAddressMiddleware::class); // Pipe more middleware here that you want to execute on every request: // - bootstrapping diff --git a/src/App/src/ConfigProvider.php b/src/App/src/ConfigProvider.php index 53a7dd6c..f12733ce 100644 --- a/src/App/src/ConfigProvider.php +++ b/src/App/src/ConfigProvider.php @@ -33,6 +33,7 @@ use Light\App\Handler\GetPackagesViewHandler; use Light\App\Handler\GetSitemapViewHandler; use Light\App\Handler\PostCreateContactHandler; +use Light\App\Middleware\IpAddressMiddleware; use Light\App\Resolver\EntityListenerResolver; use Light\App\Service\ContactService; use Light\App\Service\FeedGenerator; @@ -149,6 +150,7 @@ public function getDependencies(): array GetCreateContactFormHandler::class => AttributedServiceFactory::class, PostCreateContactHandler::class => AttributedServiceFactory::class, ContactService::class => AttributedServiceFactory::class, + IpAddressMiddleware::class => AttributedServiceFactory::class, FeedGenerator::class => FeedGeneratorFactory::class, SitemapGenerator::class => SitemapGeneratorFactory::class, GitHubClient::class => GitHubClientFactory::class, diff --git a/src/App/src/Handler/PostCreateContactHandler.php b/src/App/src/Handler/PostCreateContactHandler.php index 04e974cd..51b3a87c 100644 --- a/src/App/src/Handler/PostCreateContactHandler.php +++ b/src/App/src/Handler/PostCreateContactHandler.php @@ -8,6 +8,7 @@ use Laminas\Diactoros\Response\HtmlResponse; use Laminas\Diactoros\Response\RedirectResponse; use Light\App\Enum\ContactTopicEnum; +use Light\App\Middleware\IpAddressMiddleware; use Light\App\Service\ContactService; use Mezzio\Helper\UrlHelper; use Mezzio\Template\TemplateRendererInterface; @@ -42,7 +43,7 @@ public function handle(ServerRequestInterface $request): ResponseInterface $token = is_array($rawBody) && is_string($rawBody['cf-turnstile-response'] ?? null) ? $rawBody['cf-turnstile-response'] : ''; - $ip = $request->getServerParams()['REMOTE_ADDR'] ?? null; + $ip = $request->getAttribute(IpAddressMiddleware::ATTRIBUTE); if (! $this->contactService->verifyTurnstile($token, is_string($ip) ? $ip : null)) { $errors['captcha'] = "We couldn't verify you're human. Please try again."; diff --git a/src/App/src/Middleware/IpAddressMiddleware.php b/src/App/src/Middleware/IpAddressMiddleware.php new file mode 100644 index 00000000..f182dad9 --- /dev/null +++ b/src/App/src/Middleware/IpAddressMiddleware.php @@ -0,0 +1,193 @@ + $trustedProxies IP addresses or CIDR ranges, e.g. `10.0.0.1` or `173.245.48.0/20` + * @param list $headers checked in order; the first one that gives an address wins + */ + #[Inject( + 'config.ip_address.trusted_proxies', + 'config.ip_address.headers', + )] + public function __construct( + private readonly array $trustedProxies = [], + private readonly array $headers = ['Forwarded', 'X-Forwarded-For', 'X-Real-IP'], + ) { + } + + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface + { + return $handler->handle($request->withAttribute(self::ATTRIBUTE, $this->determineIpAddress($request))); + } + + public function determineIpAddress(ServerRequestInterface $request): ?string + { + $remoteAddr = $this->normalize($request->getServerParams()['REMOTE_ADDR'] ?? null); + if ($remoteAddr === null || ! $this->isTrustedProxy($remoteAddr)) { + return $remoteAddr; + } + + foreach ($this->headers as $header) { + $ipAddress = $this->fromHeader($request, $header); + if ($ipAddress !== null) { + return $ipAddress; + } + } + + return $remoteAddr; + } + + private function fromHeader(ServerRequestInterface $request, string $header): ?string + { + $line = $request->getHeaderLine($header); + if ($line === '') { + return null; + } + + $hops = []; + foreach (explode(',', $line) as $hop) { + $hops[] = $this->normalize(strcasecmp($header, 'Forwarded') === 0 ? $this->forwardedFor($hop) : $hop); + } + + /** + * Each proxy appends the address it received the request from, so walk from the right: the first + * address that is not one of our own proxies is the client. Anything left of it could be forged. + */ + foreach (array_reverse($hops) as $hop) { + if ($hop === null) { + return null; + } + if (! $this->isTrustedProxy($hop)) { + return $hop; + } + } + + return $hops[0]; + } + + /** + * Returns the `for=` value of one RFC 7239 element, e.g. `for=192.0.2.60;proto=https;by=203.0.113.43`. + */ + private function forwardedFor(string $element): string + { + foreach (explode(';', $element) as $pair) { + $parts = explode('=', $pair, 2); + if (isset($parts[1]) && strcasecmp(trim($parts[0]), 'for') === 0) { + return $parts[1]; + } + } + + return ''; + } + + /** + * Strips quotes, IPv6 brackets and ports; returns null for anything that is not an IP address. + */ + private function normalize(mixed $value): ?string + { + if (! is_string($value)) { + return null; + } + + $value = trim(trim($value), '"'); + if (str_starts_with($value, '[')) { + $end = strpos($value, ']'); + $value = $end === false ? '' : substr($value, 1, $end - 1); + } elseif (substr_count($value, ':') === 1) { + $value = substr($value, 0, (int) strrpos($value, ':')); + } + + return filter_var($value, FILTER_VALIDATE_IP) === false ? null : $value; + } + + private function isTrustedProxy(string $ipAddress): bool + { + foreach ($this->trustedProxies as $proxy) { + if ($this->matches($ipAddress, $proxy)) { + return true; + } + } + + return false; + } + + private function matches(string $ipAddress, string $proxy): bool + { + $subnet = $proxy; + $bits = null; + if (str_contains($proxy, '/')) { + [$subnet, $bits] = explode('/', $proxy, 2); + } + + $address = @inet_pton($ipAddress); + $network = @inet_pton($subnet); + if ($address === false || $network === false || strlen($address) !== strlen($network)) { + return false; + } + + if ($bits === null) { + return $address === $network; + } + + if (! ctype_digit($bits) || (int) $bits > strlen($address) * 8) { + return false; + } + + $bytes = intdiv((int) $bits, 8); + if (strncmp($address, $network, $bytes) !== 0) { + return false; + } + + $remainder = (int) $bits % 8; + if ($remainder === 0) { + return true; + } + + $mask = (0xFF << (8 - $remainder)) & 0xFF; + $addressBits = ord($address[$bytes]) & $mask; + $networkBits = ord($network[$bytes]) & $mask; + + return $addressBits === $networkBits; + } +} diff --git a/test/Unit/App/Handler/PostCreateContactHandlerTest.php b/test/Unit/App/Handler/PostCreateContactHandlerTest.php index 8581bed4..093e1cb7 100644 --- a/test/Unit/App/Handler/PostCreateContactHandlerTest.php +++ b/test/Unit/App/Handler/PostCreateContactHandlerTest.php @@ -12,6 +12,7 @@ use Laminas\Diactoros\Response\RedirectResponse; use Light\App\Enum\ContactTopicEnum; use Light\App\Handler\PostCreateContactHandler; +use Light\App\Middleware\IpAddressMiddleware; use Light\App\Service\ContactService; use Mezzio\Helper\UrlHelper; use Mezzio\Template\TemplateRendererInterface; @@ -197,7 +198,7 @@ public function testFailedTurnstileRerendersWithCaptchaErrorAndSendsNothing(): v $request = $this->createStub(ServerRequestInterface::class); $request->method('getParsedBody')->willReturn($data); - $request->method('getServerParams')->willReturn(['REMOTE_ADDR' => '203.0.113.5']); + $request->method('getAttribute')->willReturnMap([[IpAddressMiddleware::ATTRIBUTE, null, '203.0.113.5']]); $contactService = $this->createMock(ContactService::class); $contactService->method('normalize')->willReturn([ diff --git a/test/Unit/App/Middleware/IpAddressMiddlewareTest.php b/test/Unit/App/Middleware/IpAddressMiddlewareTest.php new file mode 100644 index 00000000..9f0a0350 --- /dev/null +++ b/test/Unit/App/Middleware/IpAddressMiddlewareTest.php @@ -0,0 +1,203 @@ + $headers + */ + private function request(?string $remoteAddr, array $headers = []): ServerRequest + { + $serverParams = $remoteAddr === null ? [] : ['REMOTE_ADDR' => $remoteAddr]; + + return new ServerRequest($serverParams, [], null, 'GET', 'php://memory', $headers); + } + + /** + * @throws Exception + */ + public function testProcessStoresTheIpAddressAsARequestAttribute(): void + { + $handler = $this->createMock(RequestHandlerInterface::class); + $handler + ->expects($this->once()) + ->method('handle') + ->with($this->callback( + fn (ServerRequestInterface $request): bool => $request->getAttribute('ip_address') === '198.51.100.7' + )) + ->willReturn(new Response()); + + (new IpAddressMiddleware())->process($this->request('198.51.100.7'), $handler); + } + + public function testHeadersAreIgnoredWithoutTrustedProxies(): void + { + $request = $this->request('198.51.100.7', ['X-Forwarded-For' => '6.6.6.6']); + + $this->assertSame('198.51.100.7', (new IpAddressMiddleware())->determineIpAddress($request)); + } + + public function testHeadersAreIgnoredWhenRemoteAddrIsNotATrustedProxy(): void + { + $request = $this->request('198.51.100.7', ['X-Forwarded-For' => '6.6.6.6']); + + $this->assertSame('198.51.100.7', (new IpAddressMiddleware(['10.0.0.1']))->determineIpAddress($request)); + } + + public function testMissingRemoteAddrGivesNull(): void + { + $this->assertNull((new IpAddressMiddleware(['10.0.0.1']))->determineIpAddress($this->request(null))); + } + + public function testInvalidRemoteAddrGivesNull(): void + { + $this->assertNull((new IpAddressMiddleware())->determineIpAddress($this->request('not-an-ip'))); + } + + /** + * @return array, string, array, string}> + */ + public static function trustedProxyProvider(): array + { + return [ + 'exact proxy' => [ + ['10.0.0.1'], + '10.0.0.1', + ['X-Forwarded-For' => '198.51.100.7'], + '198.51.100.7', + ], + 'IPv4 CIDR proxy' => [ + ['173.245.48.0/20'], + '173.245.60.1', + ['X-Forwarded-For' => '198.51.100.7'], + '198.51.100.7', + ], + 'IPv6 CIDR proxy' => [ + ['2400:cb00::/32'], + '2400:cb00:2049::1', + ['X-Forwarded-For' => '2001:db8::1'], + '2001:db8::1', + ], + 'spoofed address left of the proxy' => [ + ['10.0.0.1'], + '10.0.0.1', + ['X-Forwarded-For' => '6.6.6.6, 198.51.100.7'], + '198.51.100.7', + ], + 'chain of trusted proxies' => [ + ['10.0.0.0/8'], + '10.0.0.1', + ['X-Forwarded-For' => '198.51.100.7, 10.0.0.2'], + '198.51.100.7', + ], + 'only trusted proxies in the chain' => [ + ['10.0.0.0/8'], + '10.0.0.1', + ['X-Forwarded-For' => '10.0.0.3, 10.0.0.2'], + '10.0.0.3', + ], + 'IPv4 with port' => [ + ['10.0.0.1'], + '10.0.0.1', + ['X-Forwarded-For' => '198.51.100.7:4711'], + '198.51.100.7', + ], + 'Forwarded header' => [ + ['10.0.0.1'], + '10.0.0.1', + ['Forwarded' => 'for=198.51.100.7;proto=https;by=10.0.0.1'], + '198.51.100.7', + ], + 'Forwarded quoted IPv6 with port' => [ + ['10.0.0.1'], + '10.0.0.1', + ['Forwarded' => 'for="[2001:db8:cafe::17]:4711"'], + '2001:db8:cafe::17', + ], + 'Forwarded wins over X-Forwarded-For' => [ + ['10.0.0.1'], + '10.0.0.1', + ['Forwarded' => 'for=198.51.100.7', 'X-Forwarded-For' => '203.0.113.9'], + '198.51.100.7', + ], + 'X-Real-IP' => [ + ['10.0.0.1'], + '10.0.0.1', + ['X-Real-IP' => '198.51.100.7'], + '198.51.100.7', + ], + 'invalid hop falls back to the next header' => [ + ['10.0.0.1'], + '10.0.0.1', + ['X-Forwarded-For' => '198.51.100.7, garbage', 'X-Real-IP' => '203.0.113.9'], + '203.0.113.9', + ], + 'no usable header keeps REMOTE_ADDR' => [ + ['10.0.0.1'], + '10.0.0.1', + ['X-Forwarded-For' => 'garbage'], + '10.0.0.1', + ], + ]; + } + + /** + * @param list $trustedProxies + * @param array $headers + */ + #[DataProvider('trustedProxyProvider')] + public function testTrustedProxyHeadersGiveTheClientAddress( + array $trustedProxies, + string $remoteAddr, + array $headers, + string $expected, + ): void { + $middleware = new IpAddressMiddleware($trustedProxies); + + $this->assertSame($expected, $middleware->determineIpAddress($this->request($remoteAddr, $headers))); + } + + public function testOnlyTheConfiguredHeadersAreRead(): void + { + $middleware = new IpAddressMiddleware(['10.0.0.1'], ['CF-Connecting-IP']); + $request = $this->request('10.0.0.1', [ + 'X-Forwarded-For' => '6.6.6.6', + 'CF-Connecting-IP' => '198.51.100.7', + ]); + + $this->assertSame('198.51.100.7', $middleware->determineIpAddress($request)); + } + + /** + * @return array + */ + public static function invalidProxyProvider(): array + { + return [ + 'mask too long' => ['10.0.0.0/33'], + 'mask not a number' => ['10.0.0.0/abc'], + 'not an address' => ['proxy.example'], + 'other IP family' => ['::/0'], + ]; + } + + #[DataProvider('invalidProxyProvider')] + public function testInvalidTrustedProxyEntriesTrustNothing(string $proxy): void + { + $request = $this->request('10.0.0.1', ['X-Forwarded-For' => '198.51.100.7']); + + $this->assertSame('10.0.0.1', (new IpAddressMiddleware([$proxy]))->determineIpAddress($request)); + } +} From afcfd84da3658022579dd8401b7303304168b7b8 Mon Sep 17 00:00:00 2001 From: OStefan2001 Date: Tue, 13 Oct 2026 18:32:42 +0300 Subject: [PATCH 02/11] ip address middleware --- .../src/Middleware/IpAddressMiddleware.php | 21 ++----------------- 1 file changed, 2 insertions(+), 19 deletions(-) diff --git a/src/App/src/Middleware/IpAddressMiddleware.php b/src/App/src/Middleware/IpAddressMiddleware.php index f182dad9..ccec6c0c 100644 --- a/src/App/src/Middleware/IpAddressMiddleware.php +++ b/src/App/src/Middleware/IpAddressMiddleware.php @@ -31,20 +31,13 @@ use const FILTER_VALIDATE_IP; -/** - * Determines the client IP address and stores it in the `ip_address` request attribute, - * the same way akrabat/ip-address-middleware does. - * - * Forwarding headers are read only when REMOTE_ADDR is a trusted proxy, because any client can - * send them with any value. With no trusted proxies configured, the attribute is REMOTE_ADDR. - */ class IpAddressMiddleware implements MiddlewareInterface { public const string ATTRIBUTE = 'ip_address'; /** - * @param list $trustedProxies IP addresses or CIDR ranges, e.g. `10.0.0.1` or `173.245.48.0/20` - * @param list $headers checked in order; the first one that gives an address wins + * @param list $trustedProxies + * @param list $headers */ #[Inject( 'config.ip_address.trusted_proxies', @@ -90,10 +83,6 @@ private function fromHeader(ServerRequestInterface $request, string $header): ?s $hops[] = $this->normalize(strcasecmp($header, 'Forwarded') === 0 ? $this->forwardedFor($hop) : $hop); } - /** - * Each proxy appends the address it received the request from, so walk from the right: the first - * address that is not one of our own proxies is the client. Anything left of it could be forged. - */ foreach (array_reverse($hops) as $hop) { if ($hop === null) { return null; @@ -106,9 +95,6 @@ private function fromHeader(ServerRequestInterface $request, string $header): ?s return $hops[0]; } - /** - * Returns the `for=` value of one RFC 7239 element, e.g. `for=192.0.2.60;proto=https;by=203.0.113.43`. - */ private function forwardedFor(string $element): string { foreach (explode(';', $element) as $pair) { @@ -121,9 +107,6 @@ private function forwardedFor(string $element): string return ''; } - /** - * Strips quotes, IPv6 brackets and ports; returns null for anything that is not an IP address. - */ private function normalize(mixed $value): ?string { if (! is_string($value)) { From 06fa93203744638f907112dc3074ba0262945bed Mon Sep 17 00:00:00 2001 From: OStefan2001 Date: Wed, 14 Oct 2026 13:55:32 +0300 Subject: [PATCH 03/11] get ip address using akr middleware --- composer.json | 1 + config/autoload/ip-address.global.php | 10 - config/autoload/ip_address.global.php | 14 ++ config/config.php | 1 + config/pipeline.php | 5 +- src/App/src/ConfigProvider.php | 2 - .../src/Handler/PostCreateContactHandler.php | 7 +- .../src/Middleware/IpAddressMiddleware.php | 176 --------------- src/App/src/Service/ContactService.php | 7 +- .../Handler/PostCreateContactHandlerTest.php | 42 +++- .../Middleware/IpAddressMiddlewareTest.php | 203 ------------------ test/Unit/App/Service/ContactServiceTest.php | 32 +++ 12 files changed, 98 insertions(+), 402 deletions(-) delete mode 100644 config/autoload/ip-address.global.php create mode 100644 config/autoload/ip_address.global.php delete mode 100644 src/App/src/Middleware/IpAddressMiddleware.php delete mode 100644 test/Unit/App/Middleware/IpAddressMiddlewareTest.php diff --git a/composer.json b/composer.json index a6fe63dc..71c159c8 100644 --- a/composer.json +++ b/composer.json @@ -29,6 +29,7 @@ "php": "~8.5.0", "ext-curl": "*", "ext-dom": "*", + "akrabat/ip-address-middleware": "^2.6", "doctrine/data-fixtures": "^2.2", "doctrine/doctrine-fixtures-bundle": "^4.3", "dotkernel/dot-cache": "^4.0", diff --git a/config/autoload/ip-address.global.php b/config/autoload/ip-address.global.php deleted file mode 100644 index cb8fece9..00000000 --- a/config/autoload/ip-address.global.php +++ /dev/null @@ -1,10 +0,0 @@ - [ - 'trusted_proxies' => [], - 'headers' => ['Forwarded', 'X-Forwarded-For', 'X-Real-IP'], - ], -]; diff --git a/config/autoload/ip_address.global.php b/config/autoload/ip_address.global.php new file mode 100644 index 00000000..5980ee89 --- /dev/null +++ b/config/autoload/ip_address.global.php @@ -0,0 +1,14 @@ + [ + 'ip_address' => [ + 'check_proxy_headers' => false, + 'trusted_proxies' => [], + 'attribute_name' => 'ip_address', + 'headers_to_inspect' => [], + ], + ], +]; diff --git a/config/config.php b/config/config.php index 147edc17..63abf426 100644 --- a/config/config.php +++ b/config/config.php @@ -23,6 +23,7 @@ \Mezzio\Router\ConfigProvider::class, \Mezzio\Router\FastRouteRouter\ConfigProvider::class, \Mezzio\Twig\ConfigProvider::class, + \RKA\Middleware\Mezzio\ConfigProvider::class, // Dotkernel packages \Dot\DependencyInjection\ConfigProvider::class, diff --git a/config/pipeline.php b/config/pipeline.php index f77c99ff..5500b015 100644 --- a/config/pipeline.php +++ b/config/pipeline.php @@ -4,7 +4,6 @@ use Dot\ErrorHandler\ErrorHandlerInterface; use Dot\ResponseHeader\Middleware\ResponseHeaderMiddleware; -use Light\App\Middleware\IpAddressMiddleware; use Mezzio\Application; use Mezzio\Handler\NotFoundHandler; use Mezzio\Helper\ServerUrlMiddleware; @@ -14,13 +13,13 @@ use Mezzio\Router\Middleware\ImplicitOptionsMiddleware; use Mezzio\Router\Middleware\MethodNotAllowedMiddleware; use Mezzio\Router\Middleware\RouteMiddleware; +use RKA\Middleware\IpAddress; return function (Application $app): void { // The error handler should be the first (most outer) middleware to catch // all Exceptions. $app->pipe(ErrorHandlerInterface::class); $app->pipe(ServerUrlMiddleware::class); - $app->pipe(IpAddressMiddleware::class); // Pipe more middleware here that you want to execute on every request: // - bootstrapping @@ -65,6 +64,8 @@ // - route-based validation // - etc. + $app->pipe(IpAddress::class); + // Register the dispatch middleware in the middleware pipeline $app->pipe(DispatchMiddleware::class); diff --git a/src/App/src/ConfigProvider.php b/src/App/src/ConfigProvider.php index f12733ce..53a7dd6c 100644 --- a/src/App/src/ConfigProvider.php +++ b/src/App/src/ConfigProvider.php @@ -33,7 +33,6 @@ use Light\App\Handler\GetPackagesViewHandler; use Light\App\Handler\GetSitemapViewHandler; use Light\App\Handler\PostCreateContactHandler; -use Light\App\Middleware\IpAddressMiddleware; use Light\App\Resolver\EntityListenerResolver; use Light\App\Service\ContactService; use Light\App\Service\FeedGenerator; @@ -150,7 +149,6 @@ public function getDependencies(): array GetCreateContactFormHandler::class => AttributedServiceFactory::class, PostCreateContactHandler::class => AttributedServiceFactory::class, ContactService::class => AttributedServiceFactory::class, - IpAddressMiddleware::class => AttributedServiceFactory::class, FeedGenerator::class => FeedGeneratorFactory::class, SitemapGenerator::class => SitemapGeneratorFactory::class, GitHubClient::class => GitHubClientFactory::class, diff --git a/src/App/src/Handler/PostCreateContactHandler.php b/src/App/src/Handler/PostCreateContactHandler.php index 51b3a87c..092cf32d 100644 --- a/src/App/src/Handler/PostCreateContactHandler.php +++ b/src/App/src/Handler/PostCreateContactHandler.php @@ -8,7 +8,6 @@ use Laminas\Diactoros\Response\HtmlResponse; use Laminas\Diactoros\Response\RedirectResponse; use Light\App\Enum\ContactTopicEnum; -use Light\App\Middleware\IpAddressMiddleware; use Light\App\Service\ContactService; use Mezzio\Helper\UrlHelper; use Mezzio\Template\TemplateRendererInterface; @@ -43,13 +42,13 @@ public function handle(ServerRequestInterface $request): ResponseInterface $token = is_array($rawBody) && is_string($rawBody['cf-turnstile-response'] ?? null) ? $rawBody['cf-turnstile-response'] : ''; - $ip = $request->getAttribute(IpAddressMiddleware::ATTRIBUTE); + $ip = $request->getAttribute('ip_address'); - if (! $this->contactService->verifyTurnstile($token, is_string($ip) ? $ip : null)) { + if (! $this->contactService->verifyTurnstile($token, $ip)) { $errors['captcha'] = "We couldn't verify you're human. Please try again."; } - if ($errors === [] && ! $this->contactService->send($data)) { + if ($errors === [] && ! $this->contactService->send($data, $ip)) { $mailFailed = true; } diff --git a/src/App/src/Middleware/IpAddressMiddleware.php b/src/App/src/Middleware/IpAddressMiddleware.php deleted file mode 100644 index ccec6c0c..00000000 --- a/src/App/src/Middleware/IpAddressMiddleware.php +++ /dev/null @@ -1,176 +0,0 @@ - $trustedProxies - * @param list $headers - */ - #[Inject( - 'config.ip_address.trusted_proxies', - 'config.ip_address.headers', - )] - public function __construct( - private readonly array $trustedProxies = [], - private readonly array $headers = ['Forwarded', 'X-Forwarded-For', 'X-Real-IP'], - ) { - } - - public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface - { - return $handler->handle($request->withAttribute(self::ATTRIBUTE, $this->determineIpAddress($request))); - } - - public function determineIpAddress(ServerRequestInterface $request): ?string - { - $remoteAddr = $this->normalize($request->getServerParams()['REMOTE_ADDR'] ?? null); - if ($remoteAddr === null || ! $this->isTrustedProxy($remoteAddr)) { - return $remoteAddr; - } - - foreach ($this->headers as $header) { - $ipAddress = $this->fromHeader($request, $header); - if ($ipAddress !== null) { - return $ipAddress; - } - } - - return $remoteAddr; - } - - private function fromHeader(ServerRequestInterface $request, string $header): ?string - { - $line = $request->getHeaderLine($header); - if ($line === '') { - return null; - } - - $hops = []; - foreach (explode(',', $line) as $hop) { - $hops[] = $this->normalize(strcasecmp($header, 'Forwarded') === 0 ? $this->forwardedFor($hop) : $hop); - } - - foreach (array_reverse($hops) as $hop) { - if ($hop === null) { - return null; - } - if (! $this->isTrustedProxy($hop)) { - return $hop; - } - } - - return $hops[0]; - } - - private function forwardedFor(string $element): string - { - foreach (explode(';', $element) as $pair) { - $parts = explode('=', $pair, 2); - if (isset($parts[1]) && strcasecmp(trim($parts[0]), 'for') === 0) { - return $parts[1]; - } - } - - return ''; - } - - private function normalize(mixed $value): ?string - { - if (! is_string($value)) { - return null; - } - - $value = trim(trim($value), '"'); - if (str_starts_with($value, '[')) { - $end = strpos($value, ']'); - $value = $end === false ? '' : substr($value, 1, $end - 1); - } elseif (substr_count($value, ':') === 1) { - $value = substr($value, 0, (int) strrpos($value, ':')); - } - - return filter_var($value, FILTER_VALIDATE_IP) === false ? null : $value; - } - - private function isTrustedProxy(string $ipAddress): bool - { - foreach ($this->trustedProxies as $proxy) { - if ($this->matches($ipAddress, $proxy)) { - return true; - } - } - - return false; - } - - private function matches(string $ipAddress, string $proxy): bool - { - $subnet = $proxy; - $bits = null; - if (str_contains($proxy, '/')) { - [$subnet, $bits] = explode('/', $proxy, 2); - } - - $address = @inet_pton($ipAddress); - $network = @inet_pton($subnet); - if ($address === false || $network === false || strlen($address) !== strlen($network)) { - return false; - } - - if ($bits === null) { - return $address === $network; - } - - if (! ctype_digit($bits) || (int) $bits > strlen($address) * 8) { - return false; - } - - $bytes = intdiv((int) $bits, 8); - if (strncmp($address, $network, $bytes) !== 0) { - return false; - } - - $remainder = (int) $bits % 8; - if ($remainder === 0) { - return true; - } - - $mask = (0xFF << (8 - $remainder)) & 0xFF; - $addressBits = ord($address[$bytes]) & $mask; - $networkBits = ord($network[$bytes]) & $mask; - - return $addressBits === $networkBits; - } -} diff --git a/src/App/src/Service/ContactService.php b/src/App/src/Service/ContactService.php index 83ded42e..fecc1a4d 100644 --- a/src/App/src/Service/ContactService.php +++ b/src/App/src/Service/ContactService.php @@ -176,11 +176,11 @@ public function validate(array $data): array /** * @param ContactData $data */ - public function send(array $data): bool + public function send(array $data, ?string $ipAddress = null): bool { $this->mailService->getMessage()->setReplyTo($data['email'], $data['name']); $this->mailService->setSubject('New contact form submission on dotkernel.com'); - $this->mailService->setBody($this->buildBody($data)); + $this->mailService->setBody($this->buildBody($data, $ipAddress)); try { $result = $this->mailService->send(); @@ -237,7 +237,7 @@ private function stringValue(array $raw, string $key): string /** * @param ContactData $data */ - private function buildBody(array $data): string + private function buildBody(array $data, ?string $ipAddress): string { $rows = [ 'Topic' => ContactTopicEnum::tryFrom($data['topic'])?->label() ?? '', @@ -247,6 +247,7 @@ private function buildBody(array $data): string 'Current stack' => $data['stack'], 'Message' => $data['message'], 'Query params' => $data['query'], + 'IP address' => $ipAddress ?? '', ]; $html = ''; diff --git a/test/Unit/App/Handler/PostCreateContactHandlerTest.php b/test/Unit/App/Handler/PostCreateContactHandlerTest.php index 093e1cb7..d70359ae 100644 --- a/test/Unit/App/Handler/PostCreateContactHandlerTest.php +++ b/test/Unit/App/Handler/PostCreateContactHandlerTest.php @@ -12,7 +12,6 @@ use Laminas\Diactoros\Response\RedirectResponse; use Light\App\Enum\ContactTopicEnum; use Light\App\Handler\PostCreateContactHandler; -use Light\App\Middleware\IpAddressMiddleware; use Light\App\Service\ContactService; use Mezzio\Helper\UrlHelper; use Mezzio\Template\TemplateRendererInterface; @@ -198,7 +197,7 @@ public function testFailedTurnstileRerendersWithCaptchaErrorAndSendsNothing(): v $request = $this->createStub(ServerRequestInterface::class); $request->method('getParsedBody')->willReturn($data); - $request->method('getAttribute')->willReturnMap([[IpAddressMiddleware::ATTRIBUTE, null, '203.0.113.5']]); + $request->method('getAttribute')->willReturnMap([['ip_address', null, '203.0.113.5']]); $contactService = $this->createMock(ContactService::class); $contactService->method('normalize')->willReturn([ @@ -239,4 +238,43 @@ public function testFailedTurnstileRerendersWithCaptchaErrorAndSendsNothing(): v $this->assertInstanceOf(HtmlResponse::class, $response); $this->assertSame(200, $response->getStatusCode()); } + + /** + * @throws Exception + */ + public function testValidSubmissionSendsTheIpAddressFromTheRequestAttribute(): void + { + $request = $this->createStub(ServerRequestInterface::class); + $request->method('getParsedBody')->willReturn($this->validData()); + $request->method('getAttribute')->willReturnMap([['ip_address', null, '203.0.113.5']]); + + $contactService = $this->createMock(ContactService::class); + $contactService->method('normalize')->willReturn([ + 'topic' => 'migration', + 'name' => 'Jane Doe', + 'email' => 'jane@example.com', + 'company' => 'Acme Inc', + 'stack' => 'ZF3, PHP 7.4', + 'message' => 'We would like to talk about migrating our platform.', + 'query' => '', + ]); + $contactService->method('validate')->willReturn([]); + $contactService->method('verifyTurnstile')->willReturn(true); + $contactService + ->expects($this->once()) + ->method('send') + ->with($this->anything(), '203.0.113.5') + ->willReturn(true); + + $urlHelper = $this->createStub(UrlHelper::class); + $urlHelper->method('generate')->willReturn('/contact/?contact=sent#contact-form'); + + $response = (new PostCreateContactHandler( + $this->createStub(TemplateRendererInterface::class), + $urlHelper, + $contactService + ))->handle($request); + + $this->assertInstanceOf(RedirectResponse::class, $response); + } } diff --git a/test/Unit/App/Middleware/IpAddressMiddlewareTest.php b/test/Unit/App/Middleware/IpAddressMiddlewareTest.php deleted file mode 100644 index 9f0a0350..00000000 --- a/test/Unit/App/Middleware/IpAddressMiddlewareTest.php +++ /dev/null @@ -1,203 +0,0 @@ - $headers - */ - private function request(?string $remoteAddr, array $headers = []): ServerRequest - { - $serverParams = $remoteAddr === null ? [] : ['REMOTE_ADDR' => $remoteAddr]; - - return new ServerRequest($serverParams, [], null, 'GET', 'php://memory', $headers); - } - - /** - * @throws Exception - */ - public function testProcessStoresTheIpAddressAsARequestAttribute(): void - { - $handler = $this->createMock(RequestHandlerInterface::class); - $handler - ->expects($this->once()) - ->method('handle') - ->with($this->callback( - fn (ServerRequestInterface $request): bool => $request->getAttribute('ip_address') === '198.51.100.7' - )) - ->willReturn(new Response()); - - (new IpAddressMiddleware())->process($this->request('198.51.100.7'), $handler); - } - - public function testHeadersAreIgnoredWithoutTrustedProxies(): void - { - $request = $this->request('198.51.100.7', ['X-Forwarded-For' => '6.6.6.6']); - - $this->assertSame('198.51.100.7', (new IpAddressMiddleware())->determineIpAddress($request)); - } - - public function testHeadersAreIgnoredWhenRemoteAddrIsNotATrustedProxy(): void - { - $request = $this->request('198.51.100.7', ['X-Forwarded-For' => '6.6.6.6']); - - $this->assertSame('198.51.100.7', (new IpAddressMiddleware(['10.0.0.1']))->determineIpAddress($request)); - } - - public function testMissingRemoteAddrGivesNull(): void - { - $this->assertNull((new IpAddressMiddleware(['10.0.0.1']))->determineIpAddress($this->request(null))); - } - - public function testInvalidRemoteAddrGivesNull(): void - { - $this->assertNull((new IpAddressMiddleware())->determineIpAddress($this->request('not-an-ip'))); - } - - /** - * @return array, string, array, string}> - */ - public static function trustedProxyProvider(): array - { - return [ - 'exact proxy' => [ - ['10.0.0.1'], - '10.0.0.1', - ['X-Forwarded-For' => '198.51.100.7'], - '198.51.100.7', - ], - 'IPv4 CIDR proxy' => [ - ['173.245.48.0/20'], - '173.245.60.1', - ['X-Forwarded-For' => '198.51.100.7'], - '198.51.100.7', - ], - 'IPv6 CIDR proxy' => [ - ['2400:cb00::/32'], - '2400:cb00:2049::1', - ['X-Forwarded-For' => '2001:db8::1'], - '2001:db8::1', - ], - 'spoofed address left of the proxy' => [ - ['10.0.0.1'], - '10.0.0.1', - ['X-Forwarded-For' => '6.6.6.6, 198.51.100.7'], - '198.51.100.7', - ], - 'chain of trusted proxies' => [ - ['10.0.0.0/8'], - '10.0.0.1', - ['X-Forwarded-For' => '198.51.100.7, 10.0.0.2'], - '198.51.100.7', - ], - 'only trusted proxies in the chain' => [ - ['10.0.0.0/8'], - '10.0.0.1', - ['X-Forwarded-For' => '10.0.0.3, 10.0.0.2'], - '10.0.0.3', - ], - 'IPv4 with port' => [ - ['10.0.0.1'], - '10.0.0.1', - ['X-Forwarded-For' => '198.51.100.7:4711'], - '198.51.100.7', - ], - 'Forwarded header' => [ - ['10.0.0.1'], - '10.0.0.1', - ['Forwarded' => 'for=198.51.100.7;proto=https;by=10.0.0.1'], - '198.51.100.7', - ], - 'Forwarded quoted IPv6 with port' => [ - ['10.0.0.1'], - '10.0.0.1', - ['Forwarded' => 'for="[2001:db8:cafe::17]:4711"'], - '2001:db8:cafe::17', - ], - 'Forwarded wins over X-Forwarded-For' => [ - ['10.0.0.1'], - '10.0.0.1', - ['Forwarded' => 'for=198.51.100.7', 'X-Forwarded-For' => '203.0.113.9'], - '198.51.100.7', - ], - 'X-Real-IP' => [ - ['10.0.0.1'], - '10.0.0.1', - ['X-Real-IP' => '198.51.100.7'], - '198.51.100.7', - ], - 'invalid hop falls back to the next header' => [ - ['10.0.0.1'], - '10.0.0.1', - ['X-Forwarded-For' => '198.51.100.7, garbage', 'X-Real-IP' => '203.0.113.9'], - '203.0.113.9', - ], - 'no usable header keeps REMOTE_ADDR' => [ - ['10.0.0.1'], - '10.0.0.1', - ['X-Forwarded-For' => 'garbage'], - '10.0.0.1', - ], - ]; - } - - /** - * @param list $trustedProxies - * @param array $headers - */ - #[DataProvider('trustedProxyProvider')] - public function testTrustedProxyHeadersGiveTheClientAddress( - array $trustedProxies, - string $remoteAddr, - array $headers, - string $expected, - ): void { - $middleware = new IpAddressMiddleware($trustedProxies); - - $this->assertSame($expected, $middleware->determineIpAddress($this->request($remoteAddr, $headers))); - } - - public function testOnlyTheConfiguredHeadersAreRead(): void - { - $middleware = new IpAddressMiddleware(['10.0.0.1'], ['CF-Connecting-IP']); - $request = $this->request('10.0.0.1', [ - 'X-Forwarded-For' => '6.6.6.6', - 'CF-Connecting-IP' => '198.51.100.7', - ]); - - $this->assertSame('198.51.100.7', $middleware->determineIpAddress($request)); - } - - /** - * @return array - */ - public static function invalidProxyProvider(): array - { - return [ - 'mask too long' => ['10.0.0.0/33'], - 'mask not a number' => ['10.0.0.0/abc'], - 'not an address' => ['proxy.example'], - 'other IP family' => ['::/0'], - ]; - } - - #[DataProvider('invalidProxyProvider')] - public function testInvalidTrustedProxyEntriesTrustNothing(string $proxy): void - { - $request = $this->request('10.0.0.1', ['X-Forwarded-For' => '198.51.100.7']); - - $this->assertSame('10.0.0.1', (new IpAddressMiddleware([$proxy]))->determineIpAddress($request)); - } -} diff --git a/test/Unit/App/Service/ContactServiceTest.php b/test/Unit/App/Service/ContactServiceTest.php index a9d58aca..62e22152 100644 --- a/test/Unit/App/Service/ContactServiceTest.php +++ b/test/Unit/App/Service/ContactServiceTest.php @@ -280,6 +280,38 @@ public function testSendReturnsFalseWhenResultIsInvalid(): void $this->assertFalse($service->send($this->validData())); } + public function testSendIncludesTheIpAddressInBody(): void + { + $message = $this->createStub(Email::class); + + $mailService = $this->createMock(MailServiceInterface::class); + $mailService->method('getMessage')->willReturn($message); + $mailService + ->expects($this->once()) + ->method('setBody') + ->with($this->callback( + fn (string $body): bool => str_contains($body, '

IP address:
203.0.113.5

') + )); + $mailService->method('send')->willReturn(new MailResult(true)); + + (new ContactService($mailService, new NullLogger()))->send($this->validData(), '203.0.113.5'); + } + + public function testSendOmitsTheIpAddressRowWhenUnknown(): void + { + $message = $this->createStub(Email::class); + + $mailService = $this->createMock(MailServiceInterface::class); + $mailService->method('getMessage')->willReturn($message); + $mailService + ->expects($this->once()) + ->method('setBody') + ->with($this->callback(fn (string $body): bool => ! str_contains($body, 'IP address'))); + $mailService->method('send')->willReturn(new MailResult(true)); + + (new ContactService($mailService, new NullLogger()))->send($this->validData()); + } + public function testSendIncludesQueryParamsInBodyWhenPresent(): void { $message = $this->createStub(Email::class); From cfc0a59ead9d554a1993d18d9af96d14cd6da92c Mon Sep 17 00:00:00 2001 From: OStefan2001 Date: Thu, 15 Oct 2026 17:36:30 +0300 Subject: [PATCH 04/11] update --- src/App/src/Handler/PostCreateContactHandler.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/App/src/Handler/PostCreateContactHandler.php b/src/App/src/Handler/PostCreateContactHandler.php index 092cf32d..6942a6af 100644 --- a/src/App/src/Handler/PostCreateContactHandler.php +++ b/src/App/src/Handler/PostCreateContactHandler.php @@ -24,11 +24,13 @@ class PostCreateContactHandler implements RequestHandlerInterface TemplateRendererInterface::class, UrlHelper::class, ContactService::class, + 'config', )] public function __construct( protected TemplateRendererInterface $template, protected UrlHelper $urlHelper, protected ContactService $contactService, + protected array $config, ) { } @@ -42,7 +44,7 @@ public function handle(ServerRequestInterface $request): ResponseInterface $token = is_array($rawBody) && is_string($rawBody['cf-turnstile-response'] ?? null) ? $rawBody['cf-turnstile-response'] : ''; - $ip = $request->getAttribute('ip_address'); + $ip = $request->getAttribute($this->config['rka']['ip_address']['attribute_name'] ?? 'ip_address'); if (! $this->contactService->verifyTurnstile($token, $ip)) { $errors['captcha'] = "We couldn't verify you're human. Please try again."; From 4c132bcefe28e8f75149a8644efe6e5d57bcbc6a Mon Sep 17 00:00:00 2001 From: OStefan2001 Date: Fri, 16 Oct 2026 10:16:58 +0300 Subject: [PATCH 05/11] update ip address configuration and middleware --- config/autoload/ip-address.global.php | 31 +++++++++++++++++++ config/autoload/ip_address.global.php | 14 --------- .../src/Handler/PostCreateContactHandler.php | 6 ++-- 3 files changed, 34 insertions(+), 17 deletions(-) create mode 100644 config/autoload/ip-address.global.php delete mode 100644 config/autoload/ip_address.global.php diff --git a/config/autoload/ip-address.global.php b/config/autoload/ip-address.global.php new file mode 100644 index 00000000..8dc4828b --- /dev/null +++ b/config/autoload/ip-address.global.php @@ -0,0 +1,31 @@ + [ +// 'ip_address' => [ +// 'check_proxy_headers' => false, +// 'trusted_proxies' => [], +// 'attribute_name' => 'ip_address', +// 'headers_to_inspect' => [], +// ], +// ], +// ]; + +return [ + 'rka' => [ + 'ip_address' => [ + 'check_proxy_headers' => true, + 'trusted_proxies' => [], + 'attribute_name' => 'ip_address', + 'headers_to_inspect' => [ + 'Forwarded', + 'X-Forwarded-For', + 'X-Forwarded', + 'X-Cluster-Client-Ip', + 'Client-Ip', + ], + ], + ], +]; diff --git a/config/autoload/ip_address.global.php b/config/autoload/ip_address.global.php deleted file mode 100644 index 5980ee89..00000000 --- a/config/autoload/ip_address.global.php +++ /dev/null @@ -1,14 +0,0 @@ - [ - 'ip_address' => [ - 'check_proxy_headers' => false, - 'trusted_proxies' => [], - 'attribute_name' => 'ip_address', - 'headers_to_inspect' => [], - ], - ], -]; diff --git a/src/App/src/Handler/PostCreateContactHandler.php b/src/App/src/Handler/PostCreateContactHandler.php index 6942a6af..aa8cf7d2 100644 --- a/src/App/src/Handler/PostCreateContactHandler.php +++ b/src/App/src/Handler/PostCreateContactHandler.php @@ -24,13 +24,13 @@ class PostCreateContactHandler implements RequestHandlerInterface TemplateRendererInterface::class, UrlHelper::class, ContactService::class, - 'config', + 'config.rka.ip_address', )] public function __construct( protected TemplateRendererInterface $template, protected UrlHelper $urlHelper, protected ContactService $contactService, - protected array $config, + protected array $ipAddressConfig, ) { } @@ -44,7 +44,7 @@ public function handle(ServerRequestInterface $request): ResponseInterface $token = is_array($rawBody) && is_string($rawBody['cf-turnstile-response'] ?? null) ? $rawBody['cf-turnstile-response'] : ''; - $ip = $request->getAttribute($this->config['rka']['ip_address']['attribute_name'] ?? 'ip_address'); + $ip = $request->getAttribute($this->ipAddressConfig['attribute_name']); if (! $this->contactService->verifyTurnstile($token, $ip)) { $errors['captcha'] = "We couldn't verify you're human. Please try again."; From b9eb47989b1d19851b360bff31a4b1b3fb896b13 Mon Sep 17 00:00:00 2001 From: OStefan2001 Date: Fri, 16 Oct 2026 10:20:04 +0300 Subject: [PATCH 06/11] test and static-analysis fix --- .../src/Handler/PostCreateContactHandler.php | 3 ++ .../Handler/PostCreateContactHandlerTest.php | 44 ++++++++++++++++--- 2 files changed, 41 insertions(+), 6 deletions(-) diff --git a/src/App/src/Handler/PostCreateContactHandler.php b/src/App/src/Handler/PostCreateContactHandler.php index aa8cf7d2..a71f341f 100644 --- a/src/App/src/Handler/PostCreateContactHandler.php +++ b/src/App/src/Handler/PostCreateContactHandler.php @@ -20,6 +20,9 @@ class PostCreateContactHandler implements RequestHandlerInterface { + /** + * @param array{check_proxy_headers: bool, trusted_proxies: array, attribute_name: non-empty-string, headers_to_inspect: array} $ipAddressConfig + */ #[Inject( TemplateRendererInterface::class, UrlHelper::class, diff --git a/test/Unit/App/Handler/PostCreateContactHandlerTest.php b/test/Unit/App/Handler/PostCreateContactHandlerTest.php index d70359ae..54b94721 100644 --- a/test/Unit/App/Handler/PostCreateContactHandlerTest.php +++ b/test/Unit/App/Handler/PostCreateContactHandlerTest.php @@ -26,6 +26,24 @@ class PostCreateContactHandlerTest extends TestCase { + /** + * @return array{ + * check_proxy_headers: bool, + * trusted_proxies: array, + * attribute_name: non-empty-string, + * headers_to_inspect: array, + * } + */ + private function ipAddressConfig(): array + { + return [ + 'check_proxy_headers' => false, + 'trusted_proxies' => [], + 'attribute_name' => 'ip_address', + 'headers_to_inspect' => [], + ]; + } + /** * @return array */ @@ -74,7 +92,12 @@ public function testValidSubmissionRedirectsOnSuccess(): void ->with('app::create-contact-form', [], ['contact' => 'sent'], 'contact-form') ->willReturn('/contact/?contact=sent#contact-form'); - $handler = new PostCreateContactHandler($template, $urlHelper, $this->service(new MailResult(true))); + $handler = new PostCreateContactHandler( + $template, + $urlHelper, + $this->service(new MailResult(true)), + $this->ipAddressConfig() + ); $response = $handler->handle($request); $this->assertInstanceOf(RedirectResponse::class, $response); @@ -113,7 +136,8 @@ public function testMissingRequiredFieldsRerendersWithErrors(): void $handler = new PostCreateContactHandler( $template, $this->createStub(UrlHelper::class), - $this->service(new MailResult(true)) + $this->service(new MailResult(true)), + $this->ipAddressConfig() ); $response = $handler->handle($request); @@ -150,7 +174,8 @@ public function testRerenderKeepsAnyExtraQueryParamsAsDynamicHiddenFields(): voi $handler = new PostCreateContactHandler( $template, $this->createStub(UrlHelper::class), - $this->service(new MailResult(true)) + $this->service(new MailResult(true)), + $this->ipAddressConfig() ); $handler->handle($request); } @@ -179,7 +204,8 @@ public function testMailTransportFailureRerendersWithMailFailedFlag(): void $handler = new PostCreateContactHandler( $template, $this->createStub(UrlHelper::class), - $this->service(new RuntimeException('SMTP connection refused')) + $this->service(new RuntimeException('SMTP connection refused')), + $this->ipAddressConfig() ); $response = $handler->handle($request); @@ -232,7 +258,12 @@ public function testFailedTurnstileRerendersWithCaptchaErrorAndSendsNothing(): v ) ->willReturn(''); - $handler = new PostCreateContactHandler($template, $this->createStub(UrlHelper::class), $contactService); + $handler = new PostCreateContactHandler( + $template, + $this->createStub(UrlHelper::class), + $contactService, + $this->ipAddressConfig() + ); $response = $handler->handle($request); $this->assertInstanceOf(HtmlResponse::class, $response); @@ -272,7 +303,8 @@ public function testValidSubmissionSendsTheIpAddressFromTheRequestAttribute(): v $response = (new PostCreateContactHandler( $this->createStub(TemplateRendererInterface::class), $urlHelper, - $contactService + $contactService, + $this->ipAddressConfig() ))->handle($request); $this->assertInstanceOf(RedirectResponse::class, $response); From bbf4e5c856b6eb0d27bfa767ebd7d69b16b94bd2 Mon Sep 17 00:00:00 2001 From: OStefan2001 Date: Fri, 16 Oct 2026 10:20:54 +0300 Subject: [PATCH 07/11] test and static-analysis fix --- src/App/src/Handler/PostCreateContactHandler.php | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/App/src/Handler/PostCreateContactHandler.php b/src/App/src/Handler/PostCreateContactHandler.php index a71f341f..5f1a43f4 100644 --- a/src/App/src/Handler/PostCreateContactHandler.php +++ b/src/App/src/Handler/PostCreateContactHandler.php @@ -18,10 +18,18 @@ use function is_array; use function is_string; +/** + * @phpstan-type IpAddressConfigType array{ + * check_proxy_headers: bool, + * trusted_proxies: array, + * attribute_name: non-empty-string, + * headers_to_inspect: array, + * } + */ class PostCreateContactHandler implements RequestHandlerInterface { /** - * @param array{check_proxy_headers: bool, trusted_proxies: array, attribute_name: non-empty-string, headers_to_inspect: array} $ipAddressConfig + * @param IpAddressConfigType $ipAddressConfig */ #[Inject( TemplateRendererInterface::class, From 9437a30ac2a4aae5d27ccf860d6e76517e1c794d Mon Sep 17 00:00:00 2001 From: OStefan2001 Date: Sun, 18 Oct 2026 20:25:01 +0300 Subject: [PATCH 08/11] Use code recommendation --- config/autoload/ip-address.global.php | 19 +++++++++++++++---- config/pipeline.php | 3 --- src/App/src/RoutesDelegator.php | 3 ++- test/Unit/App/RoutesDelegatorTest.php | 6 +++++- 4 files changed, 22 insertions(+), 9 deletions(-) diff --git a/config/autoload/ip-address.global.php b/config/autoload/ip-address.global.php index 8dc4828b..a53477e5 100644 --- a/config/autoload/ip-address.global.php +++ b/config/autoload/ip-address.global.php @@ -13,15 +13,26 @@ // ], // ]; +// @see https://github.com/akrabat/ip-address-middleware/blob/main/src/IpAddress.php return [ 'rka' => [ 'ip_address' => [ + // Inspect proxy headers below instead of using REMOTE_ADDR only. 'check_proxy_headers' => true, - 'trusted_proxies' => [], - 'attribute_name' => 'ip_address', - 'headers_to_inspect' => [ - 'Forwarded', + // Proxy IPs/CIDRs/wildcards trusted to set the headers below. + 'trusted_proxies' => [], + // Request attribute the resolved IP is stored under. + 'attribute_name' => 'ip_address', + // Headers checked, in order, for the client IP. + 'headers_to_inspect' => [ + // nginx specific + 'X-Real-IP', 'X-Forwarded-For', + // cloudflare specific + 'CF-Connecting-IP', + 'True-Client-IP', + // generic + 'Forwarded', 'X-Forwarded', 'X-Cluster-Client-Ip', 'Client-Ip', diff --git a/config/pipeline.php b/config/pipeline.php index 5500b015..6640698a 100644 --- a/config/pipeline.php +++ b/config/pipeline.php @@ -13,7 +13,6 @@ use Mezzio\Router\Middleware\ImplicitOptionsMiddleware; use Mezzio\Router\Middleware\MethodNotAllowedMiddleware; use Mezzio\Router\Middleware\RouteMiddleware; -use RKA\Middleware\IpAddress; return function (Application $app): void { // The error handler should be the first (most outer) middleware to catch @@ -64,8 +63,6 @@ // - route-based validation // - etc. - $app->pipe(IpAddress::class); - // Register the dispatch middleware in the middleware pipeline $app->pipe(DispatchMiddleware::class); diff --git a/src/App/src/RoutesDelegator.php b/src/App/src/RoutesDelegator.php index be6e8c3e..fb4cce63 100644 --- a/src/App/src/RoutesDelegator.php +++ b/src/App/src/RoutesDelegator.php @@ -13,6 +13,7 @@ use Light\App\Handler\PostCreateContactHandler; use Mezzio\Application; use Psr\Container\ContainerInterface; +use RKA\Middleware\IpAddress; use function assert; @@ -28,7 +29,7 @@ public function __invoke(ContainerInterface $container, string $serviceName, cal $app->get('/{categorySlug}/{slug}.md', [GetMarkdownArticleHandler::class], 'app::markdown-article'); $app->get('/contact/', [GetCreateContactFormHandler::class], 'app::create-contact-form'); - $app->post('/contact/', [PostCreateContactHandler::class], 'app::create-contact'); + $app->post('/contact/', [IpAddress::class, PostCreateContactHandler::class], 'app::create-contact'); // Route name kept as `page::…` because `@layout/default.html.twig` links it by name. // The matching entry must stay out of `routes.page` in local.php to avoid a duplicate. diff --git a/test/Unit/App/RoutesDelegatorTest.php b/test/Unit/App/RoutesDelegatorTest.php index 51b96bac..15c207e0 100644 --- a/test/Unit/App/RoutesDelegatorTest.php +++ b/test/Unit/App/RoutesDelegatorTest.php @@ -19,6 +19,7 @@ use Psr\Container\ContainerExceptionInterface; use Psr\Container\ContainerInterface; use Psr\Container\NotFoundExceptionInterface; +use RKA\Middleware\IpAddress; class RoutesDelegatorTest extends UnitTest { @@ -77,7 +78,10 @@ public function testWillRegisterAllRoutes(): void $this->assertSame([GetCreateContactFormHandler::class], $this->registeredRoutes['/contact/']['handler']); $this->assertSame('app::create-contact-form', $this->registeredRoutes['/contact/']['name']); - $this->assertSame([PostCreateContactHandler::class], $this->registeredRoutes['POST /contact/']['handler']); + $this->assertSame( + [IpAddress::class, PostCreateContactHandler::class], + $this->registeredRoutes['POST /contact/']['handler'] + ); $this->assertSame('app::create-contact', $this->registeredRoutes['POST /contact/']['name']); } } From 11558e344a79902b5184486d60f1c4bded382990 Mon Sep 17 00:00:00 2001 From: OStefan2001 Date: Mon, 19 Oct 2026 12:21:26 +0300 Subject: [PATCH 09/11] fix: update ip --- config/autoload/ip-address.global.php | 12 +----------- src/App/src/Service/ContactService.php | 2 +- .../App/Handler/PostCreateContactHandlerTest.php | 6 +++--- 3 files changed, 5 insertions(+), 15 deletions(-) diff --git a/config/autoload/ip-address.global.php b/config/autoload/ip-address.global.php index a53477e5..443a7e29 100644 --- a/config/autoload/ip-address.global.php +++ b/config/autoload/ip-address.global.php @@ -2,16 +2,6 @@ declare(strict_types=1); -// return [ -// 'rka' => [ -// 'ip_address' => [ -// 'check_proxy_headers' => false, -// 'trusted_proxies' => [], -// 'attribute_name' => 'ip_address', -// 'headers_to_inspect' => [], -// ], -// ], -// ]; // @see https://github.com/akrabat/ip-address-middleware/blob/main/src/IpAddress.php return [ @@ -22,7 +12,7 @@ // Proxy IPs/CIDRs/wildcards trusted to set the headers below. 'trusted_proxies' => [], // Request attribute the resolved IP is stored under. - 'attribute_name' => 'ip_address', + 'attribute_name' => 'ipAddress', // Headers checked, in order, for the client IP. 'headers_to_inspect' => [ // nginx specific diff --git a/src/App/src/Service/ContactService.php b/src/App/src/Service/ContactService.php index fecc1a4d..58dd8af2 100644 --- a/src/App/src/Service/ContactService.php +++ b/src/App/src/Service/ContactService.php @@ -247,7 +247,7 @@ private function buildBody(array $data, ?string $ipAddress): string 'Current stack' => $data['stack'], 'Message' => $data['message'], 'Query params' => $data['query'], - 'IP address' => $ipAddress ?? '', + 'IP address' => $ipAddress, ]; $html = ''; diff --git a/test/Unit/App/Handler/PostCreateContactHandlerTest.php b/test/Unit/App/Handler/PostCreateContactHandlerTest.php index 54b94721..9166de61 100644 --- a/test/Unit/App/Handler/PostCreateContactHandlerTest.php +++ b/test/Unit/App/Handler/PostCreateContactHandlerTest.php @@ -39,7 +39,7 @@ private function ipAddressConfig(): array return [ 'check_proxy_headers' => false, 'trusted_proxies' => [], - 'attribute_name' => 'ip_address', + 'attribute_name' => 'ipAddress', 'headers_to_inspect' => [], ]; } @@ -223,7 +223,7 @@ public function testFailedTurnstileRerendersWithCaptchaErrorAndSendsNothing(): v $request = $this->createStub(ServerRequestInterface::class); $request->method('getParsedBody')->willReturn($data); - $request->method('getAttribute')->willReturnMap([['ip_address', null, '203.0.113.5']]); + $request->method('getAttribute')->willReturnMap([['ipAddress', null, '203.0.113.5']]); $contactService = $this->createMock(ContactService::class); $contactService->method('normalize')->willReturn([ @@ -277,7 +277,7 @@ public function testValidSubmissionSendsTheIpAddressFromTheRequestAttribute(): v { $request = $this->createStub(ServerRequestInterface::class); $request->method('getParsedBody')->willReturn($this->validData()); - $request->method('getAttribute')->willReturnMap([['ip_address', null, '203.0.113.5']]); + $request->method('getAttribute')->willReturnMap([['ipAddress', null, '203.0.113.5']]); $contactService = $this->createMock(ContactService::class); $contactService->method('normalize')->willReturn([ From d7b77760b8d3a7da1a1eae98b667cb34576b03a0 Mon Sep 17 00:00:00 2001 From: OStefan2001 Date: Mon, 19 Oct 2026 12:29:45 +0300 Subject: [PATCH 10/11] cs and analysis update --- config/autoload/ip-address.global.php | 1 - src/App/src/Service/ContactService.php | 8 ++++---- test/Unit/App/Service/ContactServiceTest.php | 14 +++++++------- 3 files changed, 11 insertions(+), 12 deletions(-) diff --git a/config/autoload/ip-address.global.php b/config/autoload/ip-address.global.php index 443a7e29..d0c0f2c5 100644 --- a/config/autoload/ip-address.global.php +++ b/config/autoload/ip-address.global.php @@ -2,7 +2,6 @@ declare(strict_types=1); - // @see https://github.com/akrabat/ip-address-middleware/blob/main/src/IpAddress.php return [ 'rka' => [ diff --git a/src/App/src/Service/ContactService.php b/src/App/src/Service/ContactService.php index 58dd8af2..9f43fbaa 100644 --- a/src/App/src/Service/ContactService.php +++ b/src/App/src/Service/ContactService.php @@ -79,7 +79,7 @@ public function turnstileSiteKey(): string return $this->turnstile['secret_key'] !== '' ? $this->turnstile['site_key'] : ''; } - public function verifyTurnstile(string $token, ?string $remoteIp): bool + public function verifyTurnstile(string $token, string $remoteIp): bool { if ($this->turnstileSiteKey() === '') { return true; @@ -93,7 +93,7 @@ public function verifyTurnstile(string $token, ?string $remoteIp): bool 'secret' => $this->turnstile['secret_key'], 'response' => $token, ]; - if ($remoteIp) { + if ($remoteIp !== '') { $data['remoteip'] = $remoteIp; } @@ -176,7 +176,7 @@ public function validate(array $data): array /** * @param ContactData $data */ - public function send(array $data, ?string $ipAddress = null): bool + public function send(array $data, string $ipAddress = ''): bool { $this->mailService->getMessage()->setReplyTo($data['email'], $data['name']); $this->mailService->setSubject('New contact form submission on dotkernel.com'); @@ -237,7 +237,7 @@ private function stringValue(array $raw, string $key): string /** * @param ContactData $data */ - private function buildBody(array $data, ?string $ipAddress): string + private function buildBody(array $data, string $ipAddress): string { $rows = [ 'Topic' => ContactTopicEnum::tryFrom($data['topic'])?->label() ?? '', diff --git a/test/Unit/App/Service/ContactServiceTest.php b/test/Unit/App/Service/ContactServiceTest.php index 62e22152..209c52b9 100644 --- a/test/Unit/App/Service/ContactServiceTest.php +++ b/test/Unit/App/Service/ContactServiceTest.php @@ -391,7 +391,7 @@ public function testTurnstileIsOffWithEmptyKeys(): void ); $this->assertSame('', $service->turnstileSiteKey()); - $this->assertTrue($service->verifyTurnstile('', null)); + $this->assertTrue($service->verifyTurnstile('', '')); $this->assertSame([], $calls); } @@ -405,14 +405,14 @@ public function testTurnstileIsOffWithoutASecretKey(): void ); $this->assertSame('', $service->turnstileSiteKey()); - $this->assertTrue($service->verifyTurnstile('', null)); + $this->assertTrue($service->verifyTurnstile('', '')); $this->assertSame([], $calls); } public function testTurnstileIsOffByDefault(): void { $this->assertSame('', $this->service()->turnstileSiteKey()); - $this->assertTrue($this->service()->verifyTurnstile('', null)); + $this->assertTrue($this->service()->verifyTurnstile('', '')); } public function testTurnstileSiteKeyIsExposedWhenBothKeysAreSet(): void @@ -449,7 +449,7 @@ public function testRemoteIpIsOmittedWhenNull(): void $calls = []; $service = $this->turnstileService(self::TURNSTILE, self::SITEVERIFY_SUCCESS, $calls); - $this->assertTrue($service->verifyTurnstile('token', null)); + $this->assertTrue($service->verifyTurnstile('token', '')); $this->assertSame([['secret' => 'secret-key', 'response' => 'token']], $calls); } @@ -476,7 +476,7 @@ public static function rejectedSiteverifyProvider(): array #[DataProvider('rejectedSiteverifyProvider')] public function testRejectedSiteverifyFails(array $turnstile, ?string $response): void { - $this->assertFalse($this->turnstileService($turnstile, $response)->verifyTurnstile('token', null)); + $this->assertFalse($this->turnstileService($turnstile, $response)->verifyTurnstile('token', '')); } public function testMatchingHostnamePasses(): void @@ -486,7 +486,7 @@ public function testMatchingHostnamePasses(): void self::SITEVERIFY_SUCCESS ); - $this->assertTrue($service->verifyTurnstile('token', null)); + $this->assertTrue($service->verifyTurnstile('token', '')); } public function testTestingKeyResponsePassesEvenWithAHostnameConfigured(): void @@ -496,6 +496,6 @@ public function testTestingKeyResponsePassesEvenWithAHostnameConfigured(): void '{"success":true,"hostname":"example.com","metadata":{"result_with_testing_key":true}}' ); - $this->assertTrue($service->verifyTurnstile('token', null)); + $this->assertTrue($service->verifyTurnstile('token', '')); } } From bc0f6ad6222eeb7e50f368a71e32929bc79c6f54 Mon Sep 17 00:00:00 2001 From: OStefan2001 Date: Mon, 19 Oct 2026 12:41:33 +0300 Subject: [PATCH 11/11] cs and analysis update --- src/App/src/Handler/PostCreateContactHandler.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/App/src/Handler/PostCreateContactHandler.php b/src/App/src/Handler/PostCreateContactHandler.php index 5f1a43f4..a7fb8294 100644 --- a/src/App/src/Handler/PostCreateContactHandler.php +++ b/src/App/src/Handler/PostCreateContactHandler.php @@ -55,7 +55,7 @@ public function handle(ServerRequestInterface $request): ResponseInterface $token = is_array($rawBody) && is_string($rawBody['cf-turnstile-response'] ?? null) ? $rawBody['cf-turnstile-response'] : ''; - $ip = $request->getAttribute($this->ipAddressConfig['attribute_name']); + $ip = (string) $request->getAttribute($this->ipAddressConfig['attribute_name']); if (! $this->contactService->verifyTurnstile($token, $ip)) { $errors['captcha'] = "We couldn't verify you're human. Please try again.";