Completed
Push — master ( 44adca...ee4262 )
by Lukas
15:07
created

Throttler   A

Complexity

Total Complexity 26

Size/Duplication

Total Lines 237
Duplicated Lines 0 %

Coupling/Cohesion

Components 1
Dependencies 7

Importance

Changes 0
Metric Value
dl 0
loc 237
rs 10
c 0
b 0
f 0
wmc 26
lcom 1
cbo 7

7 Methods

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