Skip to content

Commit 7e0115e

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 7e0115e

File tree

7 files changed

+300
-1
lines changed

7 files changed

+300
-1
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: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
namespace In2code\Powermail\Domain\Validator\SpamShield;
5+
6+
use In2code\Powermail\Storage\RateLimitStorage;
7+
use Symfony\Component\RateLimiter\RateLimiterFactory;
8+
use TYPO3\CMS\Core\Utility\GeneralUtility;
9+
10+
/**
11+
* Limit the number of submissions in a given time frame
12+
*
13+
* Exclusion of IP addresses is possible with a powermail breaker configuration.
14+
*/
15+
class RateLimitMethod extends AbstractMethod
16+
{
17+
/**
18+
* Check if this form submission is limited or shall be allowed.
19+
*
20+
* @return bool true if spam recognized
21+
*/
22+
public function spamCheck(): bool
23+
{
24+
$config = [
25+
'id' => 'powermail-ratelimit',
26+
'policy' => 'sliding_window',
27+
'limit' => $this->getLimit(),
28+
'interval' => $this->getInterval(),
29+
];
30+
31+
$storage = GeneralUtility::makeInstance(RateLimitStorage::class);
32+
33+
$factory = new RateLimiterFactory($config, $storage);
34+
35+
$keyParts = $this->getRestrictionValues($this->getRestrictions());
36+
$key = implode('-', $keyParts);
37+
38+
$limiter = $factory->create($key);
39+
if ($limiter->consume()->isAccepted()) {
40+
return false;
41+
}
42+
43+
//spam
44+
return true;
45+
}
46+
47+
/**
48+
* Replace the restriction variables with their values
49+
*
50+
* @param string[] $restrictions
51+
*
52+
* @return string[]
53+
*/
54+
protected function getRestrictionValues(array $restrictions): array
55+
{
56+
$answers = $this->mail->getAnswersByFieldMarker();
57+
58+
$values = [];
59+
foreach ($restrictions as $restriction) {
60+
if ($restriction === '__ipAddress') {
61+
$values[$restriction] = GeneralUtility::getIndpEnv('REMOTE_ADDR');
62+
} elseif ($restriction === '__formIdentifier') {
63+
$values[$restriction] = $this->mail->getForm()->getUid();
64+
} elseif ($restriction[0] === '{') {
65+
//form field
66+
$fieldName = substr($restriction, 1, -1);
67+
if (!isset($answers[$fieldName])) {
68+
throw new \InvalidArgumentException('Form has no field with variable name ' . $fieldName, 1763046923);
69+
}
70+
$values[$restriction] = $answers[$fieldName]->getValue();
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+
* @return string[]
142+
*/
143+
protected function getRestrictions(): array
144+
{
145+
$restrictions = $this->configuration['restrictions'];
146+
147+
if ($restrictions === null) {
148+
throw new \InvalidArgumentException('Restrictions must be set!', 1671727527);
149+
}
150+
151+
if (! \is_array($restrictions)) {
152+
throw new \InvalidArgumentException('Restrictions must be an array!', 1671727528);
153+
}
154+
155+
if ($restrictions === []) {
156+
throw new \InvalidArgumentException('Restrictions must not be an empty array!', 1671727529);
157+
}
158+
159+
foreach ($restrictions as $restriction) {
160+
if (! \is_string($restriction)) {
161+
throw new \InvalidArgumentException('A single restrictions must be a string!', 1671727530);
162+
}
163+
}
164+
165+
return \array_values($restrictions);
166+
}
167+
}
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
}

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
}

composer.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,8 @@
4343
"ext-gd": "*",
4444
"ext-fileinfo": "*",
4545
"ext-curl": "*",
46-
"phpoffice/phpspreadsheet": "^5.0"
46+
"phpoffice/phpspreadsheet": "^5.0",
47+
"symfony/rate-limiter": "^7.2"
4748
},
4849
"replace": {
4950
"typo3-ter/powermail": "self.version"

0 commit comments

Comments
 (0)