Skip to content

Commit 56e4690

Browse files
authored
Merge pull request #125 from phenixphp/feature/improve-rate-limit-with-forwarded-ips
Improve rate limit with forwarded ips
2 parents 382d9ef + 1dc5a16 commit 56e4690

6 files changed

Lines changed: 152 additions & 16 deletions

File tree

src/Http/Ip.php

Lines changed: 18 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -61,42 +61,44 @@ public function forwardingAddress(): string|null
6161

6262
public function hash(): string
6363
{
64-
return hash('sha256', $this->host);
64+
[$host] = $this->parseAddress($this->forwardingAddress ?? $this->host);
65+
66+
return hash('sha256', $host);
6567
}
6668

6769
protected function parse(): void
6870
{
69-
$address = trim($this->address);
71+
[$this->host, $this->port] = $this->parseAddress($this->address);
72+
}
7073

71-
if (preg_match('/^\[(?<addr>[^\]]+)\](?::(?<port>\d+))?$/', $address, $m) === 1) {
72-
$this->host = $m['addr'];
73-
$this->port = isset($m['port']) ? (int) $m['port'] : null;
74+
/**
75+
* @return array{0: string, 1: int|null}
76+
*/
77+
protected function parseAddress(string $address): array
78+
{
79+
$address = trim($address);
7480

75-
return;
81+
if (preg_match('/^\[(?<addr>[^\]]+)\](?::(?<port>\d+))?$/', $address, $m) === 1) {
82+
return [$m['addr'], isset($m['port']) ? (int) $m['port'] : null];
7683
}
7784

7885
if (filter_var($address, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) {
79-
$this->host = $address;
80-
$this->port = null;
81-
82-
return;
86+
return [$address, null];
8387
}
8488

89+
$result = [$address, null];
90+
8591
if (str_contains($address, ':')) {
8692
[$maybeHost, $maybePort] = explode(':', $address, 2);
8793

8894
if (
8995
filter_var($maybeHost, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) ||
9096
filter_var($maybeHost, FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME)
9197
) {
92-
$this->host = $maybeHost;
93-
$this->port = is_numeric($maybePort) ? (int) $maybePort : null;
94-
95-
return;
98+
$result = [$maybeHost, is_numeric($maybePort) ? (int) $maybePort : null];
9699
}
97100
}
98101

99-
$this->host = $address;
100-
$this->port = null;
102+
return $result;
101103
}
102104
}

src/Testing/Concerns/InteractWithResponses.php

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,15 @@
2424

2525
trait InteractWithResponses
2626
{
27+
protected int $redirectsFollowed = 0;
28+
29+
protected function followingRedirects(): static
30+
{
31+
$this->redirectsFollowed = 10;
32+
33+
return $this;
34+
}
35+
2736
public function call(
2837
HttpMethod $method,
2938
string $path,
@@ -61,6 +70,7 @@ public function connect(
6170
};
6271

6372
$client = (new HttpClientBuilder())
73+
->followRedirects($this->redirectsFollowed)
6474
->usingPool(new UnlimitedConnectionPool(new DefaultConnectionFactory($connector)))
6575
->build();
6676

tests/Feature/AuthenticationTest.php

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
use Phenix\Auth\Middlewares\Guest;
1313
use Phenix\Auth\PersonalAccessToken;
1414
use Phenix\Auth\User;
15+
use Phenix\Constants\AppMode;
1516
use Phenix\Database\Constants\Connection;
1617
use Phenix\Facades\Config;
1718
use Phenix\Facades\Crypto;
@@ -389,6 +390,42 @@
389390
])->assertStatusCode(HttpStatus::TOO_MANY_REQUESTS)->assertHeaders(['Retry-After' => '300']);
390391
});
391392

393+
it('tracks failed token validation attempts by forwarded client ip behind a trusted proxy', function (): void {
394+
Config::set('app.app_mode', AppMode::PROXIED->value);
395+
Config::set('app.trusted_proxies', ['127.0.0.1/32', '127.0.0.1']);
396+
397+
$connection = $this->getMockBuilder(MysqlConnectionPool::class)->getMock();
398+
$connection->expects($this->any())
399+
->method('prepare')
400+
->willReturn(
401+
new Statement(new Result()),
402+
);
403+
404+
$this->app->swap(Connection::default(), $connection);
405+
406+
Route::get('/limited-proxy', fn (): Response => response()->plain('Never reached'))
407+
->middleware(Authenticated::class);
408+
409+
$this->app->run();
410+
411+
for ($i = 0; $i < 5; $i++) {
412+
$this->get('/limited-proxy', headers: [
413+
'Authorization' => 'Bearer invalid-token',
414+
'X-Forwarded-For' => '203.0.113.10',
415+
])->assertUnauthorized();
416+
}
417+
418+
$this->get('/limited-proxy', headers: [
419+
'Authorization' => 'Bearer invalid-token',
420+
'X-Forwarded-For' => '203.0.113.10',
421+
])->assertStatusCode(HttpStatus::TOO_MANY_REQUESTS)->assertHeaders(['Retry-After' => '300']);
422+
423+
$this->get('/limited-proxy', headers: [
424+
'Authorization' => 'Bearer invalid-token',
425+
'X-Forwarded-For' => '203.0.113.11',
426+
])->assertUnauthorized();
427+
});
428+
392429
it('resets rate limit counter on successful authentication', function (): void {
393430
$user = new User();
394431
$user->id = 1;

tests/Feature/Cache/LocalRateLimitTest.php

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

33
declare(strict_types=1);
44

5+
use Phenix\Constants\AppMode;
56
use Phenix\Facades\Config;
67
use Phenix\Facades\Crypto;
78
use Phenix\Facades\Route;
@@ -65,3 +66,25 @@
6566
$this->get(path: '/')
6667
->assertOk();
6768
});
69+
70+
it('uses forwarded client ip for rate limiting behind a trusted proxy', function (): void {
71+
Config::set('app.app_mode', AppMode::PROXIED->value);
72+
Config::set('app.trusted_proxies', ['127.0.0.1/32', '127.0.0.1']);
73+
Config::set('cache.rate_limit.per_minute', 1);
74+
75+
Route::get('/', fn (): Response => response()->plain('Ok'));
76+
77+
$this->app->run();
78+
79+
$this->get(path: '/', headers: [
80+
'X-Forwarded-For' => '203.0.113.10',
81+
])->assertOk();
82+
83+
$this->get(path: '/', headers: [
84+
'X-Forwarded-For' => '203.0.113.10',
85+
])->assertStatusCode(HttpStatus::TOO_MANY_REQUESTS);
86+
87+
$this->get(path: '/', headers: [
88+
'X-Forwarded-For' => '203.0.113.11',
89+
])->assertOk();
90+
});

tests/Feature/RequestTest.php

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -509,3 +509,21 @@
509509
'Cross-Origin-Opener-Policy',
510510
]);
511511
});
512+
513+
it('can follow redirects when enabled', function (): void {
514+
Route::get('/redirect-follow', fn (): Response => response()->redirect('/redirect-target'));
515+
Route::get('/redirect-target', fn (): Response => response()->json([
516+
'message' => 'Redirect followed',
517+
'redirected' => true,
518+
]));
519+
520+
$this->app->run();
521+
522+
$this->followingRedirects()
523+
->get('/redirect-follow')
524+
->assertOk()
525+
->assertIsJson()
526+
->assertJsonPath('message', 'Redirect followed')
527+
->assertJsonPath('redirected', true)
528+
->assertHeaderIsMissing('Location');
529+
});

tests/Unit/Http/IpAddressTest.php

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,4 +269,50 @@ public function __toString(): string
269269

270270
expect($ip->isForwarded())->toBeTrue();
271271
expect($ip->forwardingAddress())->toBe('203.0.113.1:4711');
272+
expect($ip->hash())->toBe(hash('sha256', '203.0.113.1'));
273+
});
274+
275+
it('uses forwarded ipv6 host for hash when request is behind a trusted proxy', function (): void {
276+
$client = $this->createMock(Client::class);
277+
$client->method('getRemoteAddress')->willReturn(
278+
new class ('10.0.0.1:1234') implements SocketAddress {
279+
public function __construct(private string $address)
280+
{
281+
}
282+
283+
public function toString(): string
284+
{
285+
return $this->address;
286+
}
287+
288+
public function getType(): SocketAddressType
289+
{
290+
return SocketAddressType::Internet;
291+
}
292+
293+
public function __toString(): string
294+
{
295+
return $this->address;
296+
}
297+
}
298+
);
299+
300+
$request = new ServerRequest($client, HttpMethod::GET->value, Http::new(Url::to('/')));
301+
$request->setHeader('X-Forwarded-For', '2001:db8::10');
302+
$request->setAttribute(
303+
Forwarded::class,
304+
new Forwarded(
305+
new InternetAddress('2001:db8::10', 4711),
306+
[
307+
'for' => '[2001:db8::10]:4711',
308+
]
309+
)
310+
);
311+
312+
$ip = Ip::make($request);
313+
314+
expect($ip->isForwarded())->toBeTrue();
315+
expect($ip->forwardingAddress())->toBe('[2001:db8::10]:4711');
316+
expect($ip->host())->toBe('10.0.0.1');
317+
expect($ip->hash())->toBe(hash('sha256', '2001:db8::10'));
272318
});

0 commit comments

Comments
 (0)