Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .project/tests/phpstan-baseline.neon
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
173 changes: 173 additions & 0 deletions Classes/Domain/Validator/SpamShield/RateLimitMethod.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
<?php

declare(strict_types=1);
namespace In2code\Powermail\Domain\Validator\SpamShield;

use In2code\Powermail\Finisher\RateLimitFinisher;
use In2code\Powermail\Storage\RateLimitStorage;
use Symfony\Component\RateLimiter\RateLimiterFactory;
use TYPO3\CMS\Core\Utility\GeneralUtility;

/**
* Limit the number of submissions in a given time frame.
*
* Marks the submission as spam if the rate limit has been reached.
* Counting a submission against the rate limit is done in RateLimitFinisher.
*
* Exclusion of IP addresses is possible with a powermail breaker configuration.
*/
class RateLimitMethod extends AbstractMethod
{
/**
* Check if this form submission is limited or shall be allowed.
*
* @return bool true if spam recognized
*/
public function spamCheck(): bool
{
$config = [
'id' => '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);
}
}
43 changes: 43 additions & 0 deletions Classes/Finisher/RateLimitFinisher.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
<?php

declare(strict_types=1);
namespace In2code\Powermail\Finisher;

use Symfony\Component\RateLimiter\LimiterInterface;

/**
* Count the form submission against the rate limit.
*
* Only valid form submissions count against the rate limit.
* This is implemented by checking for available tokens in the
* RateLimitMethod spam shield, but consuming the tokens here.
*
* @link https://symfony.com/doc/7.3/rate_limiter.html
*/
class RateLimitFinisher extends AbstractFinisher
{
/**
* All the limiters that shall be consumed when the form is submitted.
*
* @var LimiterInterface[]
*/
protected static array $limiters = [];

/**
* Marks the limiter as to be consumed when the mail is accepted and stored.
*/
public static function markForConsumption(LimiterInterface $limiter): void
{
static::$limiters[] = $limiter;
}

/**
* Consume a token for each rate limiter.
*/
public function consumeLimitersFinisher(): void
{
foreach (static::$limiters as $limiter) {
$limiter->consume(1);
}
}
}
57 changes: 57 additions & 0 deletions Classes/Storage/RateLimitStorage.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
<?php

declare(strict_types=1);

namespace In2code\Powermail\Storage;

use Symfony\Component\RateLimiter\LimiterStateInterface;
use Symfony\Component\RateLimiter\Policy\SlidingWindow;
use Symfony\Component\RateLimiter\Policy\TokenBucket;
use Symfony\Component\RateLimiter\Policy\Window;
use Symfony\Component\RateLimiter\Storage\StorageInterface;
use TYPO3\CMS\Core\Cache\CacheManager;
use TYPO3\CMS\Core\Cache\Frontend\FrontendInterface;

/**
* Copy of TYPO3's internal CachingFrameworkStorage.
*
* \TYPO3\CMS\Core\RateLimiter\Storage\CachingFrameworkStorage
*/
class RateLimitStorage implements StorageInterface
{
private FrontendInterface $cacheInstance;

public function __construct(CacheManager $cacheInstance)
{
$this->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));
}
}
3 changes: 3 additions & 0 deletions Configuration/Services.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -46,3 +46,6 @@ services:
- name: 'event.listener'
identifier: 'powermail/modify-data-structure'
method: 'modifyDataStructure'

In2code\Powermail\Storage\RateLimitStorage:
public: true
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
34 changes: 34 additions & 0 deletions Documentation/ForAdministrators/BestPractice/SpamPrevention.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
}
}
}
}
}
Expand Down
2 changes: 1 addition & 1 deletion Documentation/ForDevelopers/AddFinisherClasses.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Loading