Skip to content

Commit

Permalink
Browse files Browse the repository at this point in the history
Add rate-limit for visitor actions that could send email
  • Loading branch information
fisharebest committed Oct 4, 2021
1 parent 81b729d commit 9ed332c
Show file tree
Hide file tree
Showing 5 changed files with 283 additions and 20 deletions.
24 changes: 16 additions & 8 deletions app/Http/RequestHandlers/ContactAction.php
Expand Up @@ -28,6 +28,7 @@
use Fisharebest\Webtrees\Services\CaptchaService;
use Fisharebest\Webtrees\Services\EmailService;
use Fisharebest\Webtrees\Services\MessageService;
use Fisharebest\Webtrees\Services\RateLimitService;
use Fisharebest\Webtrees\Services\UserService;
use Fisharebest\Webtrees\Tree;
use Fisharebest\Webtrees\Validator;
Expand Down Expand Up @@ -56,26 +57,31 @@ class ContactAction implements RequestHandlerInterface

private MessageService $message_service;

private RateLimitService $rate_limit_service;

private UserService $user_service;

/**
* MessagePage constructor.
*
* @param CaptchaService $captcha_service
* @param EmailService $email_service
* @param MessageService $message_service
* @param UserService $user_service
* @param CaptchaService $captcha_service
* @param EmailService $email_service
* @param MessageService $message_service
* @param RateLimitService $rate_limit_service
* @param UserService $user_service
*/
public function __construct(
CaptchaService $captcha_service,
EmailService $email_service,
MessageService $message_service,
RateLimitService $rate_limit_service,
UserService $user_service
) {
$this->captcha_service = $captcha_service;
$this->email_service = $email_service;
$this->user_service = $user_service;
$this->message_service = $message_service;
$this->captcha_service = $captcha_service;
$this->email_service = $email_service;
$this->user_service = $user_service;
$this->rate_limit_service = $rate_limit_service;
$this->message_service = $message_service;
}

/**
Expand Down Expand Up @@ -138,6 +144,8 @@ public function handle(ServerRequestInterface $request): ResponseInterface

$sender = new GuestUser($from_email, $from_name);

$this->rate_limit_service->limitRateForUser($to_user, 20, 1200, 'rate-limit-contact');

if ($this->message_service->deliverMessage($sender, $to_user, $subject, $body, $url, $ip)) {
FlashMessages::addMessage(I18N::translate('The message was successfully sent to %s.', e($to_user->realName())), 'success');

Expand Down
22 changes: 16 additions & 6 deletions app/Http/RequestHandlers/PasswordRequestAction.php
Expand Up @@ -25,6 +25,7 @@
use Fisharebest\Webtrees\I18N;
use Fisharebest\Webtrees\Log;
use Fisharebest\Webtrees\Services\EmailService;
use Fisharebest\Webtrees\Services\RateLimitService;
use Fisharebest\Webtrees\Services\UserService;
use Fisharebest\Webtrees\SiteUser;
use Fisharebest\Webtrees\Tree;
Expand All @@ -49,18 +50,25 @@ class PasswordRequestAction implements RequestHandlerInterface, StatusCodeInterf

private EmailService $email_service;

private RateLimitService $rate_limit_service;

private UserService $user_service;

/**
* PasswordRequestForm constructor.
*
* @param EmailService $email_service
* @param UserService $user_service
* @param EmailService $email_service
* @param RateLimitService $rate_limit_service
* @param UserService $user_service
*/
public function __construct(EmailService $email_service, UserService $user_service)
{
$this->user_service = $user_service;
$this->email_service = $email_service;
public function __construct(
EmailService $email_service,
RateLimitService $rate_limit_service,
UserService $user_service
) {
$this->email_service = $email_service;
$this->rate_limit_service = $rate_limit_service;
$this->user_service = $user_service;
}

/**
Expand All @@ -78,6 +86,8 @@ public function handle(ServerRequestInterface $request): ResponseInterface
$user = $this->user_service->findByEmail($email);

if ($user instanceof User) {
$this->rate_limit_service->limitRateForUser($user, 5, 300, 'rate-limit-pw-reset');

$token = Str::random(self::TOKEN_LENGTH);
$expire = (string) Carbon::now()->addHour()->getTimestamp();
$url = route(PasswordResetPage::class, [
Expand Down
20 changes: 14 additions & 6 deletions app/Http/RequestHandlers/RegisterAction.php
Expand Up @@ -29,6 +29,7 @@
use Fisharebest\Webtrees\NoReplyUser;
use Fisharebest\Webtrees\Services\CaptchaService;
use Fisharebest\Webtrees\Services\EmailService;
use Fisharebest\Webtrees\Services\RateLimitService;
use Fisharebest\Webtrees\Services\UserService;
use Fisharebest\Webtrees\Session;
use Fisharebest\Webtrees\Site;
Expand All @@ -54,23 +55,28 @@ class RegisterAction implements RequestHandlerInterface

private EmailService $email_service;

private RateLimitService $rate_limit_service;

private UserService $user_service;

/**
* RegisterController constructor.
*
* @param CaptchaService $captcha_service
* @param EmailService $email_service
* @param UserService $user_service
* @param CaptchaService $captcha_service
* @param EmailService $email_service
* @param RateLimitService $rate_limit_service
* @param UserService $user_service
*/
public function __construct(
CaptchaService $captcha_service,
EmailService $email_service,
RateLimitService $rate_limit_service,
UserService $user_service
) {
$this->captcha_service = $captcha_service;
$this->email_service = $email_service;
$this->user_service = $user_service;
$this->captcha_service = $captcha_service;
$this->email_service = $email_service;
$this->rate_limit_service = $rate_limit_service;
$this->user_service = $user_service;
}

/**
Expand Down Expand Up @@ -116,6 +122,8 @@ public function handle(ServerRequestInterface $request): ResponseInterface
return redirect(route(RegisterPage::class));
}

$this->rate_limit_service->limitRateForSite(5, 300, 'rate-limit-registration');

Log::addAuthenticationLog('User registration requested for: ' . $username);

$user = $this->user_service->create($username, $realname, $email, $password);
Expand Down
124 changes: 124 additions & 0 deletions app/Services/RateLimitService.php
@@ -0,0 +1,124 @@
<?php

/**
* webtrees: online genealogy
* Copyright (C) 2021 webtrees development team
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/

declare(strict_types=1);

namespace Fisharebest\Webtrees\Services;

use Fisharebest\Webtrees\Contracts\UserInterface;
use Fisharebest\Webtrees\Http\Exceptions\HttpTooManyRequestsException;
use Fisharebest\Webtrees\Site;
use LogicException;

use function array_filter;
use function explode;
use function intdiv;
use function strlen;
use function time;

/**
* Throttle events to prevent abuse.
*/
class RateLimitService
{
private int $now;

/**
*
*/
public function __construct()
{
$this->now = time();
}

/**
* Rate limit for actions related to a user, such as password reset request.
* Allow $num requests every $seconds
*
* @param int $num allow this number of events
* @param int $seconds in a rolling window of this number of seconds
* @param string $limit name of limit to enforce
*
* @return void
*/
public function limitRateForSite(int $num, int $seconds, string $limit): void
{
$history = Site::getPreference($limit);

$history = $this->checkLimitReached($num, $seconds, $history);

Site::setPreference($limit, $history);
}

/**
* Rate limit for actions related to a user, such as password reset request.
* Allow $num requests every $seconds
*
* @param UserInterface $user limit events for this user
* @param int $num allow this number of events
* @param int $seconds in a rolling window of this number of seconds
* @param string $limit name of limit to enforce
*
* @return void
*/
public function limitRateForUser(UserInterface $user, int $num, int $seconds, string $limit): void
{
$history = $user->getPreference($limit);

$history = $this->checkLimitReached($num, $seconds, $history);

$user->setPreference($limit, $history);
}

/**
* Rate limit - allow $num requests every $seconds
*
* @param int $num allow this number of events
* @param int $seconds in a rolling window of this number of seconds
* @param string $history comma-separated list of previous timestamps
*
* @return string updated list of timestamps
* @throws HttpTooManyRequestsException
*/
private function checkLimitReached(int $num, int $seconds, string $history): string
{
// Make sure we can store enough previous timestamps in a database field.
$max = intdiv(256, strlen($this->now . ','));
if ($num > $max) {
throw new LogicException('Cannot store ' . $num . ' previous events in the database');
}

// Extract the timestamps.
$timestamps = array_filter(explode(',', $history));

// Filter events within our time window.
$filter = fn(string $x): bool => (int) $x >= $this->now - $seconds && (int) $x <= $this->now;
$in_window = array_filter($timestamps, $filter);

if (count($in_window) >= $num) {
throw new HttpTooManyRequestsException();
}

$timestamps[] = (string) $this->now;

while (count($timestamps) > $max) {
array_shift($timestamps);
}

return implode(',', $timestamps);
}
}
113 changes: 113 additions & 0 deletions tests/app/Services/RateLimitServiceTest.php
@@ -0,0 +1,113 @@
<?php

/**
* webtrees: online genealogy
* Copyright (C) 2021 webtrees development team
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/

declare(strict_types=1);

namespace Fisharebest\Webtrees\Services;

use Fisharebest\Webtrees\GuestUser;
use Fisharebest\Webtrees\Http\Exceptions\HttpTooManyRequestsException;
use Fisharebest\Webtrees\TestCase;
use LogicException;

use function explode;
use function implode;
use function range;
use function time;

/**
* Test harness for the class RateLimitService
*/
class RateLimitServiceTest extends TestCase
{
/**
* @covers \Fisharebest\Webtrees\Services\RateLimitService
*
* @return void
*/
public function testTooMuchHistory(): void
{
$rate_limit_service = new RateLimitService();

$user = new GuestUser();

$this->expectException(LogicException::class);

$rate_limit_service->limitRateForUser($user, 1000, 30, 'rate-limit');
}

/**
* @covers \Fisharebest\Webtrees\Services\RateLimitService
*
* @return void
*/
public function testLimitNotReached(): void
{
$rate_limit_service = new RateLimitService();

$user = new GuestUser();

$rate_limit_service->limitRateForUser($user, 3, 30, 'rate-limit');
$history = $user->getPreference('rate-limit');
$this->assertCount(1, explode(',', $history));

$rate_limit_service->limitRateForUser($user, 3, 30, 'rate-limit');
$history = $user->getPreference('rate-limit');
$this->assertCount(2, explode(',', $history));

$rate_limit_service->limitRateForUser($user, 3, 30, 'rate-limit');
$history = $user->getPreference('rate-limit');
$this->assertCount(3, explode(',', $history));
}

/**
* @covers \Fisharebest\Webtrees\Services\RateLimitService
*
* @return void
*/
public function testOldEventsIgnored(): void
{
$rate_limit_service = new RateLimitService();

$user = new GuestUser();

$history = implode(',', range(time() - 35, time() - 31));
$user->setPreference('rate-limit', $history);

$rate_limit_service->limitRateForUser($user, 5, 30, 'rate-limit');
$history = $user->getPreference('rate-limit');
$this->assertCount(6, explode(',', $history));
}

/**
* @covers \Fisharebest\Webtrees\Services\RateLimitService
*
* @return void
*/
public function testLimitReached(): void
{
$rate_limit_service = new RateLimitService();

$user = new GuestUser();

$history = implode(',', range(time() - 5, time() - 1));
$user->setPreference('rate-limit', $history);

$this->expectException(HttpTooManyRequestsException::class);
$rate_limit_service->limitRateForUser($user, 5, 30, 'rate-limit');
}
}

0 comments on commit 9ed332c

Please sign in to comment.