You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

307 lines
7.6 KiB

10 years ago
  1. <?php
  2. /**
  3. * @copyright Copyright (c) 2016 Lukas Reschke <lukas@statuscode.ch>
  4. *
  5. * @author Lukas Reschke <lukas@statuscode.ch>
  6. *
  7. * @license GNU AGPL version 3 or any later version
  8. *
  9. * This program is free software: you can redistribute it and/or modify
  10. * it under the terms of the GNU Affero General Public License as
  11. * published by the Free Software Foundation, either version 3 of the
  12. * License, or (at your option) any later version.
  13. *
  14. * This program is distributed in the hope that it will be useful,
  15. * but WITHOUT ANY WARRANTY; without even the implied warranty of
  16. * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  17. * GNU Affero General Public License for more details.
  18. *
  19. * You should have received a copy of the GNU Affero General Public License
  20. * along with this program. If not, see <http://www.gnu.org/licenses/>.
  21. *
  22. */
  23. namespace OC\Security\Bruteforce;
  24. use OCP\AppFramework\Utility\ITimeFactory;
  25. use OCP\IConfig;
  26. use OCP\IDBConnection;
  27. use OCP\ILogger;
  28. /**
  29. * Class Throttler implements the bruteforce protection for security actions in
  30. * Nextcloud.
  31. *
  32. * It is working by logging invalid login attempts to the database and slowing
  33. * down all login attempts from the same subnet. The max delay is 30 seconds and
  34. * the starting delay are 200 milliseconds. (after the first failed login)
  35. *
  36. * This is based on Paragonie's AirBrake for Airship CMS. You can find the original
  37. * code at https://github.com/paragonie/airship/blob/7e5bad7e3c0fbbf324c11f963fd1f80e59762606/src/Engine/Security/AirBrake.php
  38. *
  39. * @package OC\Security\Bruteforce
  40. */
  41. class Throttler {
  42. const LOGIN_ACTION = 'login';
  43. /** @var IDBConnection */
  44. private $db;
  45. /** @var ITimeFactory */
  46. private $timeFactory;
  47. /** @var ILogger */
  48. private $logger;
  49. /** @var IConfig */
  50. private $config;
  51. /**
  52. * @param IDBConnection $db
  53. * @param ITimeFactory $timeFactory
  54. * @param ILogger $logger
  55. * @param IConfig $config
  56. */
  57. public function __construct(IDBConnection $db,
  58. ITimeFactory $timeFactory,
  59. ILogger $logger,
  60. IConfig $config) {
  61. $this->db = $db;
  62. $this->timeFactory = $timeFactory;
  63. $this->logger = $logger;
  64. $this->config = $config;
  65. }
  66. /**
  67. * Convert a number of seconds into the appropriate DateInterval
  68. *
  69. * @param int $expire
  70. * @return \DateInterval
  71. */
  72. private function getCutoff($expire) {
  73. $d1 = new \DateTime();
  74. $d2 = clone $d1;
  75. $d2->sub(new \DateInterval('PT' . $expire . 'S'));
  76. return $d2->diff($d1);
  77. }
  78. /**
  79. * Return the given subnet for an IPv4 address and mask bits
  80. *
  81. * @param string $ip
  82. * @param int $maskBits
  83. * @return string
  84. */
  85. private function getIPv4Subnet($ip,
  86. $maskBits = 32) {
  87. $binary = \inet_pton($ip);
  88. for ($i = 32; $i > $maskBits; $i -= 8) {
  89. $j = \intdiv($i, 8) - 1;
  90. $k = (int) \min(8, $i - $maskBits);
  91. $mask = (0xff - ((pow(2, $k)) - 1));
  92. $int = \unpack('C', $binary[$j]);
  93. $binary[$j] = \pack('C', $int[1] & $mask);
  94. }
  95. return \inet_ntop($binary).'/'.$maskBits;
  96. }
  97. /**
  98. * Return the given subnet for an IPv6 address and mask bits
  99. *
  100. * @param string $ip
  101. * @param int $maskBits
  102. * @return string
  103. */
  104. private function getIPv6Subnet($ip, $maskBits = 48) {
  105. $binary = \inet_pton($ip);
  106. for ($i = 128; $i > $maskBits; $i -= 8) {
  107. $j = \intdiv($i, 8) - 1;
  108. $k = (int) \min(8, $i - $maskBits);
  109. $mask = (0xff - ((pow(2, $k)) - 1));
  110. $int = \unpack('C', $binary[$j]);
  111. $binary[$j] = \pack('C', $int[1] & $mask);
  112. }
  113. return \inet_ntop($binary).'/'.$maskBits;
  114. }
  115. /**
  116. * Return the given subnet for an IP and the configured mask bits
  117. *
  118. * Determine if the IP is an IPv4 or IPv6 address, then pass to the correct
  119. * method for handling that specific type.
  120. *
  121. * @param string $ip
  122. * @return string
  123. */
  124. private function getSubnet($ip) {
  125. if (\preg_match('/^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$/', $ip)) {
  126. return $this->getIPv4Subnet(
  127. $ip,
  128. 32
  129. );
  130. }
  131. return $this->getIPv6Subnet(
  132. $ip,
  133. 128
  134. );
  135. }
  136. /**
  137. * Register a failed attempt to bruteforce a security control
  138. *
  139. * @param string $action
  140. * @param string $ip
  141. * @param array $metadata Optional metadata logged to the database
  142. */
  143. public function registerAttempt($action,
  144. $ip,
  145. array $metadata = []) {
  146. // No need to log if the bruteforce protection is disabled
  147. if($this->config->getSystemValue('auth.bruteforce.protection.enabled', true) === false) {
  148. return;
  149. }
  150. $values = [
  151. 'action' => $action,
  152. 'occurred' => $this->timeFactory->getTime(),
  153. 'ip' => $ip,
  154. 'subnet' => $this->getSubnet($ip),
  155. 'metadata' => json_encode($metadata),
  156. ];
  157. $this->logger->notice(
  158. sprintf(
  159. 'Bruteforce attempt from "%s" detected for action "%s".',
  160. $ip,
  161. $action
  162. ),
  163. [
  164. 'app' => 'core',
  165. ]
  166. );
  167. $qb = $this->db->getQueryBuilder();
  168. $qb->insert('bruteforce_attempts');
  169. foreach($values as $column => $value) {
  170. $qb->setValue($column, $qb->createNamedParameter($value));
  171. }
  172. $qb->execute();
  173. }
  174. /**
  175. * Check if the IP is whitelisted
  176. *
  177. * @param string $ip
  178. * @return bool
  179. */
  180. private function isIPWhitelisted($ip) {
  181. $keys = $this->config->getAppKeys('bruteForce');
  182. $keys = array_filter($keys, function($key) {
  183. $regex = '/^whitelist_/S';
  184. return preg_match($regex, $key) === 1;
  185. });
  186. if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
  187. $type = 4;
  188. } else if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) {
  189. $type = 6;
  190. } else {
  191. return false;
  192. }
  193. $ip = inet_pton($ip);
  194. foreach ($keys as $key) {
  195. $cidr = $this->config->getAppValue('bruteForce', $key, null);
  196. $cx = explode('/', $cidr);
  197. $addr = $cx[0];
  198. $mask = (int)$cx[1];
  199. // Do not compare ipv4 to ipv6
  200. if (($type === 4 && !filter_var($addr, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) ||
  201. ($type === 6 && !filter_var($addr, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6))) {
  202. continue;
  203. }
  204. $addr = inet_pton($addr);
  205. $valid = true;
  206. for($i = 0; $i < $mask; $i++) {
  207. $part = ord($addr[(int)($i/8)]);
  208. $orig = ord($ip[(int)($i/8)]);
  209. $part = $part & (15 << (1 - ($i % 2)));
  210. $orig = $orig & (15 << (1 - ($i % 2)));
  211. if ($part !== $orig) {
  212. $valid = false;
  213. break;
  214. }
  215. }
  216. if ($valid === true) {
  217. return true;
  218. }
  219. }
  220. return false;
  221. }
  222. /**
  223. * Get the throttling delay (in milliseconds)
  224. *
  225. * @param string $ip
  226. * @param string $action optionally filter by action
  227. * @return int
  228. */
  229. public function getDelay($ip, $action = '') {
  230. if ($this->isIPWhitelisted($ip)) {
  231. return 0;
  232. }
  233. $cutoffTime = (new \DateTime())
  234. ->sub($this->getCutoff(43200))
  235. ->getTimestamp();
  236. $qb = $this->db->getQueryBuilder();
  237. $qb->select('*')
  238. ->from('bruteforce_attempts')
  239. ->where($qb->expr()->gt('occurred', $qb->createNamedParameter($cutoffTime)))
  240. ->andWhere($qb->expr()->eq('subnet', $qb->createNamedParameter($this->getSubnet($ip))));
  241. if ($action !== '') {
  242. $qb->andWhere($qb->expr()->eq('action', $qb->createNamedParameter($action)));
  243. }
  244. $attempts = count($qb->execute()->fetchAll());
  245. if ($attempts === 0) {
  246. return 0;
  247. }
  248. $maxDelay = 30;
  249. $firstDelay = 0.1;
  250. if ($attempts > (8 * PHP_INT_SIZE - 1)) {
  251. // Don't ever overflow. Just assume the maxDelay time:s
  252. $firstDelay = $maxDelay;
  253. } else {
  254. $firstDelay *= pow(2, $attempts);
  255. if ($firstDelay > $maxDelay) {
  256. $firstDelay = $maxDelay;
  257. }
  258. }
  259. return (int) \ceil($firstDelay * 1000);
  260. }
  261. /**
  262. * Will sleep for the defined amount of time
  263. *
  264. * @param string $ip
  265. * @param string $action optionally filter by action
  266. * @return int the time spent sleeping
  267. */
  268. public function sleepDelay($ip, $action = '') {
  269. $delay = $this->getDelay($ip, $action);
  270. usleep($delay * 1000);
  271. return $delay;
  272. }
  273. }