Passed
Push — master ( cbde1d...e57bca )
by Morris
11:36 queued 11s
created

Throttler::resetDelay()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 16
Code Lines 11

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 2
eloc 11
nc 2
nop 3
dl 0
loc 16
rs 9.9
c 0
b 0
f 0
1
<?php
2
/**
3
 * @copyright Copyright (c) 2016 Lukas Reschke <[email protected]>
4
 *
5
 * @author Bjoern Schiessle <[email protected]>
6
 * @author Christoph Wurst <[email protected]>
7
 * @author Lukas Reschke <[email protected]>
8
 * @author Mark Berezovsky <[email protected]>
9
 * @author Morris Jobke <[email protected]>
10
 * @author Robin Appelman <[email protected]>
11
 * @author Roeland Jago Douma <[email protected]>
12
 *
13
 * @license GNU AGPL version 3 or any later version
14
 *
15
 * This program is free software: you can redistribute it and/or modify
16
 * it under the terms of the GNU Affero General Public License as
17
 * published by the Free Software Foundation, either version 3 of the
18
 * License, or (at your option) any later version.
19
 *
20
 * This program is distributed in the hope that it will be useful,
21
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
22
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
23
 * GNU Affero General Public License for more details.
24
 *
25
 * You should have received a copy of the GNU Affero General Public License
26
 * along with this program. If not, see <http://www.gnu.org/licenses/>.
27
 *
28
 */
29
30
namespace OC\Security\Bruteforce;
31
32
use OC\Security\Normalizer\IpAddress;
33
use OCP\AppFramework\Utility\ITimeFactory;
34
use OCP\IConfig;
35
use OCP\IDBConnection;
36
use OCP\ILogger;
37
38
/**
39
 * Class Throttler implements the bruteforce protection for security actions in
40
 * Nextcloud.
41
 *
42
 * It is working by logging invalid login attempts to the database and slowing
43
 * down all login attempts from the same subnet. The max delay is 30 seconds and
44
 * the starting delay are 200 milliseconds. (after the first failed login)
45
 *
46
 * This is based on Paragonie's AirBrake for Airship CMS. You can find the original
47
 * code at https://github.com/paragonie/airship/blob/7e5bad7e3c0fbbf324c11f963fd1f80e59762606/src/Engine/Security/AirBrake.php
48
 *
49
 * @package OC\Security\Bruteforce
50
 */
51
class Throttler {
52
	public const LOGIN_ACTION = 'login';
53
54
	/** @var IDBConnection */
55
	private $db;
56
	/** @var ITimeFactory */
57
	private $timeFactory;
58
	/** @var ILogger */
59
	private $logger;
60
	/** @var IConfig */
61
	private $config;
62
63
	/**
64
	 * @param IDBConnection $db
65
	 * @param ITimeFactory $timeFactory
66
	 * @param ILogger $logger
67
	 * @param IConfig $config
68
	 */
69
	public function __construct(IDBConnection $db,
70
								ITimeFactory $timeFactory,
71
								ILogger $logger,
72
								IConfig $config) {
73
		$this->db = $db;
74
		$this->timeFactory = $timeFactory;
75
		$this->logger = $logger;
76
		$this->config = $config;
77
	}
78
79
	/**
80
	 * Convert a number of seconds into the appropriate DateInterval
81
	 *
82
	 * @param int $expire
83
	 * @return \DateInterval
84
	 */
85
	private function getCutoff($expire) {
86
		$d1 = new \DateTime();
87
		$d2 = clone $d1;
88
		$d2->sub(new \DateInterval('PT' . $expire . 'S'));
89
		return $d2->diff($d1);
0 ignored issues
show
Bug Best Practice introduced by
The expression return $d2->diff($d1) could also return false which is incompatible with the documented return type DateInterval. Did you maybe forget to handle an error condition?

If the returned type also contains false, it is an indicator that maybe an error condition leading to the specific return statement remains unhandled.

Loading history...
90
	}
91
92
	/**
93
	 *  Calculate the cut off timestamp
94
	 *
95
	 * @return int
96
	 */
97
	private function getCutoffTimestamp(): int {
98
		return (new \DateTime())
99
			->sub($this->getCutoff(43200))
100
			->getTimestamp();
101
	}
102
103
	/**
104
	 * Register a failed attempt to bruteforce a security control
105
	 *
106
	 * @param string $action
107
	 * @param string $ip
108
	 * @param array $metadata Optional metadata logged to the database
109
	 * @suppress SqlInjectionChecker
110
	 */
111
	public function registerAttempt($action,
112
									$ip,
113
									array $metadata = []) {
114
		// No need to log if the bruteforce protection is disabled
115
		if ($this->config->getSystemValue('auth.bruteforce.protection.enabled', true) === false) {
116
			return;
117
		}
118
119
		$ipAddress = new IpAddress($ip);
120
		$values = [
121
			'action' => $action,
122
			'occurred' => $this->timeFactory->getTime(),
123
			'ip' => (string)$ipAddress,
124
			'subnet' => $ipAddress->getSubnet(),
125
			'metadata' => json_encode($metadata),
126
		];
127
128
		$this->logger->notice(
129
			sprintf(
130
				'Bruteforce attempt from "%s" detected for action "%s".',
131
				$ip,
132
				$action
133
			),
134
			[
135
				'app' => 'core',
136
			]
137
		);
138
139
		$qb = $this->db->getQueryBuilder();
140
		$qb->insert('bruteforce_attempts');
141
		foreach ($values as $column => $value) {
142
			$qb->setValue($column, $qb->createNamedParameter($value));
143
		}
144
		$qb->execute();
145
	}
146
147
	/**
148
	 * Check if the IP is whitelisted
149
	 *
150
	 * @param string $ip
151
	 * @return bool
152
	 */
153
	private function isIPWhitelisted($ip) {
154
		if ($this->config->getSystemValue('auth.bruteforce.protection.enabled', true) === false) {
155
			return true;
156
		}
157
158
		$keys = $this->config->getAppKeys('bruteForce');
159
		$keys = array_filter($keys, function ($key) {
160
			$regex = '/^whitelist_/S';
161
			return preg_match($regex, $key) === 1;
162
		});
163
164
		if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
165
			$type = 4;
166
		} elseif (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) {
167
			$type = 6;
168
		} else {
169
			return false;
170
		}
171
172
		$ip = inet_pton($ip);
173
174
		foreach ($keys as $key) {
175
			$cidr = $this->config->getAppValue('bruteForce', $key, null);
176
177
			$cx = explode('/', $cidr);
178
			$addr = $cx[0];
179
			$mask = (int)$cx[1];
180
181
			// Do not compare ipv4 to ipv6
182
			if (($type === 4 && !filter_var($addr, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) ||
183
				($type === 6 && !filter_var($addr, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6))) {
184
				continue;
185
			}
186
187
			$addr = inet_pton($addr);
188
189
			$valid = true;
190
			for ($i = 0; $i < $mask; $i++) {
191
				$part = ord($addr[(int)($i/8)]);
192
				$orig = ord($ip[(int)($i/8)]);
193
194
				$bitmask = 1 << (7 - ($i % 8));
195
196
				$part = $part & $bitmask;
197
				$orig = $orig & $bitmask;
198
199
				if ($part !== $orig) {
200
					$valid = false;
201
					break;
202
				}
203
			}
204
205
			if ($valid === true) {
206
				return true;
207
			}
208
		}
209
210
		return false;
211
	}
212
213
	/**
214
	 * Get the throttling delay (in milliseconds)
215
	 *
216
	 * @param string $ip
217
	 * @param string $action optionally filter by action
218
	 * @return int
219
	 */
220
	public function getDelay($ip, $action = '') {
221
		$ipAddress = new IpAddress($ip);
222
		if ($this->isIPWhitelisted((string)$ipAddress)) {
223
			return 0;
224
		}
225
226
		$cutoffTime = $this->getCutoffTimestamp();
227
228
		$qb = $this->db->getQueryBuilder();
229
		$qb->select('*')
230
			->from('bruteforce_attempts')
231
			->where($qb->expr()->gt('occurred', $qb->createNamedParameter($cutoffTime)))
232
			->andWhere($qb->expr()->eq('subnet', $qb->createNamedParameter($ipAddress->getSubnet())));
233
234
		if ($action !== '') {
235
			$qb->andWhere($qb->expr()->eq('action', $qb->createNamedParameter($action)));
236
		}
237
238
		$attempts = count($qb->execute()->fetchAll());
239
240
		if ($attempts === 0) {
241
			return 0;
242
		}
243
244
		$maxDelay = 25;
245
		$firstDelay = 0.1;
246
		if ($attempts > (8 * PHP_INT_SIZE - 1)) {
247
			// Don't ever overflow. Just assume the maxDelay time:s
248
			$firstDelay = $maxDelay;
249
		} else {
250
			$firstDelay *= pow(2, $attempts);
251
			if ($firstDelay > $maxDelay) {
252
				$firstDelay = $maxDelay;
253
			}
254
		}
255
		return (int) \ceil($firstDelay * 1000);
256
	}
257
258
	/**
259
	 * Reset the throttling delay for an IP address, action and metadata
260
	 *
261
	 * @param string $ip
262
	 * @param string $action
263
	 * @param string $metadata
264
	 */
265
	public function resetDelay($ip, $action, $metadata) {
266
		$ipAddress = new IpAddress($ip);
267
		if ($this->isIPWhitelisted((string)$ipAddress)) {
268
			return;
269
		}
270
271
		$cutoffTime = $this->getCutoffTimestamp();
272
273
		$qb = $this->db->getQueryBuilder();
274
		$qb->delete('bruteforce_attempts')
275
			->where($qb->expr()->gt('occurred', $qb->createNamedParameter($cutoffTime)))
276
			->andWhere($qb->expr()->eq('subnet', $qb->createNamedParameter($ipAddress->getSubnet())))
277
			->andWhere($qb->expr()->eq('action', $qb->createNamedParameter($action)))
278
			->andWhere($qb->expr()->eq('metadata', $qb->createNamedParameter(json_encode($metadata))));
279
280
		$qb->execute();
281
	}
282
283
	/**
284
	 * Reset the throttling delay for an IP address
285
	 *
286
	 * @param string $ip
287
	 */
288
	public function resetDelayForIP($ip) {
289
		$cutoffTime = $this->getCutoffTimestamp();
290
291
		$qb = $this->db->getQueryBuilder();
292
		$qb->delete('bruteforce_attempts')
293
			->where($qb->expr()->gt('occurred', $qb->createNamedParameter($cutoffTime)))
294
			->andWhere($qb->expr()->eq('ip', $qb->createNamedParameter($ip)));
295
296
		$qb->execute();
297
	}
298
299
	/**
300
	 * Will sleep for the defined amount of time
301
	 *
302
	 * @param string $ip
303
	 * @param string $action optionally filter by action
304
	 * @return int the time spent sleeping
305
	 */
306
	public function sleepDelay($ip, $action = '') {
307
		$delay = $this->getDelay($ip, $action);
308
		usleep($delay * 1000);
309
		return $delay;
310
	}
311
}
312