Skip to content

Commit 37645f2

Browse files
committed
[FEATURE] Add RateLimit spam check method
A new spam prevention method that 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. The RateLimitMethod utilizes the Symfony rate limiter that is already used by the TYPO3 backend login to prevent brute-force attacks. Only valid form submissions are counted toward the rate limit. This requires splitting the rate limit check (RateLimitMethod) from the actual rate limit consumption (RateLimitFinisher). 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. 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. Heavily inspired by Chris Müller's brotkrueml/typo3-form-rate-limit extension.
1 parent d447452 commit 37645f2

File tree

10 files changed

+351
-2
lines changed

10 files changed

+351
-2
lines changed

.project/tests/phpstan-baseline.neon

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1945,6 +1945,11 @@ parameters:
19451945
count: 3
19461946
path: ../../Classes/Domain/Validator/SpamShield/NameMethod.php
19471947

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

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: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,38 @@ 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+
# Parts of the rate limiting key
211+
# - placeholders: __ipAddress, __formIdentifier
212+
# - form values: {email}
213+
# - hard coded values: foo
214+
restrictions {
215+
10 = __ipAddress
216+
20 = __formIdentifier
217+
}
218+
}
219+
}
188220
}
189221
}
190222
}

Configuration/TypoScript/Main/Configuration/30_Finisher.typoscript

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
plugin.tx_powermail.settings.setup {
55
finishers {
66
# Powermail finishers
7+
0.class = In2code\Powermail\Finisher\RateLimitFinisher
78
10.class = In2code\Powermail\Finisher\SaveToAnyTableFinisher
89
20.class = In2code\Powermail\Finisher\SendParametersFinisher
910
100.class = In2code\Powermail\Finisher\RedirectFinisher

Documentation/ForAdministrators/BestPractice/SpamPrevention.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ After a submit, different spammethods must be passed:
2121
a configured list of disallowed words.
2222
- **IP-Address Blacklist**: User IP address must not be on the list of
2323
disallowed addresses.
24+
- **Rate limiting**: User IP address may submit form only N times within a
25+
time frame
2426

2527
Every submitted form will be checked with this methods. Every failed
2628
method adds a Spam-Indication-Number to a storage. The sum of the
@@ -185,6 +187,38 @@ plugin.tx_powermail {
185187
values.value = 123.132.125.123,123.132.125.124
186188
}
187189
}
190+
191+
# Rate limiter
192+
8 {
193+
_enable = 1
194+
195+
# Spamcheck name
196+
name = IP rate limiter
197+
198+
# Class
199+
class = In2code\Powermail\Domain\Validator\SpamShield\RateLimitMethod
200+
201+
# if this check fails - add this indication value to indicator (0 disables this check completely)
202+
indication = 100
203+
204+
# method configuration
205+
configuration {
206+
#see "DateTimeInterval" class for allowed values
207+
interval = 5 minutes
208+
209+
#number of form sumissions within the interval
210+
limit = 10
211+
212+
# Parts of the rate limiting key
213+
# - placeholders: __ipAddress, __formIdentifier
214+
# - form values: {email}
215+
# - hard coded values: foo
216+
restrictions {
217+
10 = __ipAddress
218+
20 = __formIdentifier
219+
}
220+
}
221+
}
188222
}
189223
}
190224
}

Documentation/ForDevelopers/AddFinisherClasses.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -169,4 +169,4 @@ class DoSomethingFinisher extends AbstractFinisher
169169
* The method `initializeFinisher()` will always be called at first.
170170
* Every finisher method could have its own initialize method, which will be called before. Like `initializeMyFinisher()` before `myFinisher()`.
171171
* Classes in extensions (if namespace and filename fits) will be automatically included from TYPO3 autoloader. If you place a single file in fileadmin, use "require" in TypoScript.
172-
* Per default 10, 20 and 100 is already in use from powermail itself (SaveToAnyTableFinisher, SendParametersFinisher, RedirectFinisher).
172+
* Per default 0, 10, 20 and 100 are already in use from powermail itself (RateLimitFinisher, SaveToAnyTableFinisher, SendParametersFinisher, RedirectFinisher).

0 commit comments

Comments
 (0)