Completed
Push — master ( 9b9ca0...f3dbfd )
by Lukas
13:11
created

Throttler::getIPv6Subnet()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 11
Code Lines 9

Duplication

Lines 11
Ratio 100 %

Importance

Changes 0
Metric Value
cc 2
eloc 9
c 0
b 0
f 0
nc 2
nop 2
dl 11
loc 11
rs 9.4285
1
<?php
2
/**
3
 * @copyright Copyright (c) 2016 Lukas Reschke <[email protected]>
4
 *
5
 * @author Lukas Reschke <[email protected]>
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
24
namespace OC\Security\Bruteforce;
25
26
use OC\Security\Normalizer\IpAddress;
27
use OCP\AppFramework\Utility\ITimeFactory;
28
use OCP\IConfig;
29
use OCP\IDBConnection;
30
use OCP\ILogger;
31
32
/**
33
 * Class Throttler implements the bruteforce protection for security actions in
34
 * Nextcloud.
35
 *
36
 * It is working by logging invalid login attempts to the database and slowing
37
 * down all login attempts from the same subnet. The max delay is 30 seconds and
38
 * the starting delay are 200 milliseconds. (after the first failed login)
39
 *
40
 * This is based on Paragonie's AirBrake for Airship CMS. You can find the original
41
 * code at https://github.com/paragonie/airship/blob/7e5bad7e3c0fbbf324c11f963fd1f80e59762606/src/Engine/Security/AirBrake.php
42
 *
43
 * @package OC\Security\Bruteforce
44
 */
45
class Throttler {
46
	const LOGIN_ACTION = 'login';
47
48
	/** @var IDBConnection */
49
	private $db;
50
	/** @var ITimeFactory */
51
	private $timeFactory;
52
	/** @var ILogger */
53
	private $logger;
54
	/** @var IConfig */
55
	private $config;
56
57
	/**
58
	 * @param IDBConnection $db
59
	 * @param ITimeFactory $timeFactory
60
	 * @param ILogger $logger
61
	 * @param IConfig $config
62
	 */
63
	public function __construct(IDBConnection $db,
64
								ITimeFactory $timeFactory,
65
								ILogger $logger,
66
								IConfig $config) {
67
		$this->db = $db;
68
		$this->timeFactory = $timeFactory;
69
		$this->logger = $logger;
70
		$this->config = $config;
71
	}
72
73
	/**
74
	 * Convert a number of seconds into the appropriate DateInterval
75
	 *
76
	 * @param int $expire
77
	 * @return \DateInterval
78
	 */
79
	private function getCutoff($expire) {
80
		$d1 = new \DateTime();
81
		$d2 = clone $d1;
82
		$d2->sub(new \DateInterval('PT' . $expire . 'S'));
83
		return $d2->diff($d1);
84
	}
85
86
	/**
87
	 * Register a failed attempt to bruteforce a security control
88
	 *
89
	 * @param string $action
90
	 * @param string $ip
91
	 * @param array $metadata Optional metadata logged to the database
92
	 */
93
	public function registerAttempt($action,
94
									$ip,
95
									array $metadata = []) {
96
		// No need to log if the bruteforce protection is disabled
97
		if($this->config->getSystemValue('auth.bruteforce.protection.enabled', true) === false) {
98
			return;
99
		}
100
101
		$ipAddress = new IpAddress($ip);
102
		$values = [
103
			'action' => $action,
104
			'occurred' => $this->timeFactory->getTime(),
105
			'ip' => (string)$ipAddress,
106
			'subnet' => $ipAddress->getSubnet(),
107
			'metadata' => json_encode($metadata),
108
		];
109
110
		$this->logger->notice(
111
			sprintf(
112
				'Bruteforce attempt from "%s" detected for action "%s".',
113
				$ip,
114
				$action
115
			),
116
			[
117
				'app' => 'core',
118
			]
119
		);
120
121
		$qb = $this->db->getQueryBuilder();
122
		$qb->insert('bruteforce_attempts');
123
		foreach($values as $column => $value) {
124
			$qb->setValue($column, $qb->createNamedParameter($value));
125
		}
126
		$qb->execute();
127
	}
128
129
	/**
130
	 * Check if the IP is whitelisted
131
	 *
132
	 * @param string $ip
133
	 * @return bool
134
	 */
135
	private function isIPWhitelisted($ip) {
136
		$keys = $this->config->getAppKeys('bruteForce');
137
		$keys = array_filter($keys, function($key) {
138
			$regex = '/^whitelist_/S';
139
			return preg_match($regex, $key) === 1;
140
		});
141
142
		if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
143
			$type = 4;
144
		} else if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) {
145
			$type = 6;
146
		} else {
147
			return false;
148
		}
149
150
		$ip = inet_pton($ip);
151
152
		foreach ($keys as $key) {
153
			$cidr = $this->config->getAppValue('bruteForce', $key, null);
154
155
			$cx = explode('/', $cidr);
156
			$addr = $cx[0];
157
			$mask = (int)$cx[1];
158
159
			// Do not compare ipv4 to ipv6
160
			if (($type === 4 && !filter_var($addr, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) ||
161
				($type === 6 && !filter_var($addr, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6))) {
162
				continue;
163
			}
164
165
			$addr = inet_pton($addr);
166
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
172
				$part = $part & (15 << (1 - ($i % 2)));
173
				$orig = $orig & (15 << (1 - ($i % 2)));
174
175
				if ($part !== $orig) {
176
					$valid = false;
177
					break;
178
				}
179
			}
180
181
			if ($valid === true) {
182
				return true;
183
			}
184
		}
185
186
		return false;
187
188
	}
189
190
	/**
191
	 * Get the throttling delay (in milliseconds)
192
	 *
193
	 * @param string $ip
194
	 * @param string $action optionally filter by action
195
	 * @return int
196
	 */
197
	public function getDelay($ip, $action = '') {
198
		$ipAddress = new IpAddress($ip);
199
		if ($this->isIPWhitelisted((string)$ipAddress)) {
200
			return 0;
201
		}
202
203
		$cutoffTime = (new \DateTime())
204
			->sub($this->getCutoff(43200))
205
			->getTimestamp();
206
207
		$qb = $this->db->getQueryBuilder();
208
		$qb->select('*')
209
			->from('bruteforce_attempts')
210
			->where($qb->expr()->gt('occurred', $qb->createNamedParameter($cutoffTime)))
211
			->andWhere($qb->expr()->eq('subnet', $qb->createNamedParameter($ipAddress->getSubnet())));
212
213
		if ($action !== '') {
214
			$qb->andWhere($qb->expr()->eq('action', $qb->createNamedParameter($action)));
215
		}
216
217
		$attempts = count($qb->execute()->fetchAll());
218
219
		if ($attempts === 0) {
220
			return 0;
221
		}
222
223
		$maxDelay = 30;
224
		$firstDelay = 0.1;
225
		if ($attempts > (8 * PHP_INT_SIZE - 1))  {
226
			// Don't ever overflow. Just assume the maxDelay time:s
227
			$firstDelay = $maxDelay;
228
		} else {
229
			$firstDelay *= pow(2, $attempts);
230
			if ($firstDelay > $maxDelay) {
231
				$firstDelay = $maxDelay;
232
			}
233
		}
234
		return (int) \ceil($firstDelay * 1000);
235
	}
236
237
	/**
238
	 * Will sleep for the defined amount of time
239
	 *
240
	 * @param string $ip
241
	 * @param string $action optionally filter by action
242
	 * @return int the time spent sleeping
243
	 */
244
	public function sleepDelay($ip, $action = '') {
245
		$delay = $this->getDelay($ip, $action);
246
		usleep($delay * 1000);
247
		return $delay;
248
	}
249
}
250