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"