Skip to content

Commit cedc4ac

Browse files
committed
[FEATURE] Add fingerprint-based rate limiting for tracking endpoints
Implements configurable rate limiter to prevent abuse and bot attacks: - RateLimiterCache: TTL-based request counter with 60s window - RateLimiter: Event listener on StopAnyProcessBeforePersistenceEvent - RateLimitException: Dedicated exception for rate limit violations - Configuration options: enable/disable toggle and requests per minute - Default limit: 20 requests per minute per fingerprint - Unit tests: 4 tests covering limit enforcement and edge cases Integration follows existing StopTracking pattern for seamless tracking prevention. Cache backend configurable (Database/Redis/Memcached).
1 parent 789f575 commit cedc4ac

8 files changed

Lines changed: 343 additions & 1 deletion

File tree

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
namespace In2code\Lux\Domain\Cache;
5+
6+
use TYPO3\CMS\Core\Cache\Frontend\FrontendInterface;
7+
8+
/**
9+
* Cache layer for rate limiting
10+
*
11+
* Stores request counters per fingerprint with 60-second TTL
12+
* Uses TYPO3 cache framework for automatic expiration and backend flexibility
13+
*/
14+
class RateLimiterCache
15+
{
16+
public const CACHE_KEY = 'luxratelimiter';
17+
public const TTL = 60; // 60 seconds = 1 minute window
18+
19+
protected FrontendInterface $cache;
20+
21+
public function __construct(FrontendInterface $cache)
22+
{
23+
$this->cache = $cache;
24+
}
25+
26+
public function getCount(string $fingerprintHash): int
27+
{
28+
$cacheIdentifier = $this->getCacheIdentifier($fingerprintHash);
29+
$data = $this->cache->get($cacheIdentifier);
30+
return (int)($data['count'] ?? 0);
31+
}
32+
33+
public function incrementAndGet(string $fingerprintHash): int
34+
{
35+
$cacheIdentifier = $this->getCacheIdentifier($fingerprintHash);
36+
$currentCount = $this->getCount($fingerprintHash);
37+
$newCount = $currentCount + 1;
38+
39+
// Store with 60-second TTL
40+
$this->cache->set(
41+
$cacheIdentifier,
42+
['count' => $newCount, 'timestamp' => time()],
43+
[self::CACHE_KEY],
44+
self::TTL
45+
);
46+
47+
return $newCount;
48+
}
49+
50+
/**
51+
* Generate cache identifier from fingerprint hash
52+
* Format: ratelimit_[first-16-chars-of-hash]
53+
*
54+
* @param string $fingerprintHash
55+
* @return string
56+
*/
57+
protected function getCacheIdentifier(string $fingerprintHash): string
58+
{
59+
$shortHash = substr($fingerprintHash, 0, 16);
60+
return 'ratelimit_' . $shortHash;
61+
}
62+
}
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
namespace In2code\Lux\Domain\Tracker;
5+
6+
use In2code\Lux\Domain\Cache\RateLimiterCache;
7+
use In2code\Lux\Events\StopAnyProcessBeforePersistenceEvent;
8+
use In2code\Lux\Exception\RateLimitException;
9+
use In2code\Lux\Utility\ConfigurationUtility;
10+
use Psr\Log\LoggerInterface;
11+
use TYPO3\CMS\Core\Configuration\Exception\ExtensionConfigurationExtensionNotConfiguredException;
12+
use TYPO3\CMS\Core\Configuration\Exception\ExtensionConfigurationPathDoesNotExistException;
13+
14+
class RateLimiter
15+
{
16+
protected RateLimiterCache $cache;
17+
protected LoggerInterface $logger;
18+
19+
public function __construct(
20+
RateLimiterCache $cache,
21+
LoggerInterface $logger
22+
) {
23+
$this->cache = $cache;
24+
$this->logger = $logger;
25+
}
26+
27+
public function __invoke(StopAnyProcessBeforePersistenceEvent $event)
28+
{
29+
if ($this->isRateLimitingEnabled()) {
30+
$fingerprint = $event->getFingerprint();
31+
$fingerprintHash = $fingerprint->getValue();
32+
33+
if ($fingerprintHash !== '') {
34+
$currentCount = $this->cache->incrementAndGet($fingerprintHash);
35+
if ($currentCount > $this->getRateLimit()) {
36+
$this->logRateLimitExceeded($fingerprintHash, $currentCount, $this->getRateLimit());
37+
throw new RateLimitException(
38+
'Rate limit exceeded: ' . $currentCount . ' requests in last minute',
39+
1768214806
40+
);
41+
}
42+
}
43+
}
44+
}
45+
46+
protected function isRateLimitingEnabled(): bool
47+
{
48+
try {
49+
return ConfigurationUtility::isRateLimitingEnabled();
50+
} catch (ExtensionConfigurationExtensionNotConfiguredException | ExtensionConfigurationPathDoesNotExistException $exception) {
51+
return true;
52+
}
53+
}
54+
55+
protected function getRateLimit(): int
56+
{
57+
try {
58+
return ConfigurationUtility::getRateLimitRequestsPerMinute();
59+
} catch (ExtensionConfigurationExtensionNotConfiguredException | ExtensionConfigurationPathDoesNotExistException $exception) {
60+
return ConfigurationUtility::RATE_LIMIT;
61+
}
62+
}
63+
64+
protected function logRateLimitExceeded(string $fingerprintHash, int $currentCount, int $limit): void
65+
{
66+
if (ConfigurationUtility::isExceptionLoggingActivated()) {
67+
$this->logger->warning('Rate limit exceeded', [
68+
'fingerprint' => $fingerprintHash,
69+
'count' => $currentCount,
70+
'limit' => $limit,
71+
'component' => 'RateLimiter',
72+
]);
73+
}
74+
}
75+
}
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+
namespace In2code\Lux\Exception;
5+
6+
use Exception;
7+
8+
class RateLimitException extends Exception
9+
{
10+
}

Classes/Utility/ConfigurationUtility.php

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212

1313
class ConfigurationUtility
1414
{
15+
public const RATE_LIMIT = 20;
16+
1517
/**
1618
* @return string
1719
* @throws ExtensionConfigurationExtensionNotConfiguredException
@@ -217,6 +219,32 @@ public static function isExceptionLoggingActivated(): bool
217219
return ($extensionConfig['enableExceptionLogging'] ?? '0') === '1';
218220
}
219221

222+
/**
223+
* Check if rate limiting is enabled
224+
*
225+
* @return bool
226+
* @throws ExtensionConfigurationExtensionNotConfiguredException
227+
* @throws ExtensionConfigurationPathDoesNotExistException
228+
*/
229+
public static function isRateLimitingEnabled(): bool
230+
{
231+
$extensionConfig = self::getExtensionConfiguration();
232+
return ($extensionConfig['enableRateLimiting'] ?? '1') === '1';
233+
}
234+
235+
/**
236+
* Get rate limit (requests per minute)
237+
*
238+
* @return int
239+
* @throws ExtensionConfigurationExtensionNotConfiguredException
240+
* @throws ExtensionConfigurationPathDoesNotExistException
241+
*/
242+
public static function getRateLimitRequestsPerMinute(): int
243+
{
244+
$extensionConfig = self::getExtensionConfiguration();
245+
return (int)($extensionConfig['rateLimitRequestsPerMinute'] ?? self::RATE_LIMIT);
246+
}
247+
220248
/**
221249
* @return bool
222250
* @throws ExtensionConfigurationExtensionNotConfiguredException

Configuration/Services.yaml

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,16 @@ services:
1717
arguments:
1818
$cache: '@cache.luxcachelayer'
1919

20+
cache.luxratelimiter:
21+
class: TYPO3\CMS\Core\Cache\Frontend\FrontendInterface
22+
factory: [ '@TYPO3\CMS\Core\Cache\CacheManager', 'getCache' ]
23+
arguments: [ 'luxratelimiter' ]
24+
25+
In2code\Lux\Domain\Cache\RateLimiterCache:
26+
public: true
27+
arguments:
28+
$cache: '@cache.luxratelimiter'
29+
2030
In2code\Lux\Command\LuxAnonymizeCommand:
2131
tags:
2232
- name: 'console.command'
@@ -243,6 +253,12 @@ services:
243253
identifier: 'lux/stopTracking'
244254
event: In2code\Lux\Events\StopAnyProcessBeforePersistenceEvent
245255

256+
In2code\Lux\Domain\Tracker\RateLimiter:
257+
tags:
258+
- name: 'event.listener'
259+
identifier: 'lux/rateLimiter'
260+
event: In2code\Lux\Events\StopAnyProcessBeforePersistenceEvent
261+
246262
In2code\Lux\Domain\Tracker\UtmTracker:
247263
public: true
248264
tags:
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
<?php
2+
3+
namespace In2code\Lux\Tests\Unit\Domain\Tracker;
4+
5+
use In2code\Lux\Domain\Cache\RateLimiterCache;
6+
use In2code\Lux\Domain\Model\Fingerprint;
7+
use In2code\Lux\Domain\Tracker\RateLimiter;
8+
use In2code\Lux\Events\StopAnyProcessBeforePersistenceEvent;
9+
use In2code\Lux\Exception\RateLimitException;
10+
use In2code\Lux\Tests\Helper\TestingHelper;
11+
use PHPUnit\Framework\Attributes\CoversClass;
12+
use PHPUnit\Framework\Attributes\Test;
13+
use Psr\Log\LoggerInterface;
14+
use TYPO3\TestingFramework\Core\Unit\UnitTestCase;
15+
16+
#[CoversClass(RateLimiter::class)]
17+
class RateLimiterTest extends UnitTestCase
18+
{
19+
protected bool $resetSingletonInstances = true;
20+
21+
public function setUp(): void
22+
{
23+
parent::setUp();
24+
TestingHelper::setDefaultConstants();
25+
}
26+
27+
#[Test]
28+
public function itAllowsRequestsUnderLimit(): void
29+
{
30+
// Mock cache to return count below limit
31+
$cacheMock = $this->createMock(RateLimiterCache::class);
32+
$cacheMock->expects(self::once())
33+
->method('incrementAndGet')
34+
->with('test1234567890abcdef1234567890ab1234567890abcdef1234567890abcdef')
35+
->willReturn(10); // 10 < 20 (default limit)
36+
37+
$loggerMock = $this->createMock(LoggerInterface::class);
38+
$loggerMock->expects(self::never())
39+
->method('warning'); // Should not log when under limit
40+
41+
$rateLimiter = new RateLimiter($cacheMock, $loggerMock);
42+
43+
// Create real fingerprint and event objects (they are final)
44+
$fingerprint = $this->createFingerprintWithValue('test1234567890abcdef1234567890ab1234567890abcdef1234567890abcdef');
45+
$event = new StopAnyProcessBeforePersistenceEvent($fingerprint);
46+
47+
// Should not throw exception
48+
$rateLimiter->__invoke($event);
49+
50+
// If we reach here, the test passes
51+
self::assertTrue(true);
52+
}
53+
54+
#[Test]
55+
public function itBlocksRequestsOverLimit(): void
56+
{
57+
// Mock cache to return count over limit
58+
$cacheMock = $this->createMock(RateLimiterCache::class);
59+
$cacheMock->expects(self::once())
60+
->method('incrementAndGet')
61+
->with('test1234567890abcdef1234567890ab1234567890abcdef1234567890abcdef')
62+
->willReturn(21); // 21 > 20 (default limit)
63+
64+
$loggerMock = $this->createMock(LoggerInterface::class);
65+
66+
$rateLimiter = new RateLimiter($cacheMock, $loggerMock);
67+
68+
// Create real fingerprint and event objects (they are final)
69+
$fingerprint = $this->createFingerprintWithValue('test1234567890abcdef1234567890ab1234567890abcdef1234567890abcdef');
70+
$event = new StopAnyProcessBeforePersistenceEvent($fingerprint);
71+
72+
// Should throw exception
73+
$this->expectException(RateLimitException::class);
74+
$this->expectExceptionMessage('Rate limit exceeded');
75+
$this->expectExceptionCode(1768214806);
76+
77+
$rateLimiter->__invoke($event);
78+
}
79+
80+
#[Test]
81+
public function itHandlesEmptyFingerprint(): void
82+
{
83+
// Mock cache should not be called
84+
$cacheMock = $this->createMock(RateLimiterCache::class);
85+
$cacheMock->expects(self::never())
86+
->method('incrementAndGet');
87+
88+
$loggerMock = $this->createMock(LoggerInterface::class);
89+
90+
$rateLimiter = new RateLimiter($cacheMock, $loggerMock);
91+
92+
// Create fingerprint with empty value
93+
$fingerprint = new Fingerprint('example.com', 'TestUA');
94+
// Note: value is empty by default since we don't call setValue()
95+
$event = new StopAnyProcessBeforePersistenceEvent($fingerprint);
96+
97+
// Should return early without exception
98+
$rateLimiter->__invoke($event);
99+
100+
// If we reach here, the test passes
101+
self::assertTrue(true);
102+
}
103+
104+
#[Test]
105+
public function itIncrementsCounterOnEachRequest(): void
106+
{
107+
// Mock cache to verify incrementAndGet is called
108+
$cacheMock = $this->createMock(RateLimiterCache::class);
109+
$cacheMock->expects(self::once())
110+
->method('incrementAndGet')
111+
->with('test1234567890abcdef1234567890ab1234567890abcdef1234567890abcdef')
112+
->willReturn(1); // First request
113+
114+
$loggerMock = $this->createMock(LoggerInterface::class);
115+
116+
$rateLimiter = new RateLimiter($cacheMock, $loggerMock);
117+
118+
// Create real fingerprint and event objects (they are final)
119+
$fingerprint = $this->createFingerprintWithValue('test1234567890abcdef1234567890ab1234567890abcdef1234567890abcdef');
120+
$event = new StopAnyProcessBeforePersistenceEvent($fingerprint);
121+
122+
$rateLimiter->__invoke($event);
123+
124+
// Test passes if incrementAndGet was called
125+
self::assertTrue(true);
126+
}
127+
128+
/**
129+
* Helper method to create a Fingerprint with a specific value
130+
* Uses reflection to set the protected value property
131+
*
132+
* @param string $value
133+
* @return Fingerprint
134+
*/
135+
protected function createFingerprintWithValue(string $value): Fingerprint
136+
{
137+
$fingerprint = new Fingerprint('example.com', 'TestUA');
138+
$reflection = new \ReflectionClass($fingerprint);
139+
$property = $reflection->getProperty('value');
140+
$property->setAccessible(true);
141+
$property->setValue($fingerprint, $value);
142+
return $fingerprint;
143+
}
144+
}

ext_conf_template.txt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,3 +57,9 @@ useCacheLayer = 1
5757

5858
# cat=advanced/enable/280; type=boolean; label= Enable exception logging: If the user is not logged in into backend and an exception happens, those exceptions can be logged as warning in var/log/typo3_[hash].log
5959
enableExceptionLogging = 0
60+
61+
# cat=advanced/enable/290; type=boolean; label= Enable rate limiting: Protect tracking endpoints from abuse by limiting requests per fingerprint. Recommended to keep enabled.
62+
enableRateLimiting = 1
63+
64+
# cat=advanced/enable/300; type=number; label= Rate limit requests per minute: Maximum number of tracking requests allowed per fingerprint per minute. Default is 100.
65+
rateLimitRequestsPerMinute = 20

ext_localconf.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,8 @@ function () {
6969
$cacheKeys = [
7070
\In2code\Lux\Domain\Service\Image\VisitorImageService::CACHE_KEY,
7171
\In2code\Lux\Domain\Service\Image\CompanyImageService::CACHE_KEY,
72-
\In2code\Lux\Domain\Cache\CacheLayer::CACHE_KEY
72+
\In2code\Lux\Domain\Cache\CacheLayer::CACHE_KEY,
73+
\In2code\Lux\Domain\Cache\RateLimiterCache::CACHE_KEY,
7374
];
7475
foreach ($cacheKeys as $cacheKey) {
7576
if (!isset($GLOBALS['TYPO3_CONF_VARS']['SYS']['caching']['cacheConfigurations'][$cacheKey])) {

0 commit comments

Comments
 (0)