Skip to content
Merged
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
13 changes: 0 additions & 13 deletions build/psalm-baseline.xml
Original file line number Diff line number Diff line change
Expand Up @@ -6069,11 +6069,6 @@
<code>OC_User::getUser()</code>
</InvalidScalarArgument>
</file>
<file src="lib/private/legacy/OC_Template.php">
<ImplementedReturnTypeMismatch occurrences="1">
<code>boolean|string</code>
</ImplementedReturnTypeMismatch>
</file>
<file src="lib/private/legacy/OC_User.php">
<UndefinedClass occurrences="1">
<code>\Test\Util\User\Dummy</code>
Expand Down Expand Up @@ -6155,14 +6150,6 @@
<file src="lib/public/AppFramework/Http/Template/PublicTemplateResponse.php">
<InvalidScalarArgument occurrences="1"/>
</file>
<file src="lib/public/AppFramework/Http/TemplateResponse.php">
<InvalidReturnStatement occurrences="1">
<code>$template-&gt;fetchPage($this-&gt;params)</code>
</InvalidReturnStatement>
<InvalidReturnType occurrences="1">
<code>string</code>
</InvalidReturnType>
</file>
<file src="lib/public/AppFramework/Http/ZipResponse.php">
<InvalidArrayAccess occurrences="5">
<code>$resource['size']</code>
Expand Down
4 changes: 4 additions & 0 deletions core/templates/429.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<div class="body-login-container update">
<h2><?php p($l->t('Too many requests')); ?></h2>
<p class="infogroup"><?php p($l->t('There were too many requests from your network. Retry later or contact your administrator if this is an error.')); ?></p>
</div>
2 changes: 2 additions & 0 deletions lib/composer/composer/autoload_classmap.php
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@
'OCP\\AppFramework\\Http\\Template\\LinkMenuAction' => $baseDir . '/lib/public/AppFramework/Http/Template/LinkMenuAction.php',
'OCP\\AppFramework\\Http\\Template\\PublicTemplateResponse' => $baseDir . '/lib/public/AppFramework/Http/Template/PublicTemplateResponse.php',
'OCP\\AppFramework\\Http\\Template\\SimpleMenuAction' => $baseDir . '/lib/public/AppFramework/Http/Template/SimpleMenuAction.php',
'OCP\\AppFramework\\Http\\TooManyRequestsResponse' => $baseDir . '/lib/public/AppFramework/Http/TooManyRequestsResponse.php',
'OCP\\AppFramework\\Http\\ZipResponse' => $baseDir . '/lib/public/AppFramework/Http/ZipResponse.php',
'OCP\\AppFramework\\IAppContainer' => $baseDir . '/lib/public/AppFramework/IAppContainer.php',
'OCP\\AppFramework\\Middleware' => $baseDir . '/lib/public/AppFramework/Middleware.php',
Expand Down Expand Up @@ -448,6 +449,7 @@
'OCP\\Search\\Result' => $baseDir . '/lib/public/Search/Result.php',
'OCP\\Search\\SearchResult' => $baseDir . '/lib/public/Search/SearchResult.php',
'OCP\\Search\\SearchResultEntry' => $baseDir . '/lib/public/Search/SearchResultEntry.php',
'OCP\\Security\\Bruteforce\\MaxDelayReached' => $baseDir . '/lib/public/Security/Bruteforce/MaxDelayReached.php',
'OCP\\Security\\CSP\\AddContentSecurityPolicyEvent' => $baseDir . '/lib/public/Security/CSP/AddContentSecurityPolicyEvent.php',
'OCP\\Security\\Events\\GenerateSecurePasswordEvent' => $baseDir . '/lib/public/Security/Events/GenerateSecurePasswordEvent.php',
'OCP\\Security\\Events\\ValidatePasswordPolicyEvent' => $baseDir . '/lib/public/Security/Events/ValidatePasswordPolicyEvent.php',
Expand Down
2 changes: 2 additions & 0 deletions lib/composer/composer/autoload_static.php
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ class ComposerStaticInit53792487c5a8370acc0b06b1a864ff4c
'OCP\\AppFramework\\Http\\Template\\LinkMenuAction' => __DIR__ . '/../../..' . '/lib/public/AppFramework/Http/Template/LinkMenuAction.php',
'OCP\\AppFramework\\Http\\Template\\PublicTemplateResponse' => __DIR__ . '/../../..' . '/lib/public/AppFramework/Http/Template/PublicTemplateResponse.php',
'OCP\\AppFramework\\Http\\Template\\SimpleMenuAction' => __DIR__ . '/../../..' . '/lib/public/AppFramework/Http/Template/SimpleMenuAction.php',
'OCP\\AppFramework\\Http\\TooManyRequestsResponse' => __DIR__ . '/../../..' . '/lib/public/AppFramework/Http/TooManyRequestsResponse.php',
'OCP\\AppFramework\\Http\\ZipResponse' => __DIR__ . '/../../..' . '/lib/public/AppFramework/Http/ZipResponse.php',
'OCP\\AppFramework\\IAppContainer' => __DIR__ . '/../../..' . '/lib/public/AppFramework/IAppContainer.php',
'OCP\\AppFramework\\Middleware' => __DIR__ . '/../../..' . '/lib/public/AppFramework/Middleware.php',
Expand Down Expand Up @@ -477,6 +478,7 @@ class ComposerStaticInit53792487c5a8370acc0b06b1a864ff4c
'OCP\\Search\\Result' => __DIR__ . '/../../..' . '/lib/public/Search/Result.php',
'OCP\\Search\\SearchResult' => __DIR__ . '/../../..' . '/lib/public/Search/SearchResult.php',
'OCP\\Search\\SearchResultEntry' => __DIR__ . '/../../..' . '/lib/public/Search/SearchResultEntry.php',
'OCP\\Security\\Bruteforce\\MaxDelayReached' => __DIR__ . '/../../..' . '/lib/public/Security/Bruteforce/MaxDelayReached.php',
'OCP\\Security\\CSP\\AddContentSecurityPolicyEvent' => __DIR__ . '/../../..' . '/lib/public/Security/CSP/AddContentSecurityPolicyEvent.php',
'OCP\\Security\\Events\\GenerateSecurePasswordEvent' => __DIR__ . '/../../..' . '/lib/public/Security/Events/GenerateSecurePasswordEvent.php',
'OCP\\Security\\Events\\ValidatePasswordPolicyEvent' => __DIR__ . '/../../..' . '/lib/public/Security/Events/ValidatePasswordPolicyEvent.php',
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
<?php

declare(strict_types=1);
/**
* @copyright Copyright (c) 2017 Lukas Reschke <[email protected]>
*
Expand Down Expand Up @@ -26,9 +28,15 @@

use OC\AppFramework\Utility\ControllerMethodReflector;
use OC\Security\Bruteforce\Throttler;
use OCP\AppFramework\Controller;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Response;
use OCP\AppFramework\Http\TooManyRequestsResponse;
use OCP\AppFramework\Middleware;
use OCP\AppFramework\OCS\OCSException;
use OCP\AppFramework\OCSController;
use OCP\IRequest;
use OCP\Security\Bruteforce\MaxDelayReached;

/**
* Class BruteForceMiddleware performs the bruteforce protection for controllers
Expand Down Expand Up @@ -66,7 +74,7 @@ public function beforeController($controller, $methodName) {

if ($this->reflector->hasAnnotation('BruteForceProtection')) {
$action = $this->reflector->getAnnotationParameter('BruteForceProtection', 'action');
$this->throttler->sleepDelay($this->request->getRemoteAddress(), $action);
$this->throttler->sleepDelayOrThrowOnMax($this->request->getRemoteAddress(), $action);
}
}

Expand All @@ -83,4 +91,23 @@ public function afterController($controller, $methodName, Response $response) {

return parent::afterController($controller, $methodName, $response);
}

/**
* @param Controller $controller
* @param string $methodName
* @param \Exception $exception
* @throws \Exception
* @return Response
*/
public function afterException($controller, $methodName, \Exception $exception): Response {
if ($exception instanceof MaxDelayReached) {
if ($controller instanceof OCSController) {
throw new OCSException($exception->getMessage(), Http::STATUS_TOO_MANY_REQUESTS);
}

return new TooManyRequestsResponse();
}

throw $exception;
}
}
89 changes: 64 additions & 25 deletions lib/private/Security/Bruteforce/Throttler.php
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
<?php

declare(strict_types=1);
/**
* @copyright Copyright (c) 2016 Lukas Reschke <[email protected]>
*
Expand Down Expand Up @@ -34,6 +36,7 @@
use OCP\IConfig;
use OCP\IDBConnection;
use OCP\ILogger;
use OCP\Security\Bruteforce\MaxDelayReached;

/**
* Class Throttler implements the bruteforce protection for security actions in
Expand All @@ -50,6 +53,9 @@
*/
class Throttler {
public const LOGIN_ACTION = 'login';
public const MAX_DELAY = 25;
public const MAX_DELAY_MS = 25000; // in milliseconds
public const MAX_ATTEMPTS = 10;

/** @var IDBConnection */
private $db;
Expand Down Expand Up @@ -82,7 +88,7 @@ public function __construct(IDBConnection $db,
* @param int $expire
* @return \DateInterval
*/
private function getCutoff($expire) {
private function getCutoff(int $expire): \DateInterval {
$d1 = new \DateTime();
$d2 = clone $d1;
$d2->sub(new \DateInterval('PT' . $expire . 'S'));
Expand All @@ -92,11 +98,12 @@ private function getCutoff($expire) {
/**
* Calculate the cut off timestamp
*
* @param float $maxAgeHours
* @return int
*/
private function getCutoffTimestamp(): int {
private function getCutoffTimestamp(float $maxAgeHours = 12.0): int {
return (new \DateTime())
->sub($this->getCutoff(43200))
->sub($this->getCutoff((int) ($maxAgeHours * 3600)))
->getTimestamp();
}

Expand All @@ -108,9 +115,9 @@ private function getCutoffTimestamp(): int {
* @param array $metadata Optional metadata logged to the database
* @suppress SqlInjectionChecker
*/
public function registerAttempt($action,
$ip,
array $metadata = []) {
public function registerAttempt(string $action,
string $ip,
array $metadata = []): void {
// No need to log if the bruteforce protection is disabled
if ($this->config->getSystemValue('auth.bruteforce.protection.enabled', true) === false) {
return;
Expand Down Expand Up @@ -150,15 +157,14 @@ public function registerAttempt($action,
* @param string $ip
* @return bool
*/
private function isIPWhitelisted($ip) {
private function isIPWhitelisted(string $ip): bool {
if ($this->config->getSystemValue('auth.bruteforce.protection.enabled', true) === false) {
return true;
}

$keys = $this->config->getAppKeys('bruteForce');
$keys = array_filter($keys, function ($key) {
$regex = '/^whitelist_/S';
return preg_match($regex, $key) === 1;
return 0 === strpos($key, 'whitelist_');
});

if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
Expand Down Expand Up @@ -215,18 +221,19 @@ private function isIPWhitelisted($ip) {
*
* @param string $ip
* @param string $action optionally filter by action
* @param float $maxAgeHours
* @return int
*/
public function getDelay($ip, $action = '') {
public function getAttempts(string $ip, string $action = '', float $maxAgeHours = 12): int {
$ipAddress = new IpAddress($ip);
if ($this->isIPWhitelisted((string)$ipAddress)) {
return 0;
}

$cutoffTime = $this->getCutoffTimestamp();
$cutoffTime = $this->getCutoffTimestamp($maxAgeHours);

$qb = $this->db->getQueryBuilder();
$qb->select('*')
$qb->select($qb->func()->count('*', 'attempts'))
->from('bruteforce_attempts')
->where($qb->expr()->gt('occurred', $qb->createNamedParameter($cutoffTime)))
->andWhere($qb->expr()->eq('subnet', $qb->createNamedParameter($ipAddress->getSubnet())));
Expand All @@ -235,34 +242,47 @@ public function getDelay($ip, $action = '') {
$qb->andWhere($qb->expr()->eq('action', $qb->createNamedParameter($action)));
}

$attempts = count($qb->execute()->fetchAll());
$result = $qb->execute();
$row = $result->fetch();
$result->closeCursor();

return (int) $row['attempts'];
}

/**
* Get the throttling delay (in milliseconds)
*
* @param string $ip
* @param string $action optionally filter by action
* @return int
*/
public function getDelay(string $ip, string $action = ''): int {
$attempts = $this->getAttempts($ip, $action);
if ($attempts === 0) {
return 0;
}

$maxDelay = 25;
$firstDelay = 0.1;
if ($attempts > (8 * PHP_INT_SIZE - 1)) {
if ($attempts > self::MAX_ATTEMPTS) {
// Don't ever overflow. Just assume the maxDelay time:s
$firstDelay = $maxDelay;
} else {
$firstDelay *= pow(2, $attempts);
if ($firstDelay > $maxDelay) {
$firstDelay = $maxDelay;
}
return self::MAX_DELAY_MS;
}
return (int) \ceil($firstDelay * 1000);

$delay = $firstDelay * 2**$attempts;
if ($delay > self::MAX_DELAY) {
return self::MAX_DELAY_MS;
}
return (int) \ceil($delay * 1000);
}

/**
* Reset the throttling delay for an IP address, action and metadata
*
* @param string $ip
* @param string $action
* @param string $metadata
* @param array $metadata
*/
public function resetDelay($ip, $action, $metadata) {
public function resetDelay(string $ip, string $action, array $metadata): void {
$ipAddress = new IpAddress($ip);
if ($this->isIPWhitelisted((string)$ipAddress)) {
return;
Expand Down Expand Up @@ -303,9 +323,28 @@ public function resetDelayForIP($ip) {
* @param string $action optionally filter by action
* @return int the time spent sleeping
*/
public function sleepDelay($ip, $action = '') {
public function sleepDelay(string $ip, string $action = ''): int {
$delay = $this->getDelay($ip, $action);
usleep($delay * 1000);
return $delay;
}

/**
* Will sleep for the defined amount of time unless maximum was reached in the last 30 minutes
* In this case a "429 Too Many Request" exception is thrown
*
* @param string $ip
* @param string $action optionally filter by action
* @return int the time spent sleeping
* @throws MaxDelayReached when reached the maximum
*/
public function sleepDelayOrThrowOnMax(string $ip, string $action = ''): int {
$delay = $this->getDelay($ip, $action);
if (($delay === self::MAX_DELAY_MS) && $this->getAttempts($ip, $action, 0.5) > self::MAX_ATTEMPTS) {
// If the ip made too many attempts within the last 30 mins we don't execute anymore
throw new MaxDelayReached('Reached maximum delay');
}
usleep($delay * 1000);
return $delay;
}
}
2 changes: 1 addition & 1 deletion lib/private/legacy/OC_Template.php
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,7 @@ public function addHeader($tag, $attributes, $text=null) {

/**
* Process the template
* @return boolean|string
* @return string
*
* This function process the template. If $this->renderAs is set, it
* will produce a full page.
Expand Down
52 changes: 52 additions & 0 deletions lib/public/AppFramework/Http/TooManyRequestsResponse.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
<?php

declare(strict_types=1);
/**
* @copyright Copyright (c) 2020 Joas Schilling <[email protected]>
*
* @license GNU AGPL version 3 or any later version
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero 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 Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/

namespace OCP\AppFramework\Http;

use OCP\Template;

/**
* A generic 429 response showing an 404 error page as well to the end-user
* @since 19.0.0
*/
class TooManyRequestsResponse extends Response {

/**
* @since 19.0.0
*/
public function __construct() {
parent::__construct();

$this->setContentSecurityPolicy(new ContentSecurityPolicy());
$this->setStatus(429);
}

/**
* @return string
* @since 19.0.0
*/
public function render() {
$template = new Template('core', '429', 'blank');
return $template->fetchPage();
}
}
Loading