Skip to content

Commit 1db2bc5

Browse files
authored
Merge pull request #278 from dotkernel/ip-detection-middleware
ip address middleware
2 parents 42af5d4 + bc0f6ad commit 1db2bc5

9 files changed

Lines changed: 178 additions & 23 deletions

File tree

‎composer.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
"php": "~8.5.0",
3030
"ext-curl": "*",
3131
"ext-dom": "*",
32+
"akrabat/ip-address-middleware": "^2.6",
3233
"doctrine/data-fixtures": "^2.2",
3334
"doctrine/doctrine-fixtures-bundle": "^4.3",
3435
"dotkernel/dot-cache": "^4.0",
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
// @see https://github.com/akrabat/ip-address-middleware/blob/main/src/IpAddress.php
6+
return [
7+
'rka' => [
8+
'ip_address' => [
9+
// Inspect proxy headers below instead of using REMOTE_ADDR only.
10+
'check_proxy_headers' => true,
11+
// Proxy IPs/CIDRs/wildcards trusted to set the headers below.
12+
'trusted_proxies' => [],
13+
// Request attribute the resolved IP is stored under.
14+
'attribute_name' => 'ipAddress',
15+
// Headers checked, in order, for the client IP.
16+
'headers_to_inspect' => [
17+
// nginx specific
18+
'X-Real-IP',
19+
'X-Forwarded-For',
20+
// cloudflare specific
21+
'CF-Connecting-IP',
22+
'True-Client-IP',
23+
// generic
24+
'Forwarded',
25+
'X-Forwarded',
26+
'X-Cluster-Client-Ip',
27+
'Client-Ip',
28+
],
29+
],
30+
],
31+
];

‎config/config.php‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
\Mezzio\Router\ConfigProvider::class,
2424
\Mezzio\Router\FastRouteRouter\ConfigProvider::class,
2525
\Mezzio\Twig\ConfigProvider::class,
26+
\RKA\Middleware\Mezzio\ConfigProvider::class,
2627

2728
// Dotkernel packages
2829
\Dot\DependencyInjection\ConfigProvider::class,

‎src/App/src/Handler/PostCreateContactHandler.php‎

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,17 +18,30 @@
1818
use function is_array;
1919
use function is_string;
2020

21+
/**
22+
* @phpstan-type IpAddressConfigType array{
23+
* check_proxy_headers: bool,
24+
* trusted_proxies: array<non-empty-string>,
25+
* attribute_name: non-empty-string,
26+
* headers_to_inspect: array<non-empty-string>,
27+
* }
28+
*/
2129
class PostCreateContactHandler implements RequestHandlerInterface
2230
{
31+
/**
32+
* @param IpAddressConfigType $ipAddressConfig
33+
*/
2334
#[Inject(
2435
TemplateRendererInterface::class,
2536
UrlHelper::class,
2637
ContactService::class,
38+
'config.rka.ip_address',
2739
)]
2840
public function __construct(
2941
protected TemplateRendererInterface $template,
3042
protected UrlHelper $urlHelper,
3143
protected ContactService $contactService,
44+
protected array $ipAddressConfig,
3245
) {
3346
}
3447

@@ -42,13 +55,13 @@ public function handle(ServerRequestInterface $request): ResponseInterface
4255
$token = is_array($rawBody) && is_string($rawBody['cf-turnstile-response'] ?? null)
4356
? $rawBody['cf-turnstile-response']
4457
: '';
45-
$ip = $request->getServerParams()['REMOTE_ADDR'] ?? null;
58+
$ip = (string) $request->getAttribute($this->ipAddressConfig['attribute_name']);
4659

47-
if (! $this->contactService->verifyTurnstile($token, is_string($ip) ? $ip : null)) {
60+
if (! $this->contactService->verifyTurnstile($token, $ip)) {
4861
$errors['captcha'] = "We couldn't verify you're human. Please try again.";
4962
}
5063

51-
if ($errors === [] && ! $this->contactService->send($data)) {
64+
if ($errors === [] && ! $this->contactService->send($data, $ip)) {
5265
$mailFailed = true;
5366
}
5467

‎src/App/src/RoutesDelegator.php‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
use Light\App\Handler\PostCreateContactHandler;
1414
use Mezzio\Application;
1515
use Psr\Container\ContainerInterface;
16+
use RKA\Middleware\IpAddress;
1617

1718
use function assert;
1819

@@ -28,7 +29,7 @@ public function __invoke(ContainerInterface $container, string $serviceName, cal
2829
$app->get('/{categorySlug}/{slug}.md', [GetMarkdownArticleHandler::class], 'app::markdown-article');
2930

3031
$app->get('/contact/', [GetCreateContactFormHandler::class], 'app::create-contact-form');
31-
$app->post('/contact/', [PostCreateContactHandler::class], 'app::create-contact');
32+
$app->post('/contact/', [IpAddress::class, PostCreateContactHandler::class], 'app::create-contact');
3233

3334
// Route name kept as `page::…` because `@layout/default.html.twig` links it by name.
3435
// The matching entry must stay out of `routes.page` in local.php to avoid a duplicate.

‎src/App/src/Service/ContactService.php‎

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ public function turnstileSiteKey(): string
7979
return $this->turnstile['secret_key'] !== '' ? $this->turnstile['site_key'] : '';
8080
}
8181

82-
public function verifyTurnstile(string $token, ?string $remoteIp): bool
82+
public function verifyTurnstile(string $token, string $remoteIp): bool
8383
{
8484
if ($this->turnstileSiteKey() === '') {
8585
return true;
@@ -93,7 +93,7 @@ public function verifyTurnstile(string $token, ?string $remoteIp): bool
9393
'secret' => $this->turnstile['secret_key'],
9494
'response' => $token,
9595
];
96-
if ($remoteIp) {
96+
if ($remoteIp !== '') {
9797
$data['remoteip'] = $remoteIp;
9898
}
9999

@@ -176,11 +176,11 @@ public function validate(array $data): array
176176
/**
177177
* @param ContactData $data
178178
*/
179-
public function send(array $data): bool
179+
public function send(array $data, string $ipAddress = ''): bool
180180
{
181181
$this->mailService->getMessage()->setReplyTo($data['email'], $data['name']);
182182
$this->mailService->setSubject('New contact form submission on dotkernel.com');
183-
$this->mailService->setBody($this->buildBody($data));
183+
$this->mailService->setBody($this->buildBody($data, $ipAddress));
184184

185185
try {
186186
$result = $this->mailService->send();
@@ -237,7 +237,7 @@ private function stringValue(array $raw, string $key): string
237237
/**
238238
* @param ContactData $data
239239
*/
240-
private function buildBody(array $data): string
240+
private function buildBody(array $data, string $ipAddress): string
241241
{
242242
$rows = [
243243
'Topic' => ContactTopicEnum::tryFrom($data['topic'])?->label() ?? '',
@@ -247,6 +247,7 @@ private function buildBody(array $data): string
247247
'Current stack' => $data['stack'],
248248
'Message' => $data['message'],
249249
'Query params' => $data['query'],
250+
'IP address' => $ipAddress,
250251
];
251252

252253
$html = '';

‎test/Unit/App/Handler/PostCreateContactHandlerTest.php‎

Lines changed: 77 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,24 @@
2626

2727
class PostCreateContactHandlerTest extends TestCase
2828
{
29+
/**
30+
* @return array{
31+
* check_proxy_headers: bool,
32+
* trusted_proxies: array<non-empty-string>,
33+
* attribute_name: non-empty-string,
34+
* headers_to_inspect: array<non-empty-string>,
35+
* }
36+
*/
37+
private function ipAddressConfig(): array
38+
{
39+
return [
40+
'check_proxy_headers' => false,
41+
'trusted_proxies' => [],
42+
'attribute_name' => 'ipAddress',
43+
'headers_to_inspect' => [],
44+
];
45+
}
46+
2947
/**
3048
* @return array<string, string>
3149
*/
@@ -74,7 +92,12 @@ public function testValidSubmissionRedirectsOnSuccess(): void
7492
->with('app::create-contact-form', [], ['contact' => 'sent'], 'contact-form')
7593
->willReturn('/contact/?contact=sent#contact-form');
7694

77-
$handler = new PostCreateContactHandler($template, $urlHelper, $this->service(new MailResult(true)));
95+
$handler = new PostCreateContactHandler(
96+
$template,
97+
$urlHelper,
98+
$this->service(new MailResult(true)),
99+
$this->ipAddressConfig()
100+
);
78101
$response = $handler->handle($request);
79102

80103
$this->assertInstanceOf(RedirectResponse::class, $response);
@@ -113,7 +136,8 @@ public function testMissingRequiredFieldsRerendersWithErrors(): void
113136
$handler = new PostCreateContactHandler(
114137
$template,
115138
$this->createStub(UrlHelper::class),
116-
$this->service(new MailResult(true))
139+
$this->service(new MailResult(true)),
140+
$this->ipAddressConfig()
117141
);
118142
$response = $handler->handle($request);
119143

@@ -150,7 +174,8 @@ public function testRerenderKeepsAnyExtraQueryParamsAsDynamicHiddenFields(): voi
150174
$handler = new PostCreateContactHandler(
151175
$template,
152176
$this->createStub(UrlHelper::class),
153-
$this->service(new MailResult(true))
177+
$this->service(new MailResult(true)),
178+
$this->ipAddressConfig()
154179
);
155180
$handler->handle($request);
156181
}
@@ -179,7 +204,8 @@ public function testMailTransportFailureRerendersWithMailFailedFlag(): void
179204
$handler = new PostCreateContactHandler(
180205
$template,
181206
$this->createStub(UrlHelper::class),
182-
$this->service(new RuntimeException('SMTP connection refused'))
207+
$this->service(new RuntimeException('SMTP connection refused')),
208+
$this->ipAddressConfig()
183209
);
184210
$response = $handler->handle($request);
185211

@@ -197,7 +223,7 @@ public function testFailedTurnstileRerendersWithCaptchaErrorAndSendsNothing(): v
197223

198224
$request = $this->createStub(ServerRequestInterface::class);
199225
$request->method('getParsedBody')->willReturn($data);
200-
$request->method('getServerParams')->willReturn(['REMOTE_ADDR' => '203.0.113.5']);
226+
$request->method('getAttribute')->willReturnMap([['ipAddress', null, '203.0.113.5']]);
201227

202228
$contactService = $this->createMock(ContactService::class);
203229
$contactService->method('normalize')->willReturn([
@@ -232,10 +258,55 @@ public function testFailedTurnstileRerendersWithCaptchaErrorAndSendsNothing(): v
232258
)
233259
->willReturn('<html></html>');
234260

235-
$handler = new PostCreateContactHandler($template, $this->createStub(UrlHelper::class), $contactService);
261+
$handler = new PostCreateContactHandler(
262+
$template,
263+
$this->createStub(UrlHelper::class),
264+
$contactService,
265+
$this->ipAddressConfig()
266+
);
236267
$response = $handler->handle($request);
237268

238269
$this->assertInstanceOf(HtmlResponse::class, $response);
239270
$this->assertSame(200, $response->getStatusCode());
240271
}
272+
273+
/**
274+
* @throws Exception
275+
*/
276+
public function testValidSubmissionSendsTheIpAddressFromTheRequestAttribute(): void
277+
{
278+
$request = $this->createStub(ServerRequestInterface::class);
279+
$request->method('getParsedBody')->willReturn($this->validData());
280+
$request->method('getAttribute')->willReturnMap([['ipAddress', null, '203.0.113.5']]);
281+
282+
$contactService = $this->createMock(ContactService::class);
283+
$contactService->method('normalize')->willReturn([
284+
'topic' => 'migration',
285+
'name' => 'Jane Doe',
286+
'email' => 'jane@example.com',
287+
'company' => 'Acme Inc',
288+
'stack' => 'ZF3, PHP 7.4',
289+
'message' => 'We would like to talk about migrating our platform.',
290+
'query' => '',
291+
]);
292+
$contactService->method('validate')->willReturn([]);
293+
$contactService->method('verifyTurnstile')->willReturn(true);
294+
$contactService
295+
->expects($this->once())
296+
->method('send')
297+
->with($this->anything(), '203.0.113.5')
298+
->willReturn(true);
299+
300+
$urlHelper = $this->createStub(UrlHelper::class);
301+
$urlHelper->method('generate')->willReturn('/contact/?contact=sent#contact-form');
302+
303+
$response = (new PostCreateContactHandler(
304+
$this->createStub(TemplateRendererInterface::class),
305+
$urlHelper,
306+
$contactService,
307+
$this->ipAddressConfig()
308+
))->handle($request);
309+
310+
$this->assertInstanceOf(RedirectResponse::class, $response);
311+
}
241312
}

‎test/Unit/App/RoutesDelegatorTest.php‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
use Psr\Container\ContainerExceptionInterface;
2020
use Psr\Container\ContainerInterface;
2121
use Psr\Container\NotFoundExceptionInterface;
22+
use RKA\Middleware\IpAddress;
2223

2324
class RoutesDelegatorTest extends UnitTest
2425
{
@@ -77,7 +78,10 @@ public function testWillRegisterAllRoutes(): void
7778
$this->assertSame([GetCreateContactFormHandler::class], $this->registeredRoutes['/contact/']['handler']);
7879
$this->assertSame('app::create-contact-form', $this->registeredRoutes['/contact/']['name']);
7980

80-
$this->assertSame([PostCreateContactHandler::class], $this->registeredRoutes['POST /contact/']['handler']);
81+
$this->assertSame(
82+
[IpAddress::class, PostCreateContactHandler::class],
83+
$this->registeredRoutes['POST /contact/']['handler']
84+
);
8185
$this->assertSame('app::create-contact', $this->registeredRoutes['POST /contact/']['name']);
8286
}
8387
}

0 commit comments

Comments
 (0)