Throttler.php 7.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286
  1. <?php
  2. declare(strict_types=1);
  3. /**
  4. * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors
  5. * SPDX-License-Identifier: AGPL-3.0-or-later
  6. */
  7. namespace OC\Security\Bruteforce;
  8. use OC\Security\Bruteforce\Backend\IBackend;
  9. use OC\Security\Normalizer\IpAddress;
  10. use OCP\AppFramework\Utility\ITimeFactory;
  11. use OCP\IConfig;
  12. use OCP\Security\Bruteforce\IThrottler;
  13. use OCP\Security\Bruteforce\MaxDelayReached;
  14. use Psr\Log\LoggerInterface;
  15. /**
  16. * Class Throttler implements the bruteforce protection for security actions in
  17. * Nextcloud.
  18. *
  19. * It is working by logging invalid login attempts to the database and slowing
  20. * down all login attempts from the same subnet. The max delay is 30 seconds and
  21. * the starting delay are 200 milliseconds. (after the first failed login)
  22. *
  23. * This is based on Paragonie's AirBrake for Airship CMS. You can find the original
  24. * code at https://github.com/paragonie/airship/blob/7e5bad7e3c0fbbf324c11f963fd1f80e59762606/src/Engine/Security/AirBrake.php
  25. *
  26. * @package OC\Security\Bruteforce
  27. */
  28. class Throttler implements IThrottler {
  29. /** @var bool[] */
  30. private array $hasAttemptsDeleted = [];
  31. /** @var bool[] */
  32. private array $ipIsWhitelisted = [];
  33. public function __construct(
  34. private ITimeFactory $timeFactory,
  35. private LoggerInterface $logger,
  36. private IConfig $config,
  37. private IBackend $backend,
  38. ) {
  39. }
  40. /**
  41. * {@inheritDoc}
  42. */
  43. public function registerAttempt(string $action,
  44. string $ip,
  45. array $metadata = []): void {
  46. // No need to log if the bruteforce protection is disabled
  47. if (!$this->config->getSystemValueBool('auth.bruteforce.protection.enabled', true)) {
  48. return;
  49. }
  50. $ipAddress = new IpAddress($ip);
  51. if ($this->isBypassListed((string)$ipAddress)) {
  52. return;
  53. }
  54. $this->logger->notice(
  55. sprintf(
  56. 'Bruteforce attempt from "%s" detected for action "%s".',
  57. $ip,
  58. $action
  59. ),
  60. [
  61. 'app' => 'core',
  62. ]
  63. );
  64. $this->backend->registerAttempt(
  65. (string)$ipAddress,
  66. $ipAddress->getSubnet(),
  67. $this->timeFactory->getTime(),
  68. $action,
  69. $metadata
  70. );
  71. }
  72. /**
  73. * Check if the IP is whitelisted
  74. */
  75. public function isBypassListed(string $ip): bool {
  76. if (isset($this->ipIsWhitelisted[$ip])) {
  77. return $this->ipIsWhitelisted[$ip];
  78. }
  79. if (!$this->config->getSystemValueBool('auth.bruteforce.protection.enabled', true)) {
  80. $this->ipIsWhitelisted[$ip] = true;
  81. return true;
  82. }
  83. $keys = $this->config->getAppKeys('bruteForce');
  84. $keys = array_filter($keys, function ($key) {
  85. return str_starts_with($key, 'whitelist_');
  86. });
  87. if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
  88. $type = 4;
  89. } elseif (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) {
  90. $type = 6;
  91. } else {
  92. $this->ipIsWhitelisted[$ip] = false;
  93. return false;
  94. }
  95. $ip = inet_pton($ip);
  96. foreach ($keys as $key) {
  97. $cidr = $this->config->getAppValue('bruteForce', $key, null);
  98. $cx = explode('/', $cidr);
  99. $addr = $cx[0];
  100. $mask = (int)$cx[1];
  101. // Do not compare ipv4 to ipv6
  102. if (($type === 4 && !filter_var($addr, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) ||
  103. ($type === 6 && !filter_var($addr, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6))) {
  104. continue;
  105. }
  106. $addr = inet_pton($addr);
  107. $valid = true;
  108. for ($i = 0; $i < $mask; $i++) {
  109. $part = ord($addr[(int)($i / 8)]);
  110. $orig = ord($ip[(int)($i / 8)]);
  111. $bitmask = 1 << (7 - ($i % 8));
  112. $part = $part & $bitmask;
  113. $orig = $orig & $bitmask;
  114. if ($part !== $orig) {
  115. $valid = false;
  116. break;
  117. }
  118. }
  119. if ($valid === true) {
  120. $this->ipIsWhitelisted[$ip] = true;
  121. return true;
  122. }
  123. }
  124. $this->ipIsWhitelisted[$ip] = false;
  125. return false;
  126. }
  127. /**
  128. * {@inheritDoc}
  129. */
  130. public function showBruteforceWarning(string $ip, string $action = ''): bool {
  131. $attempts = $this->getAttempts($ip, $action);
  132. // 4 failed attempts is the last delay below 5 seconds
  133. return $attempts >= 4;
  134. }
  135. /**
  136. * {@inheritDoc}
  137. */
  138. public function getAttempts(string $ip, string $action = '', float $maxAgeHours = 12): int {
  139. if ($maxAgeHours > 48) {
  140. $this->logger->error('Bruteforce has to use less than 48 hours');
  141. $maxAgeHours = 48;
  142. }
  143. if ($ip === '' || isset($this->hasAttemptsDeleted[$action])) {
  144. return 0;
  145. }
  146. $ipAddress = new IpAddress($ip);
  147. if ($this->isBypassListed((string)$ipAddress)) {
  148. return 0;
  149. }
  150. $maxAgeTimestamp = (int)($this->timeFactory->getTime() - 3600 * $maxAgeHours);
  151. return $this->backend->getAttempts(
  152. $ipAddress->getSubnet(),
  153. $maxAgeTimestamp,
  154. $action !== '' ? $action : null,
  155. );
  156. }
  157. /**
  158. * {@inheritDoc}
  159. */
  160. public function getDelay(string $ip, string $action = ''): int {
  161. $attempts = $this->getAttempts($ip, $action);
  162. if ($attempts === 0) {
  163. return 0;
  164. }
  165. $firstDelay = 0.1;
  166. if ($attempts > $this->config->getSystemValueInt('auth.bruteforce.max-attempts', self::MAX_ATTEMPTS)) {
  167. // Don't ever overflow. Just assume the maxDelay time:s
  168. return self::MAX_DELAY_MS;
  169. }
  170. $delay = $firstDelay * 2 ** $attempts;
  171. if ($delay > self::MAX_DELAY) {
  172. return self::MAX_DELAY_MS;
  173. }
  174. return (int)\ceil($delay * 1000);
  175. }
  176. /**
  177. * {@inheritDoc}
  178. */
  179. public function resetDelay(string $ip, string $action, array $metadata): void {
  180. // No need to log if the bruteforce protection is disabled
  181. if (!$this->config->getSystemValueBool('auth.bruteforce.protection.enabled', true)) {
  182. return;
  183. }
  184. $ipAddress = new IpAddress($ip);
  185. if ($this->isBypassListed((string)$ipAddress)) {
  186. return;
  187. }
  188. $this->backend->resetAttempts(
  189. $ipAddress->getSubnet(),
  190. $action,
  191. $metadata,
  192. );
  193. $this->hasAttemptsDeleted[$action] = true;
  194. }
  195. /**
  196. * {@inheritDoc}
  197. */
  198. public function resetDelayForIP(string $ip): void {
  199. // No need to log if the bruteforce protection is disabled
  200. if (!$this->config->getSystemValueBool('auth.bruteforce.protection.enabled', true)) {
  201. return;
  202. }
  203. $ipAddress = new IpAddress($ip);
  204. if ($this->isBypassListed((string)$ipAddress)) {
  205. return;
  206. }
  207. $this->backend->resetAttempts($ipAddress->getSubnet());
  208. }
  209. /**
  210. * {@inheritDoc}
  211. */
  212. public function sleepDelay(string $ip, string $action = ''): int {
  213. $delay = $this->getDelay($ip, $action);
  214. if (!$this->config->getSystemValueBool('auth.bruteforce.protection.testing')) {
  215. usleep($delay * 1000);
  216. }
  217. return $delay;
  218. }
  219. /**
  220. * {@inheritDoc}
  221. */
  222. public function sleepDelayOrThrowOnMax(string $ip, string $action = ''): int {
  223. $delay = $this->getDelay($ip, $action);
  224. if (($delay === self::MAX_DELAY_MS) && $this->getAttempts($ip, $action, 0.5) > $this->config->getSystemValueInt('auth.bruteforce.max-attempts', self::MAX_ATTEMPTS)) {
  225. $this->logger->info('IP address blocked because it reached the maximum failed attempts in the last 30 minutes [action: {action}, ip: {ip}]', [
  226. 'action' => $action,
  227. 'ip' => $ip,
  228. ]);
  229. // If the ip made too many attempts within the last 30 mins we don't execute anymore
  230. throw new MaxDelayReached('Reached maximum delay');
  231. }
  232. if ($delay > 100) {
  233. $this->logger->info('IP address throttled because it reached the attempts limit in the last 30 minutes [action: {action}, delay: {delay}, ip: {ip}]', [
  234. 'action' => $action,
  235. 'ip' => $ip,
  236. 'delay' => $delay,
  237. ]);
  238. }
  239. if (!$this->config->getSystemValueBool('auth.bruteforce.protection.testing')) {
  240. usleep($delay * 1000);
  241. }
  242. return $delay;
  243. }
  244. }