Throttler.php 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314
  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. * @param string $ip
  101. * @return bool
  102. */
  103. public function isBypassListed(string $ip): bool {
  104. if (isset($this->ipIsWhitelisted[$ip])) {
  105. return $this->ipIsWhitelisted[$ip];
  106. }
  107. if (!$this->config->getSystemValueBool('auth.bruteforce.protection.enabled', true)) {
  108. $this->ipIsWhitelisted[$ip] = true;
  109. return true;
  110. }
  111. $keys = $this->config->getAppKeys('bruteForce');
  112. $keys = array_filter($keys, function ($key) {
  113. return 0 === strpos($key, 'whitelist_');
  114. });
  115. if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
  116. $type = 4;
  117. } elseif (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) {
  118. $type = 6;
  119. } else {
  120. $this->ipIsWhitelisted[$ip] = false;
  121. return false;
  122. }
  123. $ip = inet_pton($ip);
  124. foreach ($keys as $key) {
  125. $cidr = $this->config->getAppValue('bruteForce', $key, null);
  126. $cx = explode('/', $cidr);
  127. $addr = $cx[0];
  128. $mask = (int)$cx[1];
  129. // Do not compare ipv4 to ipv6
  130. if (($type === 4 && !filter_var($addr, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) ||
  131. ($type === 6 && !filter_var($addr, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6))) {
  132. continue;
  133. }
  134. $addr = inet_pton($addr);
  135. $valid = true;
  136. for ($i = 0; $i < $mask; $i++) {
  137. $part = ord($addr[(int)($i / 8)]);
  138. $orig = ord($ip[(int)($i / 8)]);
  139. $bitmask = 1 << (7 - ($i % 8));
  140. $part = $part & $bitmask;
  141. $orig = $orig & $bitmask;
  142. if ($part !== $orig) {
  143. $valid = false;
  144. break;
  145. }
  146. }
  147. if ($valid === true) {
  148. $this->ipIsWhitelisted[$ip] = true;
  149. return true;
  150. }
  151. }
  152. $this->ipIsWhitelisted[$ip] = false;
  153. return false;
  154. }
  155. /**
  156. * {@inheritDoc}
  157. */
  158. public function showBruteforceWarning(string $ip, string $action = ''): bool {
  159. $attempts = $this->getAttempts($ip, $action);
  160. // 4 failed attempts is the last delay below 5 seconds
  161. return $attempts >= 4;
  162. }
  163. /**
  164. * {@inheritDoc}
  165. */
  166. public function getAttempts(string $ip, string $action = '', float $maxAgeHours = 12): int {
  167. if ($maxAgeHours > 48) {
  168. $this->logger->error('Bruteforce has to use less than 48 hours');
  169. $maxAgeHours = 48;
  170. }
  171. if ($ip === '' || isset($this->hasAttemptsDeleted[$action])) {
  172. return 0;
  173. }
  174. $ipAddress = new IpAddress($ip);
  175. if ($this->isBypassListed((string)$ipAddress)) {
  176. return 0;
  177. }
  178. $maxAgeTimestamp = (int) ($this->timeFactory->getTime() - 3600 * $maxAgeHours);
  179. return $this->backend->getAttempts(
  180. $ipAddress->getSubnet(),
  181. $maxAgeTimestamp,
  182. $action !== '' ? $action : null,
  183. );
  184. }
  185. /**
  186. * {@inheritDoc}
  187. */
  188. public function getDelay(string $ip, string $action = ''): int {
  189. $attempts = $this->getAttempts($ip, $action);
  190. if ($attempts === 0) {
  191. return 0;
  192. }
  193. $firstDelay = 0.1;
  194. if ($attempts > self::MAX_ATTEMPTS) {
  195. // Don't ever overflow. Just assume the maxDelay time:s
  196. return self::MAX_DELAY_MS;
  197. }
  198. $delay = $firstDelay * 2 ** $attempts;
  199. if ($delay > self::MAX_DELAY) {
  200. return self::MAX_DELAY_MS;
  201. }
  202. return (int) \ceil($delay * 1000);
  203. }
  204. /**
  205. * {@inheritDoc}
  206. */
  207. public function resetDelay(string $ip, string $action, array $metadata): void {
  208. // No need to log if the bruteforce protection is disabled
  209. if (!$this->config->getSystemValueBool('auth.bruteforce.protection.enabled', true)) {
  210. return;
  211. }
  212. $ipAddress = new IpAddress($ip);
  213. if ($this->isBypassListed((string)$ipAddress)) {
  214. return;
  215. }
  216. $this->backend->resetAttempts(
  217. $ipAddress->getSubnet(),
  218. $action,
  219. $metadata,
  220. );
  221. $this->hasAttemptsDeleted[$action] = true;
  222. }
  223. /**
  224. * {@inheritDoc}
  225. */
  226. public function resetDelayForIP(string $ip): void {
  227. // No need to log if the bruteforce protection is disabled
  228. if (!$this->config->getSystemValueBool('auth.bruteforce.protection.enabled', true)) {
  229. return;
  230. }
  231. $ipAddress = new IpAddress($ip);
  232. if ($this->isBypassListed((string)$ipAddress)) {
  233. return;
  234. }
  235. $this->backend->resetAttempts($ipAddress->getSubnet());
  236. }
  237. /**
  238. * {@inheritDoc}
  239. */
  240. public function sleepDelay(string $ip, string $action = ''): int {
  241. $delay = $this->getDelay($ip, $action);
  242. if (!$this->config->getSystemValueBool('auth.bruteforce.protection.testing')) {
  243. usleep($delay * 1000);
  244. }
  245. return $delay;
  246. }
  247. /**
  248. * {@inheritDoc}
  249. */
  250. public function sleepDelayOrThrowOnMax(string $ip, string $action = ''): int {
  251. $delay = $this->getDelay($ip, $action);
  252. if (($delay === self::MAX_DELAY_MS) && $this->getAttempts($ip, $action, 0.5) > self::MAX_ATTEMPTS) {
  253. $this->logger->info('IP address blocked because it reached the maximum failed attempts in the last 30 minutes [action: {action}, ip: {ip}]', [
  254. 'action' => $action,
  255. 'ip' => $ip,
  256. ]);
  257. // If the ip made too many attempts within the last 30 mins we don't execute anymore
  258. throw new MaxDelayReached('Reached maximum delay');
  259. }
  260. if ($delay > 100) {
  261. $this->logger->info('IP address throttled because it reached the attempts limit in the last 30 minutes [action: {action}, delay: {delay}, ip: {ip}]', [
  262. 'action' => $action,
  263. 'ip' => $ip,
  264. 'delay' => $delay,
  265. ]);
  266. }
  267. if (!$this->config->getSystemValueBool('auth.bruteforce.protection.testing')) {
  268. usleep($delay * 1000);
  269. }
  270. return $delay;
  271. }
  272. }