Implementing Rate Limiting and IP Blocking in Willow CMS
This blog post details the implementation of rate limiting and IP blocking within a Willow CMS, focusing on the key code components and their rationale. This information is intended for developers looking to implement similar security measures in their projects. Since putting Willow live for this site, it has been interesting to view the logs and see all the attempts from Singapore trying to find exploits!
Rate Limiting
Rate limiting is a technique for protecting application resources by restricting the number of requests a client can make within a given timeframe. This implementation uses CakePHP’s middleware pattern and caching mechanism for efficiency.
RateLimitMiddleware
The core of the rate limiting logic resides in src/Middleware/RateLimitMiddleware.php. Read the full code on GitHub. Here’s a breakdown of the key parts:
class RateLimitMiddleware implements MiddlewareInterface
{
// Use default configuration or that provided to the constructor
public function __construct(array $config = [])
{
$this->limit = $config['limit'] ?? 3;
$this->period = $config['period'] ?? 60;
// Specific some default routes that should be rate limited
$this->rateLimitedRoutes = $config['routes'] ?? [
'/users/login',
'/users/register',
'/articles/add-comment/',
];
}
// Process a server request and return a response.
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
// Use CakePHP method to get the client IP (more on this later)
$ip = $request->clientIp();
$route = $request->getUri()->getPath();
if ($this->isRouteLimited($route)) {
$key = "rate_limit_{$ip}_normal";
$rateData = $this->updateRateLimit($key, $this->period);
if ($rateData['count'] > $this->limit) {
// ...
throw new TooManyRequestsException(
// ...
);
}
}
return $handler->handle($request);
}
// ... more methods below, see the full file on GitHub
}
Key aspects of this middleware:
- Targeted Routes: The
isRouteLimited()
method checks if the current route is subject to rate limiting, allowing for granular control. - Caching for Efficiency:
updateRateLimit()
method reads and increments the request count from the cache. This avoids database queries on every request, enhancing performance. If the time period has elapsed, the counter is reset. - Exception Handling: When the limit is exceeded, a
TooManyRequestsException
is thrown, halting execution and sending a 404 error to the client, including theRetry-After
header. - Logging: Violations are logged for analysis and monitoring.
- Configuration: The number of requests allowed and time periods are retrieved from setting values allowing you to configure this from the admin area.
- Wildcard Support: The array of rate limited routes supports wild cards, so you can include
pages/*
orarticles/*
. See the isRouteLimited() method.
IP Blocking
IP blocking adds another layer of security by preventing requests from known malicious IP addresses. The implementation combines caching with database lookup, efficiently managing the blocked IP list.
IpBlockerMiddleware
This middleware src/Middleware/IpBlockerMiddleware.php handles blocking and suspicious activity tracking. Read the full code on GitHub. Here’s a breakdown of the key parts:
class IpBlockerMiddleware implements MiddlewareInterface
{
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
// ...
$clientIp = $request->clientIp();
if ($this->ipSecurity->isIpBlocked($clientIp)) {
// Return 403 Forbidden
}
if ($this->ipSecurity->isSuspiciousRequest($route, $query)) {
// Log suspicious activity
$this->ipSecurity->trackSuspiciousActivity($clientIp, $route, $query);
// Return 403 Forbidden
}
return $handler->handle($request);
}
}
Here’s how it works:
- IP Blocking: If the requesting IP is in the blocked list (checked via
isIpBlocked()
), a 403 Forbidden response is immediately returned. - Suspicious Request Detection: The
isSuspiciousRequest()
method checks against a list of suspicious URL patterns. If a match is found, the activity is logged and tracked.
IpSecurityService
The src/Service/IpSecurityService.php handles the core logic for IP blocking and suspicious activity detection. It uses caching to improve performance and provides a centralized place for IP-related functions. Read the full code on GitHub. Here’s a breakdown of the key parts:
class IpSecurityService
{
private array $suspiciousPatterns = [/* list of regular expressions */ ];
public function isIpBlocked(string $ip): bool
{
// Caching logic ...
// Database lookup if not in cache
$blockedIp = $blockedIpsTable->find()
// ... expiry checks ...
->first();
// ... caching logic
return $blockedStatus;
}
public function blockIp(string $ip, string $reason, ?DateTime $expiresAt = null): bool
{
// ... database save to blocked_ips table and logging the event
}
public function isSuspiciousRequest(string $route, string $query): bool
{
// Regex matching against suspicious patterns
// ... logging ...
}
public function trackSuspiciousActivity(string $ip, string $route, string $query): void
{
// Increase suspicious activity counter using cache
// ... progressive blocking logic (if IP has offended in last 24 hours block for 48 hours) ...
}
}
Key improvements and features of this service:
- Caching: Both
isIpBlocked()
andtrackSuspiciousActivity()
use caching to reduce database load. - Progressive Blocking: The
trackSuspiciousActivity()
method implements progressive blocking. Repeat offenders receive longer block durations. - Comprehensive Pattern List: A detailed list of suspicious patterns in
$suspiciousPatterns
helps detect a variety of malicious requests. I think in future I will load/cache these from the database for easy editing/addition of new rules.
Load Balancer Support
The code supports Willow CMS running behind a load balancer by trusting proxy headers. This is enabled by setting 'Security.trustProxy'
to true
in the Willow CMS Admin->Settings page. This ensures that the client IP and other request information are correctly retrieved from the forwarded headers and we use the built in CakePHP framework code to reliably get the client IP.
if (SettingsManager::read('Security.trustProxy', false)) {
$request = $request->withAttribute('trustProxy', true);
}
PHPUnit Tests
This is pretty important code so it has to come with some Unit Tests. You can read them in RateLimitMiddlewareTest and IpBlockerMiddlewareTest. I make use of Data Providers in PHPUnit to keep the tests clean and easily updatable.
IpBlockerMiddlewareTest
This test case focuses on the IpBlockerMiddleware
, covering cases for blocked IPs, cached statuses, expired blocks, suspicious requests, and path traversal.
- Fixture Setup: The
$fixtures = ['app.BlockedIps']
declaration uses a fixture for theBlockedIps
table, providing a consistent data set for testing database interactions. - Mocking:
RequestHandlerInterface
is mocked to isolate the middleware’s behavior and simulate request handling. - Test Cases:
- testProcessWithBlockedIp: Confirms that blocked IPs receive a 403 response.
- testProcessWithNonBlockedIp: Checks that non-blocked IPs pass through.
- testProcessWithCachedBlockedStatus: Validates that cached block statuses are respected.
- testProcessWithExpiredBlockedIp: Verifies that expired blocks don’t cause a 403.
- testProcessWithMissingRemoteAddr: Confirms behavior when
REMOTE_ADDR
is absent. - testProcessWithSuspiciousRequest, testProcessWithEncodedSuspiciousRequest: Checks suspicious URL pattern detection, including encoded patterns.
- testMultipleSuspiciousRequestsLeadToBlock: Simulates multiple suspicious requests and verifies that the IP is eventually blocked and saved to the database.
- Data Providers: The pathTraversalProvider and sqlInjectionProvider offer a clean way to test various attack vectors without code duplication. These providers feed data into parameterized test methods, increasing test coverage.
RateLimitMiddlewareTest
This test suite targets RateLimitMiddleware
, focusing on rate limiting logic, wildcard routes, and different route handling.
- Setup: The
setUp
method clears the ‘rate_limit’ cache before each test, ensuring a clean slate for accurate rate counting. It also defines rate limiting parameters (limit
andperiod
) for the tests. - Test Cases:
- testRateLimitGeneralRoute: Tests a route that is not rate limited.
- testRateLimitSensitiveRoute: Tests a route that is rate limited, asserting a
TooManyRequestsException
after exceeding the limit. - testDifferentRoutesDoNotAffectRateLimit: Validates that different routes have independent rate limits, preventing unintended interference.
- testRateLimitWildcardRoute: Demonstrates rate limiting with wildcard routes using the PagesController as example.
These test cases demonstrate good practices for testing middleware. They offer targeted scenarios, leverage mocking where appropriate, and use data providers for efficient testing of multiple variations.