3d2600b039
This adds a phan plugin which checks for SQL injections on code using our QueryBuilder, while it isn't perfect it should already catch most potential issues. As always, static analysis will sometimes have false positives and this is also here the case. So in some cases the analyzer just doesn't know if something is potential user input or not, thus I had to add some `@suppress SqlInjectionChecker` in front of those potential injections. The Phan plugin hasn't the most awesome code but it works and I also added a file with test cases. Signed-off-by: Lukas Reschke <lukas@statuscode.ch>
254 lines
6.4 KiB
PHP
254 lines
6.4 KiB
PHP
<?php
|
|
/**
|
|
* @copyright Copyright (c) 2016 Lukas Reschke <lukas@statuscode.ch>
|
|
*
|
|
* @author Lukas Reschke <lukas@statuscode.ch>
|
|
*
|
|
* @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 OC\Security\Bruteforce;
|
|
|
|
use OC\Security\Normalizer\IpAddress;
|
|
use OCP\AppFramework\Utility\ITimeFactory;
|
|
use OCP\IConfig;
|
|
use OCP\IDBConnection;
|
|
use OCP\ILogger;
|
|
|
|
/**
|
|
* Class Throttler implements the bruteforce protection for security actions in
|
|
* Nextcloud.
|
|
*
|
|
* It is working by logging invalid login attempts to the database and slowing
|
|
* down all login attempts from the same subnet. The max delay is 30 seconds and
|
|
* the starting delay are 200 milliseconds. (after the first failed login)
|
|
*
|
|
* This is based on Paragonie's AirBrake for Airship CMS. You can find the original
|
|
* code at https://github.com/paragonie/airship/blob/7e5bad7e3c0fbbf324c11f963fd1f80e59762606/src/Engine/Security/AirBrake.php
|
|
*
|
|
* @package OC\Security\Bruteforce
|
|
*/
|
|
class Throttler {
|
|
const LOGIN_ACTION = 'login';
|
|
|
|
/** @var IDBConnection */
|
|
private $db;
|
|
/** @var ITimeFactory */
|
|
private $timeFactory;
|
|
/** @var ILogger */
|
|
private $logger;
|
|
/** @var IConfig */
|
|
private $config;
|
|
|
|
/**
|
|
* @param IDBConnection $db
|
|
* @param ITimeFactory $timeFactory
|
|
* @param ILogger $logger
|
|
* @param IConfig $config
|
|
*/
|
|
public function __construct(IDBConnection $db,
|
|
ITimeFactory $timeFactory,
|
|
ILogger $logger,
|
|
IConfig $config) {
|
|
$this->db = $db;
|
|
$this->timeFactory = $timeFactory;
|
|
$this->logger = $logger;
|
|
$this->config = $config;
|
|
}
|
|
|
|
/**
|
|
* Convert a number of seconds into the appropriate DateInterval
|
|
*
|
|
* @param int $expire
|
|
* @return \DateInterval
|
|
*/
|
|
private function getCutoff($expire) {
|
|
$d1 = new \DateTime();
|
|
$d2 = clone $d1;
|
|
$d2->sub(new \DateInterval('PT' . $expire . 'S'));
|
|
return $d2->diff($d1);
|
|
}
|
|
|
|
/**
|
|
* Register a failed attempt to bruteforce a security control
|
|
*
|
|
* @param string $action
|
|
* @param string $ip
|
|
* @param array $metadata Optional metadata logged to the database
|
|
* @suppress SqlInjectionChecker
|
|
*/
|
|
public function registerAttempt($action,
|
|
$ip,
|
|
array $metadata = []) {
|
|
// No need to log if the bruteforce protection is disabled
|
|
if($this->config->getSystemValue('auth.bruteforce.protection.enabled', true) === false) {
|
|
return;
|
|
}
|
|
|
|
$ipAddress = new IpAddress($ip);
|
|
$values = [
|
|
'action' => $action,
|
|
'occurred' => $this->timeFactory->getTime(),
|
|
'ip' => (string)$ipAddress,
|
|
'subnet' => $ipAddress->getSubnet(),
|
|
'metadata' => json_encode($metadata),
|
|
];
|
|
|
|
$this->logger->notice(
|
|
sprintf(
|
|
'Bruteforce attempt from "%s" detected for action "%s".',
|
|
$ip,
|
|
$action
|
|
),
|
|
[
|
|
'app' => 'core',
|
|
]
|
|
);
|
|
|
|
$qb = $this->db->getQueryBuilder();
|
|
$qb->insert('bruteforce_attempts');
|
|
foreach($values as $column => $value) {
|
|
$qb->setValue($column, $qb->createNamedParameter($value));
|
|
}
|
|
$qb->execute();
|
|
}
|
|
|
|
/**
|
|
* Check if the IP is whitelisted
|
|
*
|
|
* @param string $ip
|
|
* @return bool
|
|
*/
|
|
private function isIPWhitelisted($ip) {
|
|
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;
|
|
});
|
|
|
|
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
|
|
$type = 4;
|
|
} else if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) {
|
|
$type = 6;
|
|
} else {
|
|
return false;
|
|
}
|
|
|
|
$ip = inet_pton($ip);
|
|
|
|
foreach ($keys as $key) {
|
|
$cidr = $this->config->getAppValue('bruteForce', $key, null);
|
|
|
|
$cx = explode('/', $cidr);
|
|
$addr = $cx[0];
|
|
$mask = (int)$cx[1];
|
|
|
|
// Do not compare ipv4 to ipv6
|
|
if (($type === 4 && !filter_var($addr, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) ||
|
|
($type === 6 && !filter_var($addr, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6))) {
|
|
continue;
|
|
}
|
|
|
|
$addr = inet_pton($addr);
|
|
|
|
$valid = true;
|
|
for($i = 0; $i < $mask; $i++) {
|
|
$part = ord($addr[(int)($i/8)]);
|
|
$orig = ord($ip[(int)($i/8)]);
|
|
|
|
$part = $part & (15 << (1 - ($i % 2)));
|
|
$orig = $orig & (15 << (1 - ($i % 2)));
|
|
|
|
if ($part !== $orig) {
|
|
$valid = false;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if ($valid === true) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
/**
|
|
* Get the throttling delay (in milliseconds)
|
|
*
|
|
* @param string $ip
|
|
* @param string $action optionally filter by action
|
|
* @return int
|
|
*/
|
|
public function getDelay($ip, $action = '') {
|
|
$ipAddress = new IpAddress($ip);
|
|
if ($this->isIPWhitelisted((string)$ipAddress)) {
|
|
return 0;
|
|
}
|
|
|
|
$cutoffTime = (new \DateTime())
|
|
->sub($this->getCutoff(43200))
|
|
->getTimestamp();
|
|
|
|
$qb = $this->db->getQueryBuilder();
|
|
$qb->select('*')
|
|
->from('bruteforce_attempts')
|
|
->where($qb->expr()->gt('occurred', $qb->createNamedParameter($cutoffTime)))
|
|
->andWhere($qb->expr()->eq('subnet', $qb->createNamedParameter($ipAddress->getSubnet())));
|
|
|
|
if ($action !== '') {
|
|
$qb->andWhere($qb->expr()->eq('action', $qb->createNamedParameter($action)));
|
|
}
|
|
|
|
$attempts = count($qb->execute()->fetchAll());
|
|
|
|
if ($attempts === 0) {
|
|
return 0;
|
|
}
|
|
|
|
$maxDelay = 30;
|
|
$firstDelay = 0.1;
|
|
if ($attempts > (8 * PHP_INT_SIZE - 1)) {
|
|
// Don't ever overflow. Just assume the maxDelay time:s
|
|
$firstDelay = $maxDelay;
|
|
} else {
|
|
$firstDelay *= pow(2, $attempts);
|
|
if ($firstDelay > $maxDelay) {
|
|
$firstDelay = $maxDelay;
|
|
}
|
|
}
|
|
return (int) \ceil($firstDelay * 1000);
|
|
}
|
|
|
|
/**
|
|
* Will sleep for the defined amount of time
|
|
*
|
|
* @param string $ip
|
|
* @param string $action optionally filter by action
|
|
* @return int the time spent sleeping
|
|
*/
|
|
public function sleepDelay($ip, $action = '') {
|
|
$delay = $this->getDelay($ip, $action);
|
|
usleep($delay * 1000);
|
|
return $delay;
|
|
}
|
|
}
|