1
0

Throttler.php 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365
  1. <?php
  2. declare(strict_types=1);
  3. /**
  4. * @copyright Copyright (c) 2016 Lukas Reschke <lukas@statuscode.ch>
  5. *
  6. * @author Bjoern Schiessle <bjoern@schiessle.org>
  7. * @author Christoph Wurst <christoph@winzerhof-wurst.at>
  8. * @author Joas Schilling <coding@schilljs.com>
  9. * @author Johannes Riedel <joeried@users.noreply.github.com>
  10. * @author Lukas Reschke <lukas@statuscode.ch>
  11. * @author Morris Jobke <hey@morrisjobke.de>
  12. * @author Robin Appelman <robin@icewind.nl>
  13. * @author Roeland Jago Douma <roeland@famdouma.nl>
  14. *
  15. * @license GNU AGPL version 3 or any later version
  16. *
  17. * This program is free software: you can redistribute it and/or modify
  18. * it under the terms of the GNU Affero General Public License as
  19. * published by the Free Software Foundation, either version 3 of the
  20. * License, or (at your option) any later version.
  21. *
  22. * This program is distributed in the hope that it will be useful,
  23. * but WITHOUT ANY WARRANTY; without even the implied warranty of
  24. * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  25. * GNU Affero General Public License for more details.
  26. *
  27. * You should have received a copy of the GNU Affero General Public License
  28. * along with this program. If not, see <http://www.gnu.org/licenses/>.
  29. *
  30. */
  31. namespace OC\Security\Bruteforce;
  32. use OC\Security\Normalizer\IpAddress;
  33. use OCP\AppFramework\Utility\ITimeFactory;
  34. use OCP\IConfig;
  35. use OCP\IDBConnection;
  36. use OCP\Security\Bruteforce\IThrottler;
  37. use OCP\Security\Bruteforce\MaxDelayReached;
  38. use Psr\Log\LoggerInterface;
  39. /**
  40. * Class Throttler implements the bruteforce protection for security actions in
  41. * Nextcloud.
  42. *
  43. * It is working by logging invalid login attempts to the database and slowing
  44. * down all login attempts from the same subnet. The max delay is 30 seconds and
  45. * the starting delay are 200 milliseconds. (after the first failed login)
  46. *
  47. * This is based on Paragonie's AirBrake for Airship CMS. You can find the original
  48. * code at https://github.com/paragonie/airship/blob/7e5bad7e3c0fbbf324c11f963fd1f80e59762606/src/Engine/Security/AirBrake.php
  49. *
  50. * @package OC\Security\Bruteforce
  51. */
  52. class Throttler implements IThrottler {
  53. public const LOGIN_ACTION = 'login';
  54. /** @var IDBConnection */
  55. private $db;
  56. /** @var ITimeFactory */
  57. private $timeFactory;
  58. private LoggerInterface $logger;
  59. /** @var IConfig */
  60. private $config;
  61. /** @var bool[] */
  62. private $hasAttemptsDeleted = [];
  63. public function __construct(IDBConnection $db,
  64. ITimeFactory $timeFactory,
  65. LoggerInterface $logger,
  66. IConfig $config) {
  67. $this->db = $db;
  68. $this->timeFactory = $timeFactory;
  69. $this->logger = $logger;
  70. $this->config = $config;
  71. }
  72. /**
  73. * Convert a number of seconds into the appropriate DateInterval
  74. *
  75. * @param int $expire
  76. * @return \DateInterval
  77. */
  78. private function getCutoff(int $expire): \DateInterval {
  79. $d1 = new \DateTime();
  80. $d2 = clone $d1;
  81. $d2->sub(new \DateInterval('PT' . $expire . 'S'));
  82. return $d2->diff($d1);
  83. }
  84. /**
  85. * Calculate the cut off timestamp
  86. *
  87. * @param float $maxAgeHours
  88. * @return int
  89. */
  90. private function getCutoffTimestamp(float $maxAgeHours = 12.0): int {
  91. return (new \DateTime())
  92. ->sub($this->getCutoff((int) ($maxAgeHours * 3600)))
  93. ->getTimestamp();
  94. }
  95. /**
  96. * Register a failed attempt to bruteforce a security control
  97. *
  98. * @param string $action
  99. * @param string $ip
  100. * @param array $metadata Optional metadata logged to the database
  101. */
  102. public function registerAttempt(string $action,
  103. string $ip,
  104. array $metadata = []): void {
  105. // No need to log if the bruteforce protection is disabled
  106. if (!$this->config->getSystemValueBool('auth.bruteforce.protection.enabled', true)) {
  107. return;
  108. }
  109. $ipAddress = new IpAddress($ip);
  110. $values = [
  111. 'action' => $action,
  112. 'occurred' => $this->timeFactory->getTime(),
  113. 'ip' => (string)$ipAddress,
  114. 'subnet' => $ipAddress->getSubnet(),
  115. 'metadata' => json_encode($metadata),
  116. ];
  117. $this->logger->notice(
  118. sprintf(
  119. 'Bruteforce attempt from "%s" detected for action "%s".',
  120. $ip,
  121. $action
  122. ),
  123. [
  124. 'app' => 'core',
  125. ]
  126. );
  127. $qb = $this->db->getQueryBuilder();
  128. $qb->insert('bruteforce_attempts');
  129. foreach ($values as $column => $value) {
  130. $qb->setValue($column, $qb->createNamedParameter($value));
  131. }
  132. $qb->execute();
  133. }
  134. /**
  135. * Check if the IP is whitelisted
  136. *
  137. * @param string $ip
  138. * @return bool
  139. */
  140. private function isIPWhitelisted(string $ip): bool {
  141. if (!$this->config->getSystemValueBool('auth.bruteforce.protection.enabled', true)) {
  142. return true;
  143. }
  144. $keys = $this->config->getAppKeys('bruteForce');
  145. $keys = array_filter($keys, function ($key) {
  146. return str_starts_with($key, 'whitelist_');
  147. });
  148. if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
  149. $type = 4;
  150. } elseif (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) {
  151. $type = 6;
  152. } else {
  153. return false;
  154. }
  155. $ip = inet_pton($ip);
  156. foreach ($keys as $key) {
  157. $cidr = $this->config->getAppValue('bruteForce', $key, null);
  158. $cx = explode('/', $cidr);
  159. $addr = $cx[0];
  160. $mask = (int)$cx[1];
  161. // Do not compare ipv4 to ipv6
  162. if (($type === 4 && !filter_var($addr, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) ||
  163. ($type === 6 && !filter_var($addr, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6))) {
  164. continue;
  165. }
  166. $addr = inet_pton($addr);
  167. $valid = true;
  168. for ($i = 0; $i < $mask; $i++) {
  169. $part = ord($addr[(int)($i / 8)]);
  170. $orig = ord($ip[(int)($i / 8)]);
  171. $bitmask = 1 << (7 - ($i % 8));
  172. $part = $part & $bitmask;
  173. $orig = $orig & $bitmask;
  174. if ($part !== $orig) {
  175. $valid = false;
  176. break;
  177. }
  178. }
  179. if ($valid === true) {
  180. return true;
  181. }
  182. }
  183. return false;
  184. }
  185. /**
  186. * Get the throttling delay (in milliseconds)
  187. *
  188. * @param string $ip
  189. * @param string $action optionally filter by action
  190. * @param float $maxAgeHours
  191. * @return int
  192. */
  193. public function getAttempts(string $ip, string $action = '', float $maxAgeHours = 12): int {
  194. if ($maxAgeHours > 48) {
  195. $this->logger->error('Bruteforce has to use less than 48 hours');
  196. $maxAgeHours = 48;
  197. }
  198. if ($ip === '' || isset($this->hasAttemptsDeleted[$action])) {
  199. return 0;
  200. }
  201. $ipAddress = new IpAddress($ip);
  202. if ($this->isIPWhitelisted((string)$ipAddress)) {
  203. return 0;
  204. }
  205. $cutoffTime = $this->getCutoffTimestamp($maxAgeHours);
  206. $qb = $this->db->getQueryBuilder();
  207. $qb->select($qb->func()->count('*', 'attempts'))
  208. ->from('bruteforce_attempts')
  209. ->where($qb->expr()->gt('occurred', $qb->createNamedParameter($cutoffTime)))
  210. ->andWhere($qb->expr()->eq('subnet', $qb->createNamedParameter($ipAddress->getSubnet())));
  211. if ($action !== '') {
  212. $qb->andWhere($qb->expr()->eq('action', $qb->createNamedParameter($action)));
  213. }
  214. $result = $qb->execute();
  215. $row = $result->fetch();
  216. $result->closeCursor();
  217. return (int) $row['attempts'];
  218. }
  219. /**
  220. * Get the throttling delay (in milliseconds)
  221. *
  222. * @param string $ip
  223. * @param string $action optionally filter by action
  224. * @return int
  225. */
  226. public function getDelay(string $ip, string $action = ''): int {
  227. $attempts = $this->getAttempts($ip, $action);
  228. if ($attempts === 0) {
  229. return 0;
  230. }
  231. $firstDelay = 0.1;
  232. if ($attempts > self::MAX_ATTEMPTS) {
  233. // Don't ever overflow. Just assume the maxDelay time:s
  234. return self::MAX_DELAY_MS;
  235. }
  236. $delay = $firstDelay * 2 ** $attempts;
  237. if ($delay > self::MAX_DELAY) {
  238. return self::MAX_DELAY_MS;
  239. }
  240. return (int) \ceil($delay * 1000);
  241. }
  242. /**
  243. * Reset the throttling delay for an IP address, action and metadata
  244. *
  245. * @param string $ip
  246. * @param string $action
  247. * @param array $metadata
  248. */
  249. public function resetDelay(string $ip, string $action, array $metadata): void {
  250. $ipAddress = new IpAddress($ip);
  251. if ($this->isIPWhitelisted((string)$ipAddress)) {
  252. return;
  253. }
  254. $cutoffTime = $this->getCutoffTimestamp();
  255. $qb = $this->db->getQueryBuilder();
  256. $qb->delete('bruteforce_attempts')
  257. ->where($qb->expr()->gt('occurred', $qb->createNamedParameter($cutoffTime)))
  258. ->andWhere($qb->expr()->eq('subnet', $qb->createNamedParameter($ipAddress->getSubnet())))
  259. ->andWhere($qb->expr()->eq('action', $qb->createNamedParameter($action)))
  260. ->andWhere($qb->expr()->eq('metadata', $qb->createNamedParameter(json_encode($metadata))));
  261. $qb->executeStatement();
  262. $this->hasAttemptsDeleted[$action] = true;
  263. }
  264. /**
  265. * Reset the throttling delay for an IP address
  266. *
  267. * @param string $ip
  268. */
  269. public function resetDelayForIP(string $ip): void {
  270. $cutoffTime = $this->getCutoffTimestamp();
  271. $qb = $this->db->getQueryBuilder();
  272. $qb->delete('bruteforce_attempts')
  273. ->where($qb->expr()->gt('occurred', $qb->createNamedParameter($cutoffTime)))
  274. ->andWhere($qb->expr()->eq('ip', $qb->createNamedParameter($ip)));
  275. $qb->execute();
  276. }
  277. /**
  278. * Will sleep for the defined amount of time
  279. *
  280. * @param string $ip
  281. * @param string $action optionally filter by action
  282. * @return int the time spent sleeping
  283. */
  284. public function sleepDelay(string $ip, string $action = ''): int {
  285. $delay = $this->getDelay($ip, $action);
  286. usleep($delay * 1000);
  287. return $delay;
  288. }
  289. /**
  290. * Will sleep for the defined amount of time unless maximum was reached in the last 30 minutes
  291. * In this case a "429 Too Many Request" exception is thrown
  292. *
  293. * @param string $ip
  294. * @param string $action optionally filter by action
  295. * @return int the time spent sleeping
  296. * @throws MaxDelayReached when reached the maximum
  297. */
  298. public function sleepDelayOrThrowOnMax(string $ip, string $action = ''): int {
  299. $delay = $this->getDelay($ip, $action);
  300. if (($delay === self::MAX_DELAY_MS) && $this->getAttempts($ip, $action, 0.5) > self::MAX_ATTEMPTS) {
  301. $this->logger->info('IP address blocked because it reached the maximum failed attempts in the last 30 minutes [action: {action}, ip: {ip}]', [
  302. 'action' => $action,
  303. 'ip' => $ip,
  304. ]);
  305. // If the ip made too many attempts within the last 30 mins we don't execute anymore
  306. throw new MaxDelayReached('Reached maximum delay');
  307. }
  308. if ($delay > 100) {
  309. $this->logger->info('IP address throttled because it reached the attempts limit in the last 30 minutes [action: {action}, delay: {delay}, ip: {ip}]', [
  310. 'action' => $action,
  311. 'ip' => $ip,
  312. 'delay' => $delay,
  313. ]);
  314. }
  315. usleep($delay * 1000);
  316. return $delay;
  317. }
  318. }