Throttler.php 8.2 KB

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