Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
31 changes: 31 additions & 0 deletions config/autoload/ip-address.global.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?php

declare(strict_types=1);

// @see https://github.com/akrabat/ip-address-middleware/blob/main/src/IpAddress.php
return [
'rka' => [
'ip_address' => [
Comment thread
arhimede marked this conversation as resolved.
// Inspect proxy headers below instead of using REMOTE_ADDR only.
'check_proxy_headers' => true,
// Proxy IPs/CIDRs/wildcards trusted to set the headers below.
'trusted_proxies' => [],
// Request attribute the resolved IP is stored under.
'attribute_name' => 'ipAddress',
// 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',
],
],
],
];
1 change: 1 addition & 0 deletions config/config.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
19 changes: 16 additions & 3 deletions src/App/src/Handler/PostCreateContactHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,17 +18,30 @@
use function is_array;
use function is_string;

/**
* @phpstan-type IpAddressConfigType array{
* check_proxy_headers: bool,
* trusted_proxies: array<non-empty-string>,
* attribute_name: non-empty-string,
* headers_to_inspect: array<non-empty-string>,
* }
*/
class PostCreateContactHandler implements RequestHandlerInterface
{
/**
* @param IpAddressConfigType $ipAddressConfig
*/
#[Inject(
TemplateRendererInterface::class,
UrlHelper::class,
ContactService::class,
'config.rka.ip_address',
)]
public function __construct(
protected TemplateRendererInterface $template,
protected UrlHelper $urlHelper,
protected ContactService $contactService,
protected array $ipAddressConfig,
) {
}

Expand All @@ -42,13 +55,13 @@ 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 = (string) $request->getAttribute($this->ipAddressConfig['attribute_name']);

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;
}

Expand Down
3 changes: 2 additions & 1 deletion src/App/src/RoutesDelegator.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
use Light\App\Handler\PostCreateContactHandler;
use Mezzio\Application;
use Psr\Container\ContainerInterface;
use RKA\Middleware\IpAddress;

use function assert;

Expand All @@ -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.
Expand Down
11 changes: 6 additions & 5 deletions src/App/src/Service/ContactService.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
}

Expand Down Expand Up @@ -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 = ''): 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();
Expand Down Expand Up @@ -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() ?? '',
Expand All @@ -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 = '';
Expand Down
83 changes: 77 additions & 6 deletions test/Unit/App/Handler/PostCreateContactHandlerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,24 @@

class PostCreateContactHandlerTest extends TestCase
{
/**
* @return array{
* check_proxy_headers: bool,
* trusted_proxies: array<non-empty-string>,
* attribute_name: non-empty-string,
* headers_to_inspect: array<non-empty-string>,
* }
*/
private function ipAddressConfig(): array
{
return [
'check_proxy_headers' => false,
'trusted_proxies' => [],
'attribute_name' => 'ipAddress',
'headers_to_inspect' => [],
];
}

/**
* @return array<string, string>
*/
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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);

Expand All @@ -197,7 +223,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([['ipAddress', null, '203.0.113.5']]);

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

$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);
$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([['ipAddress', 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,
$this->ipAddressConfig()
))->handle($request);

$this->assertInstanceOf(RedirectResponse::class, $response);
}
}
6 changes: 5 additions & 1 deletion test/Unit/App/RoutesDelegatorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
use Psr\Container\ContainerExceptionInterface;
use Psr\Container\ContainerInterface;
use Psr\Container\NotFoundExceptionInterface;
use RKA\Middleware\IpAddress;

class RoutesDelegatorTest extends UnitTest
{
Expand Down Expand Up @@ -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']);
}
}
Loading
Loading