Skip to content

Commit

Permalink
Allow using hashed IP addresses (#9)
Browse files Browse the repository at this point in the history
* [fix] Use correct psr-4 root for test sources

* [feat] Add option to hash IP addresses before storing them

* [refactor] Separate test setup and tests

* [chore] Add tests for IP hashing

* [chore] Update documentation (add `hash_ips`)
  • Loading branch information
AndreasMaros authored Dec 8, 2021
1 parent 2cc5c33 commit 7596d9e
Show file tree
Hide file tree
Showing 7 changed files with 185 additions and 56 deletions.
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ composer require los/los-rate-limit
'reset_time' => 3600,
],
],
'hash_ips' => false,
'hash_salt' => 'Los%Rate',
]
```

Expand All @@ -71,6 +73,11 @@ composer require los/los-rate-limit
* `forwarded_ip_index` If null (default), the first plausible IP in an XFF header (reading left to right) is used. If numeric, only a specific index of IP is used. Use `-2` to get the penultimate IP from the list, which could make sense if the header always ends `...<client_ip>, <router_ip>`. Or use `0` to use only the first IP (stopping if it's not valid). Like `prefer_forwarded`, this only makes sense if your app's always reached through a predictable hop that controls the header - remember these are easily spoofed on the initial request.
* `keys` Specify different max_requests/reset_time per api key
* `ips` Specify different max_requests/reset_time per IP
* `hash_ips` Enable the hashing of IP addresses before storing them. This is particularly useful when using a
filesystem-based cache implementation and working with IPv6 addresses. A salted MD5-hash will be used if you set
this to `true`.
* `hash_salt' This setting allows you to optionally define a custom salt when using hashed IP addresses. Only
effective when `hash_ips` is `true`.

The values above indicate that the user can trigger 100 requests per hour.

Expand Down
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
],
"autoload-dev": {
"psr-4": {
"LosMiddlewareTest\\RateLimit\\": "tests/"
"LosMiddlewareTest\\RateLimit\\": "test/"
}
},
"autoload": {
Expand Down
14 changes: 13 additions & 1 deletion src/RateLimitMiddleware.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
use function explode;
use function filter_var;
use function is_array;
use function md5;
use function str_replace;
use function time;

Expand Down Expand Up @@ -74,7 +75,11 @@ public function process(ServerRequestInterface $request, RequestHandlerInterface
throw new MissingRequirement('Could not detect the client IP');
}

$key = str_replace('.', '-', $key);
if ($this->options['hash_ips'] === true) {
$key = $this->hashIp($key);
} else {
$key = str_replace('.', '-', $key);
}
}

$data = $this->storage->get($key);
Expand Down Expand Up @@ -173,6 +178,13 @@ private function getClientIp(ServerRequestInterface $request)
return null;
}

private function hashIp(string $ip) : string
{
$salt = $this->options['hash_salt'];

return md5($salt . $ip);
}

/**
* @param mixed $possibleIp
*/
Expand Down
2 changes: 2 additions & 0 deletions src/RateLimitOptions.php
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ class RateLimitOptions extends ArrayObject
],
'keys' => [],
'ips' => [],
'hash_ips' => false,
'hash_salt' => 'Los%Rate',
];

/**
Expand Down
54 changes: 54 additions & 0 deletions test/HashIpsTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
<?php

namespace LosMiddlewareTest\RateLimit;

use LosMiddleware\RateLimit\RateLimitMiddleware;
use LosMiddleware\RateLimit\RateLimitOptions;
use Psr\Http\Server\RequestHandlerInterface;
use Laminas\Diactoros\Response\JsonResponse;
use Laminas\Diactoros\ServerRequest;

class HashIpsTest extends TestSetup
{
protected function setUp() : void
{
$options = new RateLimitOptions([
'max_requests' => 2,
'reset_time' => 10,
'ip_max_requests' => 2,
'ip_reset_time' => 10,
'api_header' => 'X-Api-Key',
'trust_forwarded' => true,
'prefer_forwarded' => false,
'forwarded_headers_allowed' => [
'Client-Ip',
'Forwarded',
'Forwarded-For',
'X-Cluster-Client-Ip',
'X-Forwarded',
'X-Forwarded-For',
],
'forwarded_ip_index' => null,
'hash_ips' => true,
]);

$problemResponse = $this->getMockProblemResponse();
$storage = $this->getMockStorage();
$this->middleware = new RateLimitMiddleware($storage, $problemResponse, $options);
}

public function testHashIp()
{
$defaultSalt = 'Los%Rate';
$clientIp = '192.168.1.1';

$request = new ServerRequest(['REMOTE_ADDR' => '127.0.0.1']);
$request = $request->withHeader('X-Forwarded-For', $clientIp);

$handler = $this->createMock(RequestHandlerInterface::class);
$handler->method('handle')->willReturn(new JsonResponse([]));
$this->middleware->process($request, $handler);

$this->assertArrayHasKey(\md5($defaultSalt . $clientIp), $this->cache);
}
}
70 changes: 16 additions & 54 deletions test/RateLimitTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,44 +12,8 @@
use Laminas\Diactoros\ServerRequest;
use Mezzio\ProblemDetails\ProblemDetailsResponseFactory;

class RateLimitTest extends TestCase
class RateLimitTest extends TestSetup
{
/** @var array */
private $cache = [];
/** @var RateLimitMiddleware */
private $middleware;

protected function setUp() : void
{
$options = new RateLimitOptions([
'max_requests' => 2,
'reset_time' => 10,
'ip_max_requests' => 2,
'ip_reset_time' => 10,
'api_header' => 'X-Api-Key',
'trust_forwarded' => true,
'prefer_forwarded' => false,
'forwarded_headers_allowed' => [
'Client-Ip',
'Forwarded',
'Forwarded-For',
'X-Cluster-Client-Ip',
'X-Forwarded',
'X-Forwarded-For',
],
'forwarded_ip_index' => null,
]);

$problemResponse = $this->createMock(ProblemDetailsResponseFactory::class);
$problemResponse->method('createResponseFromThrowable')->willReturn(new JsonResponse([], 429));

$storage = $this->createMock(CacheInterface::class);
$storage->method('get')->will($this->returnCallback([$this, 'getCache']));
$storage->method('set')->will($this->returnCallback([$this, 'setCache']));

$this->middleware = new RateLimitMiddleware($storage, $problemResponse, $options);
}

public function testNeedIpOuApiKey()
{
$request = new ServerRequest();
Expand Down Expand Up @@ -135,6 +99,21 @@ public function testGenerate429()
$this->assertSame(429, $result->getStatusCode());
}

public function testStoresUnhashedIps()
{
$clientIp = '192.168.1.1';
$expectedCacheKey = '192-168-1-1';

$request = new ServerRequest(['REMOTE_ADDR' => '127.0.0.1']);
$request = $request->withHeader('X-Forwarded-For', $clientIp);

$handler = $this->createMock(RequestHandlerInterface::class);
$handler->method('handle')->willReturn(new JsonResponse([]));
$this->middleware->process($request, $handler);

$this->assertArrayHasKey($expectedCacheKey, $this->cache);
}

public function testReset()
{
// $container = new Container('LosRateLimit');
Expand All @@ -157,21 +136,4 @@ public function testReset()
$this->assertLessThanOrEqual(10, $result->getHeader(RateLimitMiddleware::HEADER_RESET)[0]);
$this->assertSame(200, $result->getStatusCode());
}

/**
* @param null|mixed $default
* @return null|mixed
*/
public function getCache(string $key, $default = null)
{
return $this->cache[$key] ?? $default;
}

/**
* @param mixed $value
*/
public function setCache(string $key, $value) : void
{
$this->cache[$key] = $value;
}
}
92 changes: 92 additions & 0 deletions test/TestSetup.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
<?php

namespace LosMiddlewareTest\RateLimit;

use Laminas\Diactoros\Response\JsonResponse;
use LosMiddleware\RateLimit\RateLimitMiddleware;
use LosMiddleware\RateLimit\RateLimitOptions;
use Mezzio\ProblemDetails\ProblemDetailsResponseFactory;
use PHPUnit\Framework\TestCase;
use Psr\SimpleCache\CacheInterface;

class TestSetup extends TestCase
{
/** @var array */
protected $cache = [];

/** @var RateLimitMiddleware */
protected $middleware;

protected function setUp(): void
{
$options = new RateLimitOptions([
'max_requests' => 2,
'reset_time' => 10,
'ip_max_requests' => 2,
'ip_reset_time' => 10,
'api_header' => 'X-Api-Key',
'trust_forwarded' => true,
'prefer_forwarded' => false,
'forwarded_headers_allowed' => [
'Client-Ip',
'Forwarded',
'Forwarded-For',
'X-Cluster-Client-Ip',
'X-Forwarded',
'X-Forwarded-For',
],
'forwarded_ip_index' => null,
]);

$problemResponse = $this->getMockProblemResponse();
$storage = $this->getMockStorage();
$this->middleware = new RateLimitMiddleware(
$storage,
$problemResponse,
$options
);
}

/**
* @param null|mixed $default
*
* @return null|mixed
*/
public function getCache(string $key, $default = null)
{
return $this->cache[$key] ?? $default;
}

/**
* @param mixed $value
*/
public function setCache(string $key, $value): void
{
$this->cache[$key] = $value;
}

protected function getMockProblemResponse()
{
$problemResponse = $this->createMock(
ProblemDetailsResponseFactory::class
);
$problemResponse->method('createResponseFromThrowable')->willReturn(
new JsonResponse([], 429)
);

return $problemResponse;
}

protected function getMockStorage()
{
$storage = $this->createMock(CacheInterface::class);
$storage->method('get')->will(
$this->returnCallback([$this, 'getCache'])
);
$storage->method('set')->will(
$this->returnCallback([$this, 'setCache'])
);

return $storage;
}
}

0 comments on commit 7596d9e

Please sign in to comment.