Skip to content

Commit ffdfa69

Browse files
committed
[FEATURE] Add RateLimit spam check method
A new spam prevention method utilizing the Symfony rate limiter that is already used by the TYPO3 backend login to prevent brute-force attacks. It marks form submissions as spam when a user submits forms too often in a given time frame, e.g. 10x in 5 minutes. This helps reducing spam flood attacks, because only the first submissions will be allowed. Both interval and limit are configurable via TypoScript. All valid DateTimeInterval strings are accepted, allowing interval declarations like "10 minutes" or "5 hours". Configuring the properties for rate limiting identifier is possible: Either rate limit all submissions from an IP address, or rate limit submissions from an IP to a certain form only. Adding form field values is possible as well, preventing duplicate submissions from e.g. an e-mail addresses. Heavily inspired by Chris Müller's brotkrueml/typo3-form-rate-limit extension. Rate limit information is stored via TYPO3's caching framework in the 'ratelimiter' cache. This allows admins to share the limit across multiple machines by configuring it to use a database or redis backend.
1 parent d447452 commit ffdfa69

File tree

4 files changed

+255
-0
lines changed

4 files changed

+255
-0
lines changed
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
namespace In2code\Powermail\Domain\Validator\SpamShield;
5+
6+
use In2code\Powermail\Storage\RateLimitStorage;
7+
use In2code\Powermail\Utility\ObjectUtility;
8+
use Symfony\Component\RateLimiter\RateLimiterFactory;
9+
use TYPO3\CMS\Core\Utility\GeneralUtility;
10+
11+
/**
12+
* Limit the number of submissions in a given time frame
13+
*
14+
* Exclusion of IP addresses is possible with a powermail breaker configuration.
15+
*/
16+
class RateLimitMethod extends AbstractMethod
17+
{
18+
/**
19+
* Check if this form submission is limited or shall be allowed.
20+
*
21+
* @return bool true if spam recognized
22+
*/
23+
public function spamCheck(): bool
24+
{
25+
$config = [
26+
'id' => 'powermail-ratelimit',
27+
'policy' => 'sliding_window',
28+
'limit' => $this->getLimit(),
29+
'interval' => $this->getInterval(),
30+
];
31+
32+
$storage = GeneralUtility::makeInstance(RateLimitStorage::class);
33+
34+
$factory = new RateLimiterFactory($config, $storage);
35+
36+
$keyParts = $this->getRestrictionValues($this->getRestrictions());
37+
$key = implode('-', $keyParts);
38+
39+
$limiter = $factory->create($key);
40+
if ($limiter->consume()->isAccepted()) {
41+
return false;
42+
}
43+
44+
//spam
45+
return true;
46+
}
47+
48+
/**
49+
* Replace the restriction variables with their values
50+
*/
51+
protected function getRestrictionValues(array $restrictions): array
52+
{
53+
$answers = $this->mail->getAnswersByFieldMarker();
54+
55+
$values = [];
56+
foreach ($restrictions as $restriction) {
57+
if ($restriction === '__ipAddress') {
58+
$values[$restriction] = GeneralUtility::getIndpEnv('REMOTE_ADDR');
59+
60+
} elseif ($restriction === '__formIdentifier') {
61+
$values[$restriction] = $this->mail->getForm()->getUid();
62+
63+
} elseif ($restriction[0] === '{') {
64+
//form field
65+
$fieldName = substr($restriction, 1, -1);
66+
if (!isset($answers[$fieldName])) {
67+
throw new \InvalidArgumentException('Form has no field with variable name ' . $field, 1763046923);
68+
}
69+
$values[$restriction] = $answers[$fieldName]->getValue();
70+
71+
} else {
72+
//hard-coded value
73+
$values[$restriction] = $restriction;
74+
}
75+
}
76+
77+
return $values;
78+
}
79+
80+
/**
81+
* Get the configured time interval in which the limit has to be adhered to
82+
*/
83+
protected function getInterval(): string
84+
{
85+
$interval = $this->configuration['interval'];
86+
87+
if ($interval === null) {
88+
throw new \InvalidArgumentException('Interval must be set!', 1671448702);
89+
}
90+
if (! \is_string($interval)) {
91+
throw new \InvalidArgumentException('Interval must be a string!', 1671448703);
92+
}
93+
94+
if (@\DateInterval::createFromDateString($interval) === false) {
95+
// @todo Remove check and exception when compatibility of PHP >= 8.3
96+
// @see https://www.php.net/manual/de/class.datemalformedintervalstringexception.php
97+
throw new \InvalidArgumentException(
98+
\sprintf(
99+
'Interval is not valid, "%s" given!',
100+
$interval,
101+
),
102+
1671448704,
103+
);
104+
}
105+
106+
return $interval;
107+
}
108+
109+
/**
110+
* Get how many form submissions are allowed within the time interval
111+
*/
112+
protected function getLimit(): int
113+
{
114+
$limit = $this->configuration['limit'];
115+
116+
if ($limit === null) {
117+
throw new \InvalidArgumentException('Limit must be set!', 1671449026);
118+
}
119+
120+
if (! \is_numeric($limit)) {
121+
throw new \InvalidArgumentException('Limit must be numeric!', 1671449027);
122+
}
123+
124+
$limit = (int) $limit;
125+
if ($limit < 1) {
126+
throw new \InvalidArgumentException('Limit must be greater than 0!', 1671449028);
127+
}
128+
129+
return $limit;
130+
}
131+
132+
/**
133+
* Get the list of properties that are used to identify the form
134+
*
135+
* Supported values:
136+
* - __ipAddress
137+
* - __formIdentifier
138+
* - {email} - Form field names
139+
* - foo - Hard-coded values
140+
*/
141+
protected function getRestrictions(): array
142+
{
143+
$restrictions = $this->configuration['restrictions'];
144+
145+
if ($restrictions === null) {
146+
throw new \InvalidArgumentException('Restrictions must be set!', 1671727527);
147+
}
148+
149+
if (! \is_array($restrictions)) {
150+
throw new \InvalidArgumentException('Restrictions must be an array!', 1671727528);
151+
}
152+
153+
if ($restrictions === []) {
154+
throw new \InvalidArgumentException('Restrictions must not be an empty array!', 1671727529);
155+
}
156+
157+
foreach ($restrictions as $restriction) {
158+
if (! \is_string($restriction)) {
159+
throw new \InvalidArgumentException('A single restrictions must be a string!', 1671727530);
160+
}
161+
}
162+
163+
return \array_values($restrictions);
164+
}
165+
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace In2code\Powermail\Storage;
6+
7+
use Symfony\Component\DependencyInjection\Attribute\Autoconfigure;
8+
use Symfony\Component\RateLimiter\LimiterStateInterface;
9+
use Symfony\Component\RateLimiter\Policy\SlidingWindow;
10+
use Symfony\Component\RateLimiter\Policy\TokenBucket;
11+
use Symfony\Component\RateLimiter\Policy\Window;
12+
use Symfony\Component\RateLimiter\Storage\StorageInterface;
13+
use TYPO3\CMS\Core\Cache\CacheManager;
14+
use TYPO3\CMS\Core\Cache\Frontend\FrontendInterface;
15+
16+
/**
17+
* Copy of TYPO3's internal CachingFrameworkStorage.
18+
*
19+
* \TYPO3\CMS\Core\RateLimiter\Storage\CachingFrameworkStorage
20+
*/
21+
class RateLimitStorage implements StorageInterface
22+
{
23+
private FrontendInterface $cacheInstance;
24+
25+
public function __construct(CacheManager $cacheInstance)
26+
{
27+
$this->cacheInstance = $cacheInstance->getCache('ratelimiter');
28+
$this->cacheInstance->collectGarbage();
29+
}
30+
31+
public function save(LimiterStateInterface $limiterState): void
32+
{
33+
$this->cacheInstance->set(
34+
sha1($limiterState->getId()),
35+
serialize($limiterState),
36+
[],
37+
$limiterState->getExpirationTime()
38+
);
39+
}
40+
41+
public function fetch(string $limiterStateId): ?LimiterStateInterface
42+
{
43+
$cacheItem = $this->cacheInstance->get(sha1($limiterStateId));
44+
if ($cacheItem) {
45+
$value = unserialize($cacheItem, ['allowed_classes' => [Window::class, SlidingWindow::class, TokenBucket::class]]);
46+
if ($value instanceof LimiterStateInterface) {
47+
return $value;
48+
}
49+
}
50+
51+
return null;
52+
}
53+
54+
public function delete(string $limiterStateId): void
55+
{
56+
$this->cacheInstance->remove(sha1($limiterStateId));
57+
}
58+
}

Configuration/Services.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,3 +46,6 @@ services:
4646
- name: 'event.listener'
4747
identifier: 'powermail/modify-data-structure'
4848
method: 'modifyDataStructure'
49+
50+
In2code\Powermail\Storage\RateLimitStorage:
51+
public: true

Configuration/TypoScript/Main/Configuration/12_Spamshield.typoscript

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,35 @@ plugin.tx_powermail.settings.setup {
185185
values.value = 123.132.125.123,123.132.125.124
186186
}
187187
}
188+
189+
# Rate limiter
190+
8 {
191+
_enable = 1
192+
193+
# Spamcheck name
194+
name = IP rate limiter
195+
196+
# Class
197+
class = In2code\Powermail\Domain\Validator\SpamShield\RateLimitMethod
198+
199+
# if this check fails - add this indication value to indicator (0 disables this check completely)
200+
indication = 100
201+
202+
# method configuration
203+
configuration {
204+
#see "DateTimeInterval" class for allowed values
205+
interval = 5 minutes
206+
207+
#number of form sumissions within the interval
208+
limit = 10
209+
210+
restrictions {
211+
10 = __ipAddress
212+
20 = __formIdentifier
213+
30 = {email}
214+
}
215+
}
216+
}
188217
}
189218
}
190219
}

0 commit comments

Comments
 (0)