Skip to content

Commit 0644e18

Browse files
committed
ip address middleware
1 parent 894208d commit 0644e18

7 files changed

Lines changed: 414 additions & 2 deletions

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
return [
6+
'ip_address' => [
7+
'trusted_proxies' => [],
8+
'headers' => ['Forwarded', 'X-Forwarded-For', 'X-Real-IP'],
9+
],
10+
];

‎config/pipeline.php‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
use Dot\ErrorHandler\ErrorHandlerInterface;
66
use Dot\ResponseHeader\Middleware\ResponseHeaderMiddleware;
7+
use Light\App\Middleware\IpAddressMiddleware;
78
use Mezzio\Application;
89
use Mezzio\Handler\NotFoundHandler;
910
use Mezzio\Helper\ServerUrlMiddleware;
@@ -19,6 +20,7 @@
1920
// all Exceptions.
2021
$app->pipe(ErrorHandlerInterface::class);
2122
$app->pipe(ServerUrlMiddleware::class);
23+
$app->pipe(IpAddressMiddleware::class);
2224

2325
// Pipe more middleware here that you want to execute on every request:
2426
// - bootstrapping

‎src/App/src/ConfigProvider.php‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
use Light\App\Handler\GetPackagesViewHandler;
3434
use Light\App\Handler\GetSitemapViewHandler;
3535
use Light\App\Handler\PostCreateContactHandler;
36+
use Light\App\Middleware\IpAddressMiddleware;
3637
use Light\App\Resolver\EntityListenerResolver;
3738
use Light\App\Service\ContactService;
3839
use Light\App\Service\FeedGenerator;
@@ -149,6 +150,7 @@ public function getDependencies(): array
149150
GetCreateContactFormHandler::class => AttributedServiceFactory::class,
150151
PostCreateContactHandler::class => AttributedServiceFactory::class,
151152
ContactService::class => AttributedServiceFactory::class,
153+
IpAddressMiddleware::class => AttributedServiceFactory::class,
152154
FeedGenerator::class => FeedGeneratorFactory::class,
153155
SitemapGenerator::class => SitemapGeneratorFactory::class,
154156
GitHubClient::class => GitHubClientFactory::class,

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
use Laminas\Diactoros\Response\HtmlResponse;
99
use Laminas\Diactoros\Response\RedirectResponse;
1010
use Light\App\Enum\ContactTopicEnum;
11+
use Light\App\Middleware\IpAddressMiddleware;
1112
use Light\App\Service\ContactService;
1213
use Mezzio\Helper\UrlHelper;
1314
use Mezzio\Template\TemplateRendererInterface;
@@ -42,7 +43,7 @@ public function handle(ServerRequestInterface $request): ResponseInterface
4243
$token = is_array($rawBody) && is_string($rawBody['cf-turnstile-response'] ?? null)
4344
? $rawBody['cf-turnstile-response']
4445
: '';
45-
$ip = $request->getServerParams()['REMOTE_ADDR'] ?? null;
46+
$ip = $request->getAttribute(IpAddressMiddleware::ATTRIBUTE);
4647

4748
if (! $this->contactService->verifyTurnstile($token, is_string($ip) ? $ip : null)) {
4849
$errors['captcha'] = "We couldn't verify you're human. Please try again.";
Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Light\App\Middleware;
6+
7+
use Dot\DependencyInjection\Attribute\Inject;
8+
use Psr\Http\Message\ResponseInterface;
9+
use Psr\Http\Message\ServerRequestInterface;
10+
use Psr\Http\Server\MiddlewareInterface;
11+
use Psr\Http\Server\RequestHandlerInterface;
12+
13+
use function array_reverse;
14+
use function ctype_digit;
15+
use function explode;
16+
use function filter_var;
17+
use function inet_pton;
18+
use function intdiv;
19+
use function is_string;
20+
use function ord;
21+
use function str_contains;
22+
use function str_starts_with;
23+
use function strcasecmp;
24+
use function strlen;
25+
use function strncmp;
26+
use function strpos;
27+
use function strrpos;
28+
use function substr;
29+
use function substr_count;
30+
use function trim;
31+
32+
use const FILTER_VALIDATE_IP;
33+
34+
/**
35+
* Determines the client IP address and stores it in the `ip_address` request attribute,
36+
* the same way akrabat/ip-address-middleware does.
37+
*
38+
* Forwarding headers are read only when REMOTE_ADDR is a trusted proxy, because any client can
39+
* send them with any value. With no trusted proxies configured, the attribute is REMOTE_ADDR.
40+
*/
41+
class IpAddressMiddleware implements MiddlewareInterface
42+
{
43+
public const string ATTRIBUTE = 'ip_address';
44+
45+
/**
46+
* @param list<string> $trustedProxies IP addresses or CIDR ranges, e.g. `10.0.0.1` or `173.245.48.0/20`
47+
* @param list<string> $headers checked in order; the first one that gives an address wins
48+
*/
49+
#[Inject(
50+
'config.ip_address.trusted_proxies',
51+
'config.ip_address.headers',
52+
)]
53+
public function __construct(
54+
private readonly array $trustedProxies = [],
55+
private readonly array $headers = ['Forwarded', 'X-Forwarded-For', 'X-Real-IP'],
56+
) {
57+
}
58+
59+
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
60+
{
61+
return $handler->handle($request->withAttribute(self::ATTRIBUTE, $this->determineIpAddress($request)));
62+
}
63+
64+
public function determineIpAddress(ServerRequestInterface $request): ?string
65+
{
66+
$remoteAddr = $this->normalize($request->getServerParams()['REMOTE_ADDR'] ?? null);
67+
if ($remoteAddr === null || ! $this->isTrustedProxy($remoteAddr)) {
68+
return $remoteAddr;
69+
}
70+
71+
foreach ($this->headers as $header) {
72+
$ipAddress = $this->fromHeader($request, $header);
73+
if ($ipAddress !== null) {
74+
return $ipAddress;
75+
}
76+
}
77+
78+
return $remoteAddr;
79+
}
80+
81+
private function fromHeader(ServerRequestInterface $request, string $header): ?string
82+
{
83+
$line = $request->getHeaderLine($header);
84+
if ($line === '') {
85+
return null;
86+
}
87+
88+
$hops = [];
89+
foreach (explode(',', $line) as $hop) {
90+
$hops[] = $this->normalize(strcasecmp($header, 'Forwarded') === 0 ? $this->forwardedFor($hop) : $hop);
91+
}
92+
93+
/**
94+
* Each proxy appends the address it received the request from, so walk from the right: the first
95+
* address that is not one of our own proxies is the client. Anything left of it could be forged.
96+
*/
97+
foreach (array_reverse($hops) as $hop) {
98+
if ($hop === null) {
99+
return null;
100+
}
101+
if (! $this->isTrustedProxy($hop)) {
102+
return $hop;
103+
}
104+
}
105+
106+
return $hops[0];
107+
}
108+
109+
/**
110+
* Returns the `for=` value of one RFC 7239 element, e.g. `for=192.0.2.60;proto=https;by=203.0.113.43`.
111+
*/
112+
private function forwardedFor(string $element): string
113+
{
114+
foreach (explode(';', $element) as $pair) {
115+
$parts = explode('=', $pair, 2);
116+
if (isset($parts[1]) && strcasecmp(trim($parts[0]), 'for') === 0) {
117+
return $parts[1];
118+
}
119+
}
120+
121+
return '';
122+
}
123+
124+
/**
125+
* Strips quotes, IPv6 brackets and ports; returns null for anything that is not an IP address.
126+
*/
127+
private function normalize(mixed $value): ?string
128+
{
129+
if (! is_string($value)) {
130+
return null;
131+
}
132+
133+
$value = trim(trim($value), '"');
134+
if (str_starts_with($value, '[')) {
135+
$end = strpos($value, ']');
136+
$value = $end === false ? '' : substr($value, 1, $end - 1);
137+
} elseif (substr_count($value, ':') === 1) {
138+
$value = substr($value, 0, (int) strrpos($value, ':'));
139+
}
140+
141+
return filter_var($value, FILTER_VALIDATE_IP) === false ? null : $value;
142+
}
143+
144+
private function isTrustedProxy(string $ipAddress): bool
145+
{
146+
foreach ($this->trustedProxies as $proxy) {
147+
if ($this->matches($ipAddress, $proxy)) {
148+
return true;
149+
}
150+
}
151+
152+
return false;
153+
}
154+
155+
private function matches(string $ipAddress, string $proxy): bool
156+
{
157+
$subnet = $proxy;
158+
$bits = null;
159+
if (str_contains($proxy, '/')) {
160+
[$subnet, $bits] = explode('/', $proxy, 2);
161+
}
162+
163+
$address = @inet_pton($ipAddress);
164+
$network = @inet_pton($subnet);
165+
if ($address === false || $network === false || strlen($address) !== strlen($network)) {
166+
return false;
167+
}
168+
169+
if ($bits === null) {
170+
return $address === $network;
171+
}
172+
173+
if (! ctype_digit($bits) || (int) $bits > strlen($address) * 8) {
174+
return false;
175+
}
176+
177+
$bytes = intdiv((int) $bits, 8);
178+
if (strncmp($address, $network, $bytes) !== 0) {
179+
return false;
180+
}
181+
182+
$remainder = (int) $bits % 8;
183+
if ($remainder === 0) {
184+
return true;
185+
}
186+
187+
$mask = (0xFF << (8 - $remainder)) & 0xFF;
188+
$addressBits = ord($address[$bytes]) & $mask;
189+
$networkBits = ord($network[$bytes]) & $mask;
190+
191+
return $addressBits === $networkBits;
192+
}
193+
}

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
use Laminas\Diactoros\Response\RedirectResponse;
1313
use Light\App\Enum\ContactTopicEnum;
1414
use Light\App\Handler\PostCreateContactHandler;
15+
use Light\App\Middleware\IpAddressMiddleware;
1516
use Light\App\Service\ContactService;
1617
use Mezzio\Helper\UrlHelper;
1718
use Mezzio\Template\TemplateRendererInterface;
@@ -197,7 +198,7 @@ public function testFailedTurnstileRerendersWithCaptchaErrorAndSendsNothing(): v
197198

198199
$request = $this->createStub(ServerRequestInterface::class);
199200
$request->method('getParsedBody')->willReturn($data);
200-
$request->method('getServerParams')->willReturn(['REMOTE_ADDR' => '203.0.113.5']);
201+
$request->method('getAttribute')->willReturnMap([[IpAddressMiddleware::ATTRIBUTE, null, '203.0.113.5']]);
201202

202203
$contactService = $this->createMock(ContactService::class);
203204
$contactService->method('normalize')->willReturn([

0 commit comments

Comments
 (0)