From 6121bbdfda6c90dbcffe95285832d7d51d72bf9b Mon Sep 17 00:00:00 2001 From: Christian Weiske Date: Thu, 13 Nov 2025 16:16:30 +0100 Subject: [PATCH] [FEATURE] Add RateLimit spam check method MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .project/tests/phpstan-baseline.neon | 5 + .../Validator/SpamShield/RateLimitMethod.php | 173 ++++++++++++++++++ Classes/Finisher/RateLimitFinisher.php | 43 +++++ Classes/Storage/RateLimitStorage.php | 57 ++++++ Configuration/Services.yaml | 3 + .../Configuration/12_Spamshield.typoscript | 32 ++++ .../Main/Configuration/30_Finisher.typoscript | 1 + .../BestPractice/SpamPrevention.md | 34 ++++ .../ForDevelopers/AddFinisherClasses.md | 2 +- composer.json | 3 +- 10 files changed, 351 insertions(+), 2 deletions(-) create mode 100644 Classes/Domain/Validator/SpamShield/RateLimitMethod.php create mode 100644 Classes/Finisher/RateLimitFinisher.php create mode 100644 Classes/Storage/RateLimitStorage.php diff --git a/.project/tests/phpstan-baseline.neon b/.project/tests/phpstan-baseline.neon index 2a59f61ae..100e53f85 100644 --- a/.project/tests/phpstan-baseline.neon +++ b/.project/tests/phpstan-baseline.neon @@ -1945,6 +1945,11 @@ parameters: count: 3 path: ../../Classes/Domain/Validator/SpamShield/NameMethod.php + - + message: "#^Cannot call method getUid\\(\\) on In2code\\\\Powermail\\\\Domain\\\\Model\\\\Form\\|TYPO3\\\\CMS\\\\Extbase\\\\Persistence\\\\Generic\\\\LazyLoadingProxy\\|null\\.$#" + count: 1 + path: ../../Classes/Domain/Validator/SpamShield/RateLimitMethod.php + - message: "#^Cannot call method getUid\\(\\) on In2code\\\\Powermail\\\\Domain\\\\Model\\\\Form\\|TYPO3\\\\CMS\\\\Extbase\\\\Persistence\\\\Generic\\\\LazyLoadingProxy\\|null\\.$#" count: 1 diff --git a/Classes/Domain/Validator/SpamShield/RateLimitMethod.php b/Classes/Domain/Validator/SpamShield/RateLimitMethod.php new file mode 100644 index 000000000..1e7243684 --- /dev/null +++ b/Classes/Domain/Validator/SpamShield/RateLimitMethod.php @@ -0,0 +1,173 @@ + 'powermail-ratelimit', + 'policy' => 'sliding_window', + 'limit' => $this->getLimit(), + 'interval' => $this->getInterval(), + ]; + + $storage = GeneralUtility::makeInstance(RateLimitStorage::class); + + $factory = new RateLimiterFactory($config, $storage); + + $keyParts = $this->getRestrictionValues($this->getRestrictions()); + $key = implode('-', $keyParts); + + $limiter = $factory->create($key); + RateLimitFinisher::markForConsumption($limiter); + + if ($limiter->consume(0)->getRemainingTokens() > 0) { + return false; + } + + //spam + return true; + } + + /** + * Replace the restriction variables with their values + * + * @param string[] $restrictions + * + * @return string[] + */ + protected function getRestrictionValues(array $restrictions): array + { + $answers = $this->mail->getAnswersByFieldMarker(); + + $values = []; + foreach ($restrictions as $restriction) { + if ($restriction === '__ipAddress') { + $values[$restriction] = GeneralUtility::getIndpEnv('REMOTE_ADDR'); + } elseif ($restriction === '__formIdentifier') { + $values[$restriction] = $this->mail->getForm()->getUid(); + } elseif ($restriction[0] === '{') { + //form field + $fieldName = substr($restriction, 1, -1); + if (!isset($answers[$fieldName])) { + throw new \InvalidArgumentException('Form has no field with variable name ' . $fieldName, 1763046923); + } + $values[$restriction] = $answers[$fieldName]->getValue(); + } else { + //hard-coded value + $values[$restriction] = $restriction; + } + } + + return $values; + } + + /** + * Get the configured time interval in which the limit has to be adhered to + */ + protected function getInterval(): string + { + $interval = $this->configuration['interval']; + + if ($interval === null) { + throw new \InvalidArgumentException('Interval must be set!', 1671448702); + } + if (! \is_string($interval)) { + throw new \InvalidArgumentException('Interval must be a string!', 1671448703); + } + + if (@\DateInterval::createFromDateString($interval) === false) { + // @todo Remove check and exception when compatibility of PHP >= 8.3 + // @see https://www.php.net/manual/de/class.datemalformedintervalstringexception.php + throw new \InvalidArgumentException( + \sprintf( + 'Interval is not valid, "%s" given!', + $interval, + ), + 1671448704, + ); + } + + return $interval; + } + + /** + * Get how many form submissions are allowed within the time interval + */ + protected function getLimit(): int + { + $limit = $this->configuration['limit']; + + if ($limit === null) { + throw new \InvalidArgumentException('Limit must be set!', 1671449026); + } + + if (! \is_numeric($limit)) { + throw new \InvalidArgumentException('Limit must be numeric!', 1671449027); + } + + $limit = (int)$limit; + if ($limit < 1) { + throw new \InvalidArgumentException('Limit must be greater than 0!', 1671449028); + } + + return $limit; + } + + /** + * Get the list of properties that are used to identify the form + * + * Supported values: + * - __ipAddress + * - __formIdentifier + * - {email} - Form field names + * - foo - Hard-coded values + * + * @return string[] + */ + protected function getRestrictions(): array + { + $restrictions = $this->configuration['restrictions']; + + if ($restrictions === null) { + throw new \InvalidArgumentException('Restrictions must be set!', 1671727527); + } + + if (! \is_array($restrictions)) { + throw new \InvalidArgumentException('Restrictions must be an array!', 1671727528); + } + + if ($restrictions === []) { + throw new \InvalidArgumentException('Restrictions must not be an empty array!', 1671727529); + } + + foreach ($restrictions as $restriction) { + if (! \is_string($restriction)) { + throw new \InvalidArgumentException('A single restrictions must be a string!', 1671727530); + } + } + + return \array_values($restrictions); + } +} diff --git a/Classes/Finisher/RateLimitFinisher.php b/Classes/Finisher/RateLimitFinisher.php new file mode 100644 index 000000000..007e2b64e --- /dev/null +++ b/Classes/Finisher/RateLimitFinisher.php @@ -0,0 +1,43 @@ +consume(1); + } + } +} diff --git a/Classes/Storage/RateLimitStorage.php b/Classes/Storage/RateLimitStorage.php new file mode 100644 index 000000000..b605b3b1b --- /dev/null +++ b/Classes/Storage/RateLimitStorage.php @@ -0,0 +1,57 @@ +cacheInstance = $cacheInstance->getCache('ratelimiter'); + $this->cacheInstance->collectGarbage(); + } + + public function save(LimiterStateInterface $limiterState): void + { + $this->cacheInstance->set( + sha1($limiterState->getId()), + serialize($limiterState), + [], + $limiterState->getExpirationTime() + ); + } + + public function fetch(string $limiterStateId): ?LimiterStateInterface + { + $cacheItem = $this->cacheInstance->get(sha1($limiterStateId)); + if ($cacheItem) { + $value = unserialize($cacheItem, ['allowed_classes' => [Window::class, SlidingWindow::class, TokenBucket::class]]); + if ($value instanceof LimiterStateInterface) { + return $value; + } + } + + return null; + } + + public function delete(string $limiterStateId): void + { + $this->cacheInstance->remove(sha1($limiterStateId)); + } +} diff --git a/Configuration/Services.yaml b/Configuration/Services.yaml index 532e92aa0..363912d05 100644 --- a/Configuration/Services.yaml +++ b/Configuration/Services.yaml @@ -46,3 +46,6 @@ services: - name: 'event.listener' identifier: 'powermail/modify-data-structure' method: 'modifyDataStructure' + + In2code\Powermail\Storage\RateLimitStorage: + public: true diff --git a/Configuration/TypoScript/Main/Configuration/12_Spamshield.typoscript b/Configuration/TypoScript/Main/Configuration/12_Spamshield.typoscript index 8afaf0e14..31e0db30c 100644 --- a/Configuration/TypoScript/Main/Configuration/12_Spamshield.typoscript +++ b/Configuration/TypoScript/Main/Configuration/12_Spamshield.typoscript @@ -185,6 +185,38 @@ plugin.tx_powermail.settings.setup { values.value = 123.132.125.123,123.132.125.124 } } + + # Rate limiter + 8 { + _enable = 1 + + # Spamcheck name + name = IP rate limiter + + # Class + class = In2code\Powermail\Domain\Validator\SpamShield\RateLimitMethod + + # if this check fails - add this indication value to indicator (0 disables this check completely) + indication = 100 + + # method configuration + configuration { + #see "DateTimeInterval" class for allowed values + interval = 5 minutes + + #number of form sumissions within the interval + limit = 10 + + # Parts of the rate limiting key + # - placeholders: __ipAddress, __formIdentifier + # - form values: {email} + # - hard coded values: foo + restrictions { + 10 = __ipAddress + 20 = __formIdentifier + } + } + } } } } diff --git a/Configuration/TypoScript/Main/Configuration/30_Finisher.typoscript b/Configuration/TypoScript/Main/Configuration/30_Finisher.typoscript index 3884bff4c..ca6513924 100644 --- a/Configuration/TypoScript/Main/Configuration/30_Finisher.typoscript +++ b/Configuration/TypoScript/Main/Configuration/30_Finisher.typoscript @@ -4,6 +4,7 @@ plugin.tx_powermail.settings.setup { finishers { # Powermail finishers + 0.class = In2code\Powermail\Finisher\RateLimitFinisher 10.class = In2code\Powermail\Finisher\SaveToAnyTableFinisher 20.class = In2code\Powermail\Finisher\SendParametersFinisher 100.class = In2code\Powermail\Finisher\RedirectFinisher diff --git a/Documentation/ForAdministrators/BestPractice/SpamPrevention.md b/Documentation/ForAdministrators/BestPractice/SpamPrevention.md index 4f5159752..42ea3d8b1 100644 --- a/Documentation/ForAdministrators/BestPractice/SpamPrevention.md +++ b/Documentation/ForAdministrators/BestPractice/SpamPrevention.md @@ -21,6 +21,8 @@ After a submit, different spammethods must be passed: a configured list of disallowed words. - **IP-Address Blacklist**: User IP address must not be on the list of disallowed addresses. +- **Rate limiting**: User IP address may submit form only N times within a + time frame Every submitted form will be checked with this methods. Every failed method adds a Spam-Indication-Number to a storage. The sum of the @@ -185,6 +187,38 @@ plugin.tx_powermail { values.value = 123.132.125.123,123.132.125.124 } } + + # Rate limiter + 8 { + _enable = 1 + + # Spamcheck name + name = IP rate limiter + + # Class + class = In2code\Powermail\Domain\Validator\SpamShield\RateLimitMethod + + # if this check fails - add this indication value to indicator (0 disables this check completely) + indication = 100 + + # method configuration + configuration { + #see "DateTimeInterval" class for allowed values + interval = 5 minutes + + #number of form sumissions within the interval + limit = 10 + + # Parts of the rate limiting key + # - placeholders: __ipAddress, __formIdentifier + # - form values: {email} + # - hard coded values: foo + restrictions { + 10 = __ipAddress + 20 = __formIdentifier + } + } + } } } } diff --git a/Documentation/ForDevelopers/AddFinisherClasses.md b/Documentation/ForDevelopers/AddFinisherClasses.md index 552d7e829..2cc1adcee 100644 --- a/Documentation/ForDevelopers/AddFinisherClasses.md +++ b/Documentation/ForDevelopers/AddFinisherClasses.md @@ -169,4 +169,4 @@ class DoSomethingFinisher extends AbstractFinisher * The method `initializeFinisher()` will always be called at first. * Every finisher method could have its own initialize method, which will be called before. Like `initializeMyFinisher()` before `myFinisher()`. * 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. -* Per default 10, 20 and 100 is already in use from powermail itself (SaveToAnyTableFinisher, SendParametersFinisher, RedirectFinisher). +* Per default 0, 10, 20 and 100 are already in use from powermail itself (RateLimitFinisher, SaveToAnyTableFinisher, SendParametersFinisher, RedirectFinisher). diff --git a/composer.json b/composer.json index e930b3db5..fb9bdd498 100644 --- a/composer.json +++ b/composer.json @@ -43,7 +43,8 @@ "ext-gd": "*", "ext-fileinfo": "*", "ext-curl": "*", - "phpoffice/phpspreadsheet": "^5.0" + "phpoffice/phpspreadsheet": "^5.0", + "symfony/rate-limiter": "^7.2" }, "replace": { "typo3-ter/powermail": "self.version"