Completed
Pull Request — master (#479)
by Lukas
33:52 queued 25:33
created

Throttler::getIPv6Subnet()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 11
Code Lines 9

Duplication

Lines 11
Ratio 100 %

Importance

Changes 1
Bugs 0 Features 1
Metric Value
cc 2
eloc 9
c 1
b 0
f 1
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
 * @license GNU AGPL version 3 or any later version
6
 *
7
 * This program is free software: you can redistribute it and/or modify
8
 * it under the terms of the GNU Affero General Public License as
9
 * published by the Free Software Foundation, either version 3 of the
10
 * License, or (at your option) any later version.
11
 *
12
 * This program is distributed in the hope that it will be useful,
13
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
14
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
15
 * GNU Affero General Public License for more details.
16
 *
17
 * You should have received a copy of the GNU Affero General Public License
18
 * along with this program.  If not, see <http://www.gnu.org/licenses/>.
19
 *
20
 */
21
22
namespace OC\Security\Bruteforce;
23
24
use OCP\AppFramework\Utility\ITimeFactory;
25
use OCP\IConfig;
26
use OCP\IDBConnection;
27
use OCP\ILogger;
28
29
/**
30
 * Class Throttler implements the bruteforce protection for security actions in
31
 * Nextcloud.
32
 *
33
 * It is working by logging invalid login attempts to the database and slowing
34
 * down all login attempts from the same subnet. The max delay is 30 seconds and
35
 * the starting delay are 200 milliseconds. (after the first failed login)
36
 *
37
 * This is based on Paragonie's AirBrake for Airship CMS. You can find the original
38
 * code at https://github.com/paragonie/airship/blob/7e5bad7e3c0fbbf324c11f963fd1f80e59762606/src/Engine/Security/AirBrake.php
39
 *
40
 * @package OC\Security\Bruteforce
41
 */
42
class Throttler {
43
	const LOGIN_ACTION = 'login';
44
45
	/** @var IDBConnection */
46
	private $db;
47
	/** @var ITimeFactory */
48
	private $timeFactory;
49
	/** @var ILogger */
50
	private $logger;
51
	/** @var IConfig */
52
	private $config;
53
54
	/**
55
	 * @param IDBConnection $db
56
	 * @param ITimeFactory $timeFactory
57
	 * @param ILogger $logger
58
	 * @param IConfig $config
59
	 */
60
	public function __construct(IDBConnection $db,
61
								ITimeFactory $timeFactory,
62
								ILogger $logger,
63
								IConfig $config) {
64
		$this->db = $db;
65
		$this->timeFactory = $timeFactory;
66
		$this->logger = $logger;
67
		$this->config = $config;
68
	}
69
70
	/**
71
	 * Convert a number of seconds into the appropriate DateInterval
72
	 *
73
	 * @param int $expire
74
	 * @return \DateInterval
75
	 */
76
	private function getCutoff($expire) {
77
		$d1 = new \DateTime();
78
		$d2 = clone $d1;
79
		$d2->sub(new \DateInterval('PT' . $expire . 'S'));
80
		return $d2->diff($d1);
81
	}
82
83
	/**
84
	 * Return the given subnet for an IPv4 address and mask bits
85
	 *
86
	 * @param string $ip
87
	 * @param int $maskBits
88
	 * @return string
89
	 */
90 View Code Duplication
	private function getIPv4Subnet($ip,
0 ignored issues
show
Duplication introduced by
This method seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
91
								  $maskBits = 32) {
92
		$binary = \inet_pton($ip);
93
		for ($i = 32; $i > $maskBits; $i -= 8) {
94
			$j = \intdiv($i, 8) - 1;
95
			$k = (int) \min(8, $i - $maskBits);
96
			$mask = (0xff - ((pow(2, $k)) - 1));
97
			$int = \unpack('C', $binary[$j]);
98
			$binary[$j] = \pack('C', $int[1] & $mask);
99
		}
100
		return \inet_ntop($binary).'/'.$maskBits;
101
	}
102
103
	/**
104
	 * Return the given subnet for an IPv6 address and mask bits
105
	 *
106
	 * @param string $ip
107
	 * @param int $maskBits
108
	 * @return string
109
	 */
110 View Code Duplication
	private function getIPv6Subnet($ip, $maskBits = 48) {
0 ignored issues
show
Duplication introduced by
This method seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
111
		$binary = \inet_pton($ip);
112
		for ($i = 128; $i > $maskBits; $i -= 8) {
113
			$j = \intdiv($i, 8) - 1;
114
			$k = (int) \min(8, $i - $maskBits);
115
			$mask = (0xff - ((pow(2, $k)) - 1));
116
			$int = \unpack('C', $binary[$j]);
117
			$binary[$j] = \pack('C', $int[1] & $mask);
118
		}
119
		return \inet_ntop($binary).'/'.$maskBits;
120
	}
121
122
	/**
123
	 * Return the given subnet for an IP and the configured mask bits
124
	 *
125
	 * Determine if the IP is an IPv4 or IPv6 address, then pass to the correct
126
	 * method for handling that specific type.
127
	 *
128
	 * @param string $ip
129
	 * @return string
130
	 */
131
	private function getSubnet($ip) {
132
		if (\preg_match('/^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$/', $ip)) {
133
			return $this->getIPv4Subnet(
134
				$ip,
135
				32
136
			);
137
		}
138
		return $this->getIPv6Subnet(
139
			$ip,
140
			128
141
		);
142
	}
143
144
	/**
145
	 * Register a failed attempt to bruteforce a security control
146
	 *
147
	 * @param string $action
148
	 * @param string $ip
149
	 * @param array $metadata Optional metadata logged to the database
150
	 */
151
	public function registerAttempt($action,
152
									$ip,
153
									array $metadata = []) {
154
		// No need to log if the bruteforce protection is disabled
155
		if($this->config->getSystemValue('auth.bruteforce.protection.enabled', true) === false) {
156
			return;
157
		}
158
159
		$values = [
160
			'action' => $action,
161
			'occurred' => $this->timeFactory->getTime(),
162
			'ip' => $ip,
163
			'subnet' => $this->getSubnet($ip),
164
			'metadata' => json_encode($metadata),
165
		];
166
167
		$this->logger->notice(
168
			sprintf(
169
				'Bruteforce attempt from "%s" detected for action "%s".',
170
				$ip,
171
				$action
172
			),
173
			[
174
				'app' => 'core',
175
			]
176
		);
177
178
		$qb = $this->db->getQueryBuilder();
179
		$qb->insert('bruteforce_attempts');
180
		foreach($values as $column => $value) {
181
			$qb->setValue($column, $qb->createNamedParameter($value));
182
		}
183
		$qb->execute();
184
	}
185
186
	/**
187
	 * Get the throttling delay (in milliseconds)
188
	 *
189
	 * @param string $ip
190
	 * @return int
191
	 */
192
	public function getDelay($ip) {
193
		$cutoffTime = (new \DateTime())
194
			->sub($this->getCutoff(43200))
195
			->getTimestamp();
196
197
		$qb = $this->db->getQueryBuilder();
198
		$qb->select('*')
199
			->from('bruteforce_attempts')
200
			->where($qb->expr()->gt('occurred', $qb->createNamedParameter($cutoffTime)))
201
			->andWhere($qb->expr()->eq('subnet', $qb->createNamedParameter($this->getSubnet($ip))));
202
		$attempts = count($qb->execute()->fetchAll());
203
204
		if ($attempts === 0) {
205
			return 0;
206
		}
207
208
		$maxDelay = 30;
209
		$firstDelay = 0.1;
210
		if ($attempts > (8 * PHP_INT_SIZE - 1))  {
211
			// Don't ever overflow. Just assume the maxDelay time:s
212
			$firstDelay = $maxDelay;
213
		} else {
214
			$firstDelay *= pow(2, $attempts);
215
			if ($firstDelay > $maxDelay) {
216
				$firstDelay = $maxDelay;
217
			}
218
		}
219
		return (int) \ceil($firstDelay * 1000);
220
	}
221
222
	/**
223
	 * Will sleep for the defined amount of time
224
	 *
225
	 * @param string $ip
226
	 */
227
	public function sleepDelay($ip) {
228
		usleep($this->getDelay($ip) * 1000);
229
	}
230
}
231