Passed
Push — master ( 58798b...677b25 )
by Joas
12:03 queued 12s
created
lib/private/Security/Bruteforce/Throttler.php 1 patch
Indentation   +318 added lines, -318 removed lines patch added patch discarded remove patch
@@ -53,322 +53,322 @@
 block discarded – undo
53 53
  * @package OC\Security\Bruteforce
54 54
  */
55 55
 class Throttler {
56
-	public const LOGIN_ACTION = 'login';
57
-	public const MAX_DELAY = 25;
58
-	public const MAX_DELAY_MS = 25000; // in milliseconds
59
-	public const MAX_ATTEMPTS = 10;
60
-
61
-	/** @var IDBConnection */
62
-	private $db;
63
-	/** @var ITimeFactory */
64
-	private $timeFactory;
65
-	/** @var ILogger */
66
-	private $logger;
67
-	/** @var IConfig */
68
-	private $config;
69
-	/** @var bool */
70
-	private $hasAttemptsDeleted = false;
71
-
72
-	/**
73
-	 * @param IDBConnection $db
74
-	 * @param ITimeFactory $timeFactory
75
-	 * @param ILogger $logger
76
-	 * @param IConfig $config
77
-	 */
78
-	public function __construct(IDBConnection $db,
79
-								ITimeFactory $timeFactory,
80
-								ILogger $logger,
81
-								IConfig $config) {
82
-		$this->db = $db;
83
-		$this->timeFactory = $timeFactory;
84
-		$this->logger = $logger;
85
-		$this->config = $config;
86
-	}
87
-
88
-	/**
89
-	 * Convert a number of seconds into the appropriate DateInterval
90
-	 *
91
-	 * @param int $expire
92
-	 * @return \DateInterval
93
-	 */
94
-	private function getCutoff(int $expire): \DateInterval {
95
-		$d1 = new \DateTime();
96
-		$d2 = clone $d1;
97
-		$d2->sub(new \DateInterval('PT' . $expire . 'S'));
98
-		return $d2->diff($d1);
99
-	}
100
-
101
-	/**
102
-	 *  Calculate the cut off timestamp
103
-	 *
104
-	 * @param float $maxAgeHours
105
-	 * @return int
106
-	 */
107
-	private function getCutoffTimestamp(float $maxAgeHours = 12.0): int {
108
-		return (new \DateTime())
109
-			->sub($this->getCutoff((int) ($maxAgeHours * 3600)))
110
-			->getTimestamp();
111
-	}
112
-
113
-	/**
114
-	 * Register a failed attempt to bruteforce a security control
115
-	 *
116
-	 * @param string $action
117
-	 * @param string $ip
118
-	 * @param array $metadata Optional metadata logged to the database
119
-	 */
120
-	public function registerAttempt(string $action,
121
-									string $ip,
122
-									array $metadata = []): void {
123
-		// No need to log if the bruteforce protection is disabled
124
-		if ($this->config->getSystemValue('auth.bruteforce.protection.enabled', true) === false) {
125
-			return;
126
-		}
127
-
128
-		$ipAddress = new IpAddress($ip);
129
-		$values = [
130
-			'action' => $action,
131
-			'occurred' => $this->timeFactory->getTime(),
132
-			'ip' => (string)$ipAddress,
133
-			'subnet' => $ipAddress->getSubnet(),
134
-			'metadata' => json_encode($metadata),
135
-		];
136
-
137
-		$this->logger->notice(
138
-			sprintf(
139
-				'Bruteforce attempt from "%s" detected for action "%s".',
140
-				$ip,
141
-				$action
142
-			),
143
-			[
144
-				'app' => 'core',
145
-			]
146
-		);
147
-
148
-		$qb = $this->db->getQueryBuilder();
149
-		$qb->insert('bruteforce_attempts');
150
-		foreach ($values as $column => $value) {
151
-			$qb->setValue($column, $qb->createNamedParameter($value));
152
-		}
153
-		$qb->execute();
154
-	}
155
-
156
-	/**
157
-	 * Check if the IP is whitelisted
158
-	 *
159
-	 * @param string $ip
160
-	 * @return bool
161
-	 */
162
-	private function isIPWhitelisted(string $ip): bool {
163
-		if ($this->config->getSystemValue('auth.bruteforce.protection.enabled', true) === false) {
164
-			return true;
165
-		}
166
-
167
-		$keys = $this->config->getAppKeys('bruteForce');
168
-		$keys = array_filter($keys, function ($key) {
169
-			return 0 === strpos($key, 'whitelist_');
170
-		});
171
-
172
-		if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
173
-			$type = 4;
174
-		} elseif (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) {
175
-			$type = 6;
176
-		} else {
177
-			return false;
178
-		}
179
-
180
-		$ip = inet_pton($ip);
181
-
182
-		foreach ($keys as $key) {
183
-			$cidr = $this->config->getAppValue('bruteForce', $key, null);
184
-
185
-			$cx = explode('/', $cidr);
186
-			$addr = $cx[0];
187
-			$mask = (int)$cx[1];
188
-
189
-			// Do not compare ipv4 to ipv6
190
-			if (($type === 4 && !filter_var($addr, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) ||
191
-				($type === 6 && !filter_var($addr, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6))) {
192
-				continue;
193
-			}
194
-
195
-			$addr = inet_pton($addr);
196
-
197
-			$valid = true;
198
-			for ($i = 0; $i < $mask; $i++) {
199
-				$part = ord($addr[(int)($i / 8)]);
200
-				$orig = ord($ip[(int)($i / 8)]);
201
-
202
-				$bitmask = 1 << (7 - ($i % 8));
203
-
204
-				$part = $part & $bitmask;
205
-				$orig = $orig & $bitmask;
206
-
207
-				if ($part !== $orig) {
208
-					$valid = false;
209
-					break;
210
-				}
211
-			}
212
-
213
-			if ($valid === true) {
214
-				return true;
215
-			}
216
-		}
217
-
218
-		return false;
219
-	}
220
-
221
-	/**
222
-	 * Get the throttling delay (in milliseconds)
223
-	 *
224
-	 * @param string $ip
225
-	 * @param string $action optionally filter by action
226
-	 * @param float $maxAgeHours
227
-	 * @return int
228
-	 */
229
-	public function getAttempts(string $ip, string $action = '', float $maxAgeHours = 12): int {
230
-		if ($maxAgeHours > 48) {
231
-			$this->logger->error('Bruteforce has to use less than 48 hours');
232
-			$maxAgeHours = 48;
233
-		}
234
-
235
-		if ($ip === '' || $this->hasAttemptsDeleted) {
236
-			return 0;
237
-		}
238
-
239
-		$ipAddress = new IpAddress($ip);
240
-		if ($this->isIPWhitelisted((string)$ipAddress)) {
241
-			return 0;
242
-		}
243
-
244
-		$cutoffTime = $this->getCutoffTimestamp($maxAgeHours);
245
-
246
-		$qb = $this->db->getQueryBuilder();
247
-		$qb->select($qb->func()->count('*', 'attempts'))
248
-			->from('bruteforce_attempts')
249
-			->where($qb->expr()->gt('occurred', $qb->createNamedParameter($cutoffTime)))
250
-			->andWhere($qb->expr()->eq('subnet', $qb->createNamedParameter($ipAddress->getSubnet())));
251
-
252
-		if ($action !== '') {
253
-			$qb->andWhere($qb->expr()->eq('action', $qb->createNamedParameter($action)));
254
-		}
255
-
256
-		$result = $qb->execute();
257
-		$row = $result->fetch();
258
-		$result->closeCursor();
259
-
260
-		return (int) $row['attempts'];
261
-	}
262
-
263
-	/**
264
-	 * Get the throttling delay (in milliseconds)
265
-	 *
266
-	 * @param string $ip
267
-	 * @param string $action optionally filter by action
268
-	 * @return int
269
-	 */
270
-	public function getDelay(string $ip, string $action = ''): int {
271
-		$attempts = $this->getAttempts($ip, $action);
272
-		if ($attempts === 0) {
273
-			return 0;
274
-		}
275
-
276
-		$firstDelay = 0.1;
277
-		if ($attempts > self::MAX_ATTEMPTS) {
278
-			// Don't ever overflow. Just assume the maxDelay time:s
279
-			return self::MAX_DELAY_MS;
280
-		}
281
-
282
-		$delay = $firstDelay * 2 ** $attempts;
283
-		if ($delay > self::MAX_DELAY) {
284
-			return self::MAX_DELAY_MS;
285
-		}
286
-		return (int) \ceil($delay * 1000);
287
-	}
288
-
289
-	/**
290
-	 * Reset the throttling delay for an IP address, action and metadata
291
-	 *
292
-	 * @param string $ip
293
-	 * @param string $action
294
-	 * @param array $metadata
295
-	 */
296
-	public function resetDelay(string $ip, string $action, array $metadata): void {
297
-		$ipAddress = new IpAddress($ip);
298
-		if ($this->isIPWhitelisted((string)$ipAddress)) {
299
-			return;
300
-		}
301
-
302
-		$cutoffTime = $this->getCutoffTimestamp();
303
-
304
-		$qb = $this->db->getQueryBuilder();
305
-		$qb->delete('bruteforce_attempts')
306
-			->where($qb->expr()->gt('occurred', $qb->createNamedParameter($cutoffTime)))
307
-			->andWhere($qb->expr()->eq('subnet', $qb->createNamedParameter($ipAddress->getSubnet())))
308
-			->andWhere($qb->expr()->eq('action', $qb->createNamedParameter($action)))
309
-			->andWhere($qb->expr()->eq('metadata', $qb->createNamedParameter(json_encode($metadata))));
310
-
311
-		$qb->executeStatement();
312
-
313
-		$this->hasAttemptsDeleted = true;
314
-	}
315
-
316
-	/**
317
-	 * Reset the throttling delay for an IP address
318
-	 *
319
-	 * @param string $ip
320
-	 */
321
-	public function resetDelayForIP($ip) {
322
-		$cutoffTime = $this->getCutoffTimestamp();
323
-
324
-		$qb = $this->db->getQueryBuilder();
325
-		$qb->delete('bruteforce_attempts')
326
-			->where($qb->expr()->gt('occurred', $qb->createNamedParameter($cutoffTime)))
327
-			->andWhere($qb->expr()->eq('ip', $qb->createNamedParameter($ip)));
328
-
329
-		$qb->execute();
330
-	}
331
-
332
-	/**
333
-	 * Will sleep for the defined amount of time
334
-	 *
335
-	 * @param string $ip
336
-	 * @param string $action optionally filter by action
337
-	 * @return int the time spent sleeping
338
-	 */
339
-	public function sleepDelay(string $ip, string $action = ''): int {
340
-		$delay = $this->getDelay($ip, $action);
341
-		usleep($delay * 1000);
342
-		return $delay;
343
-	}
344
-
345
-	/**
346
-	 * Will sleep for the defined amount of time unless maximum was reached in the last 30 minutes
347
-	 * In this case a "429 Too Many Request" exception is thrown
348
-	 *
349
-	 * @param string $ip
350
-	 * @param string $action optionally filter by action
351
-	 * @return int the time spent sleeping
352
-	 * @throws MaxDelayReached when reached the maximum
353
-	 */
354
-	public function sleepDelayOrThrowOnMax(string $ip, string $action = ''): int {
355
-		$delay = $this->getDelay($ip, $action);
356
-		if (($delay === self::MAX_DELAY_MS) && $this->getAttempts($ip, $action, 0.5) > self::MAX_ATTEMPTS) {
357
-			$this->logger->info('IP address blocked because it reached the maximum failed attempts in the last 30 minutes [action: {action}, ip: {ip}]', [
358
-				'action' => $action,
359
-				'ip' => $ip,
360
-			]);
361
-			// If the ip made too many attempts within the last 30 mins we don't execute anymore
362
-			throw new MaxDelayReached('Reached maximum delay');
363
-		}
364
-		if ($delay > 100) {
365
-			$this->logger->info('IP address throttled because it reached the attempts limit in the last 30 minutes [action: {action}, delay: {delay}, ip: {ip}]', [
366
-				'action' => $action,
367
-				'ip' => $ip,
368
-				'delay' => $delay,
369
-			]);
370
-		}
371
-		usleep($delay * 1000);
372
-		return $delay;
373
-	}
56
+    public const LOGIN_ACTION = 'login';
57
+    public const MAX_DELAY = 25;
58
+    public const MAX_DELAY_MS = 25000; // in milliseconds
59
+    public const MAX_ATTEMPTS = 10;
60
+
61
+    /** @var IDBConnection */
62
+    private $db;
63
+    /** @var ITimeFactory */
64
+    private $timeFactory;
65
+    /** @var ILogger */
66
+    private $logger;
67
+    /** @var IConfig */
68
+    private $config;
69
+    /** @var bool */
70
+    private $hasAttemptsDeleted = false;
71
+
72
+    /**
73
+     * @param IDBConnection $db
74
+     * @param ITimeFactory $timeFactory
75
+     * @param ILogger $logger
76
+     * @param IConfig $config
77
+     */
78
+    public function __construct(IDBConnection $db,
79
+                                ITimeFactory $timeFactory,
80
+                                ILogger $logger,
81
+                                IConfig $config) {
82
+        $this->db = $db;
83
+        $this->timeFactory = $timeFactory;
84
+        $this->logger = $logger;
85
+        $this->config = $config;
86
+    }
87
+
88
+    /**
89
+     * Convert a number of seconds into the appropriate DateInterval
90
+     *
91
+     * @param int $expire
92
+     * @return \DateInterval
93
+     */
94
+    private function getCutoff(int $expire): \DateInterval {
95
+        $d1 = new \DateTime();
96
+        $d2 = clone $d1;
97
+        $d2->sub(new \DateInterval('PT' . $expire . 'S'));
98
+        return $d2->diff($d1);
99
+    }
100
+
101
+    /**
102
+     *  Calculate the cut off timestamp
103
+     *
104
+     * @param float $maxAgeHours
105
+     * @return int
106
+     */
107
+    private function getCutoffTimestamp(float $maxAgeHours = 12.0): int {
108
+        return (new \DateTime())
109
+            ->sub($this->getCutoff((int) ($maxAgeHours * 3600)))
110
+            ->getTimestamp();
111
+    }
112
+
113
+    /**
114
+     * Register a failed attempt to bruteforce a security control
115
+     *
116
+     * @param string $action
117
+     * @param string $ip
118
+     * @param array $metadata Optional metadata logged to the database
119
+     */
120
+    public function registerAttempt(string $action,
121
+                                    string $ip,
122
+                                    array $metadata = []): void {
123
+        // No need to log if the bruteforce protection is disabled
124
+        if ($this->config->getSystemValue('auth.bruteforce.protection.enabled', true) === false) {
125
+            return;
126
+        }
127
+
128
+        $ipAddress = new IpAddress($ip);
129
+        $values = [
130
+            'action' => $action,
131
+            'occurred' => $this->timeFactory->getTime(),
132
+            'ip' => (string)$ipAddress,
133
+            'subnet' => $ipAddress->getSubnet(),
134
+            'metadata' => json_encode($metadata),
135
+        ];
136
+
137
+        $this->logger->notice(
138
+            sprintf(
139
+                'Bruteforce attempt from "%s" detected for action "%s".',
140
+                $ip,
141
+                $action
142
+            ),
143
+            [
144
+                'app' => 'core',
145
+            ]
146
+        );
147
+
148
+        $qb = $this->db->getQueryBuilder();
149
+        $qb->insert('bruteforce_attempts');
150
+        foreach ($values as $column => $value) {
151
+            $qb->setValue($column, $qb->createNamedParameter($value));
152
+        }
153
+        $qb->execute();
154
+    }
155
+
156
+    /**
157
+     * Check if the IP is whitelisted
158
+     *
159
+     * @param string $ip
160
+     * @return bool
161
+     */
162
+    private function isIPWhitelisted(string $ip): bool {
163
+        if ($this->config->getSystemValue('auth.bruteforce.protection.enabled', true) === false) {
164
+            return true;
165
+        }
166
+
167
+        $keys = $this->config->getAppKeys('bruteForce');
168
+        $keys = array_filter($keys, function ($key) {
169
+            return 0 === strpos($key, 'whitelist_');
170
+        });
171
+
172
+        if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
173
+            $type = 4;
174
+        } elseif (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) {
175
+            $type = 6;
176
+        } else {
177
+            return false;
178
+        }
179
+
180
+        $ip = inet_pton($ip);
181
+
182
+        foreach ($keys as $key) {
183
+            $cidr = $this->config->getAppValue('bruteForce', $key, null);
184
+
185
+            $cx = explode('/', $cidr);
186
+            $addr = $cx[0];
187
+            $mask = (int)$cx[1];
188
+
189
+            // Do not compare ipv4 to ipv6
190
+            if (($type === 4 && !filter_var($addr, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) ||
191
+                ($type === 6 && !filter_var($addr, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6))) {
192
+                continue;
193
+            }
194
+
195
+            $addr = inet_pton($addr);
196
+
197
+            $valid = true;
198
+            for ($i = 0; $i < $mask; $i++) {
199
+                $part = ord($addr[(int)($i / 8)]);
200
+                $orig = ord($ip[(int)($i / 8)]);
201
+
202
+                $bitmask = 1 << (7 - ($i % 8));
203
+
204
+                $part = $part & $bitmask;
205
+                $orig = $orig & $bitmask;
206
+
207
+                if ($part !== $orig) {
208
+                    $valid = false;
209
+                    break;
210
+                }
211
+            }
212
+
213
+            if ($valid === true) {
214
+                return true;
215
+            }
216
+        }
217
+
218
+        return false;
219
+    }
220
+
221
+    /**
222
+     * Get the throttling delay (in milliseconds)
223
+     *
224
+     * @param string $ip
225
+     * @param string $action optionally filter by action
226
+     * @param float $maxAgeHours
227
+     * @return int
228
+     */
229
+    public function getAttempts(string $ip, string $action = '', float $maxAgeHours = 12): int {
230
+        if ($maxAgeHours > 48) {
231
+            $this->logger->error('Bruteforce has to use less than 48 hours');
232
+            $maxAgeHours = 48;
233
+        }
234
+
235
+        if ($ip === '' || $this->hasAttemptsDeleted) {
236
+            return 0;
237
+        }
238
+
239
+        $ipAddress = new IpAddress($ip);
240
+        if ($this->isIPWhitelisted((string)$ipAddress)) {
241
+            return 0;
242
+        }
243
+
244
+        $cutoffTime = $this->getCutoffTimestamp($maxAgeHours);
245
+
246
+        $qb = $this->db->getQueryBuilder();
247
+        $qb->select($qb->func()->count('*', 'attempts'))
248
+            ->from('bruteforce_attempts')
249
+            ->where($qb->expr()->gt('occurred', $qb->createNamedParameter($cutoffTime)))
250
+            ->andWhere($qb->expr()->eq('subnet', $qb->createNamedParameter($ipAddress->getSubnet())));
251
+
252
+        if ($action !== '') {
253
+            $qb->andWhere($qb->expr()->eq('action', $qb->createNamedParameter($action)));
254
+        }
255
+
256
+        $result = $qb->execute();
257
+        $row = $result->fetch();
258
+        $result->closeCursor();
259
+
260
+        return (int) $row['attempts'];
261
+    }
262
+
263
+    /**
264
+     * Get the throttling delay (in milliseconds)
265
+     *
266
+     * @param string $ip
267
+     * @param string $action optionally filter by action
268
+     * @return int
269
+     */
270
+    public function getDelay(string $ip, string $action = ''): int {
271
+        $attempts = $this->getAttempts($ip, $action);
272
+        if ($attempts === 0) {
273
+            return 0;
274
+        }
275
+
276
+        $firstDelay = 0.1;
277
+        if ($attempts > self::MAX_ATTEMPTS) {
278
+            // Don't ever overflow. Just assume the maxDelay time:s
279
+            return self::MAX_DELAY_MS;
280
+        }
281
+
282
+        $delay = $firstDelay * 2 ** $attempts;
283
+        if ($delay > self::MAX_DELAY) {
284
+            return self::MAX_DELAY_MS;
285
+        }
286
+        return (int) \ceil($delay * 1000);
287
+    }
288
+
289
+    /**
290
+     * Reset the throttling delay for an IP address, action and metadata
291
+     *
292
+     * @param string $ip
293
+     * @param string $action
294
+     * @param array $metadata
295
+     */
296
+    public function resetDelay(string $ip, string $action, array $metadata): void {
297
+        $ipAddress = new IpAddress($ip);
298
+        if ($this->isIPWhitelisted((string)$ipAddress)) {
299
+            return;
300
+        }
301
+
302
+        $cutoffTime = $this->getCutoffTimestamp();
303
+
304
+        $qb = $this->db->getQueryBuilder();
305
+        $qb->delete('bruteforce_attempts')
306
+            ->where($qb->expr()->gt('occurred', $qb->createNamedParameter($cutoffTime)))
307
+            ->andWhere($qb->expr()->eq('subnet', $qb->createNamedParameter($ipAddress->getSubnet())))
308
+            ->andWhere($qb->expr()->eq('action', $qb->createNamedParameter($action)))
309
+            ->andWhere($qb->expr()->eq('metadata', $qb->createNamedParameter(json_encode($metadata))));
310
+
311
+        $qb->executeStatement();
312
+
313
+        $this->hasAttemptsDeleted = true;
314
+    }
315
+
316
+    /**
317
+     * Reset the throttling delay for an IP address
318
+     *
319
+     * @param string $ip
320
+     */
321
+    public function resetDelayForIP($ip) {
322
+        $cutoffTime = $this->getCutoffTimestamp();
323
+
324
+        $qb = $this->db->getQueryBuilder();
325
+        $qb->delete('bruteforce_attempts')
326
+            ->where($qb->expr()->gt('occurred', $qb->createNamedParameter($cutoffTime)))
327
+            ->andWhere($qb->expr()->eq('ip', $qb->createNamedParameter($ip)));
328
+
329
+        $qb->execute();
330
+    }
331
+
332
+    /**
333
+     * Will sleep for the defined amount of time
334
+     *
335
+     * @param string $ip
336
+     * @param string $action optionally filter by action
337
+     * @return int the time spent sleeping
338
+     */
339
+    public function sleepDelay(string $ip, string $action = ''): int {
340
+        $delay = $this->getDelay($ip, $action);
341
+        usleep($delay * 1000);
342
+        return $delay;
343
+    }
344
+
345
+    /**
346
+     * Will sleep for the defined amount of time unless maximum was reached in the last 30 minutes
347
+     * In this case a "429 Too Many Request" exception is thrown
348
+     *
349
+     * @param string $ip
350
+     * @param string $action optionally filter by action
351
+     * @return int the time spent sleeping
352
+     * @throws MaxDelayReached when reached the maximum
353
+     */
354
+    public function sleepDelayOrThrowOnMax(string $ip, string $action = ''): int {
355
+        $delay = $this->getDelay($ip, $action);
356
+        if (($delay === self::MAX_DELAY_MS) && $this->getAttempts($ip, $action, 0.5) > self::MAX_ATTEMPTS) {
357
+            $this->logger->info('IP address blocked because it reached the maximum failed attempts in the last 30 minutes [action: {action}, ip: {ip}]', [
358
+                'action' => $action,
359
+                'ip' => $ip,
360
+            ]);
361
+            // If the ip made too many attempts within the last 30 mins we don't execute anymore
362
+            throw new MaxDelayReached('Reached maximum delay');
363
+        }
364
+        if ($delay > 100) {
365
+            $this->logger->info('IP address throttled because it reached the attempts limit in the last 30 minutes [action: {action}, delay: {delay}, ip: {ip}]', [
366
+                'action' => $action,
367
+                'ip' => $ip,
368
+                'delay' => $delay,
369
+            ]);
370
+        }
371
+        usleep($delay * 1000);
372
+        return $delay;
373
+    }
374 374
 }
Please login to merge, or discard this patch.