Skip to content

Commit 05fc730

Browse files
committed
add command get get bruteforce stats
Signed-off-by: Robin Appelman <robin@icewind.nl>
1 parent b76b0bb commit 05fc730

6 files changed

Lines changed: 168 additions & 39 deletions

File tree

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
/**
5+
* @copyright Copyright (c) 2023 Robin Appelman <robin@icewind.nl>
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\Core\Command\Security;
25+
26+
use OC\Core\Command\Base;
27+
use OC\Security\Bruteforce\Throttler;
28+
use Symfony\Component\Console\Helper\Table;
29+
use Symfony\Component\Console\Input\InputInterface;
30+
use Symfony\Component\Console\Input\InputOption;
31+
use Symfony\Component\Console\Output\OutputInterface;
32+
33+
class BruteforceStatus extends Base {
34+
public function __construct(
35+
protected Throttler $throttler,
36+
) {
37+
parent::__construct();
38+
}
39+
40+
protected function configure(): void {
41+
parent::configure();
42+
$this
43+
->setName('security:bruteforce:status')
44+
->setDescription('List bruteforce attempts summary')
45+
->addOption('ipaddress', 'i', InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Only list attempts from the specified ip range in CIDR notation')
46+
->addOption('action', 'a', InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Only list attempts for the specified action')
47+
->addOption('count', 'c', InputOption::VALUE_REQUIRED, 'Only list a limited number of items with the most occurrences');
48+
}
49+
50+
protected function execute(InputInterface $input, OutputInterface $output): int {
51+
$ips = $input->getOption('ipaddress');
52+
$actions = $input->getOption('action');
53+
$count = $input->getOption('count');
54+
55+
$summary = $this->throttler->summarizeAttempts();
56+
57+
if ($ips) {
58+
$summary = array_filter($summary, function (array $item) use ($ips) {
59+
return $this->throttler->inSubnets($item['ip'], $ips);
60+
});
61+
}
62+
63+
if ($actions) {
64+
$actions = array_map(function (string $action) {
65+
return strtolower($action);
66+
}, $actions);
67+
$summary = array_filter($summary, function (array $item) use ($actions) {
68+
$lowerAction = strtolower($item['action']);
69+
foreach ($actions as $action) {
70+
return str_contains($lowerAction, $action);
71+
}
72+
return false;
73+
});
74+
}
75+
76+
if ($count) {
77+
$summary = array_slice($summary, 0, $count);
78+
}
79+
80+
if ($input->getOption('output') === self::OUTPUT_FORMAT_JSON || $input->getOption('output') === self::OUTPUT_FORMAT_JSON_PRETTY) {
81+
$this->writeArrayInOutputFormat($input, $output, $summary);
82+
} else {
83+
$table = new Table($output);
84+
$table
85+
->setHeaders(['IP', 'Action', 'Count'])
86+
->setRows(array_map(function (array $item) {
87+
return [$item['ip'], $item['action'], $item['count']];
88+
}, $summary));
89+
$table->render();
90+
}
91+
return 0;
92+
}
93+
}

core/Command/Security/ResetBruteforceAttempts.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ public function __construct(
3939
protected function configure() {
4040
$this
4141
->setName('security:bruteforce:reset')
42-
->setDescription('resets bruteforce attemps for given IP address')
42+
->setDescription('resets bruteforce attempts for given IP address')
4343
->addArgument(
4444
'ipaddress',
4545
InputArgument::REQUIRED,

core/register_command.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@
4848
* along with this program. If not, see <http://www.gnu.org/licenses/>
4949
*
5050
*/
51+
52+
use OC\Core\Command\Security\BruteforceStatus;
5153
use Psr\Log\LoggerInterface;
5254

5355
$application->add(new \Stecman\Component\Symfony\Console\BashCompletion\CompletionCommand());
@@ -210,6 +212,7 @@
210212
$application->add(new OC\Core\Command\Security\ImportCertificate(\OC::$server->getCertificateManager()));
211213
$application->add(new OC\Core\Command\Security\RemoveCertificate(\OC::$server->getCertificateManager()));
212214
$application->add(new OC\Core\Command\Security\ResetBruteforceAttempts(\OC::$server->getBruteForceThrottler()));
215+
$application->add(\OC::$server->get(BruteforceStatus::class));
213216
} else {
214217
$application->add(\OC::$server->get(\OC\Core\Command\Maintenance\Install::class));
215218
}

lib/composer/composer/autoload_classmap.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1010,6 +1010,7 @@
10101010
'OC\\Core\\Command\\Preview\\Generate' => $baseDir . '/core/Command/Preview/Generate.php',
10111011
'OC\\Core\\Command\\Preview\\Repair' => $baseDir . '/core/Command/Preview/Repair.php',
10121012
'OC\\Core\\Command\\Preview\\ResetRenderedTexts' => $baseDir . '/core/Command/Preview/ResetRenderedTexts.php',
1013+
'OC\\Core\\Command\\Security\\BruteforceStatus' => $baseDir . '/core/Command/Security/BruteforceStatus.php',
10131014
'OC\\Core\\Command\\Security\\ImportCertificate' => $baseDir . '/core/Command/Security/ImportCertificate.php',
10141015
'OC\\Core\\Command\\Security\\ListCertificates' => $baseDir . '/core/Command/Security/ListCertificates.php',
10151016
'OC\\Core\\Command\\Security\\RemoveCertificate' => $baseDir . '/core/Command/Security/RemoveCertificate.php',

lib/composer/composer/autoload_static.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1043,6 +1043,7 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2
10431043
'OC\\Core\\Command\\Preview\\Generate' => __DIR__ . '/../../..' . '/core/Command/Preview/Generate.php',
10441044
'OC\\Core\\Command\\Preview\\Repair' => __DIR__ . '/../../..' . '/core/Command/Preview/Repair.php',
10451045
'OC\\Core\\Command\\Preview\\ResetRenderedTexts' => __DIR__ . '/../../..' . '/core/Command/Preview/ResetRenderedTexts.php',
1046+
'OC\\Core\\Command\\Security\\BruteforceStatus' => __DIR__ . '/../../..' . '/core/Command/Security/BruteforceStatus.php',
10461047
'OC\\Core\\Command\\Security\\ImportCertificate' => __DIR__ . '/../../..' . '/core/Command/Security/ImportCertificate.php',
10471048
'OC\\Core\\Command\\Security\\ListCertificates' => __DIR__ . '/../../..' . '/core/Command/Security/ListCertificates.php',
10481049
'OC\\Core\\Command\\Security\\RemoveCertificate' => __DIR__ . '/../../..' . '/core/Command/Security/RemoveCertificate.php',

lib/private/Security/Bruteforce/Throttler.php

Lines changed: 69 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
* along with this program. If not, see <http://www.gnu.org/licenses/>.
3131
*
3232
*/
33+
3334
namespace OC\Security\Bruteforce;
3435

3536
use OC\Security\Normalizer\IpAddress;
@@ -66,10 +67,12 @@ class Throttler implements IThrottler {
6667
/** @var bool[] */
6768
private $hasAttemptsDeleted = [];
6869

69-
public function __construct(IDBConnection $db,
70-
ITimeFactory $timeFactory,
71-
LoggerInterface $logger,
72-
IConfig $config) {
70+
public function __construct(
71+
IDBConnection $db,
72+
ITimeFactory $timeFactory,
73+
LoggerInterface $logger,
74+
IConfig $config
75+
) {
7376
$this->db = $db;
7477
$this->timeFactory = $timeFactory;
7578
$this->logger = $logger;
@@ -97,7 +100,7 @@ private function getCutoff(int $expire): \DateInterval {
97100
*/
98101
private function getCutoffTimestamp(float $maxAgeHours = 12.0): int {
99102
return (new \DateTime())
100-
->sub($this->getCutoff((int) ($maxAgeHours * 3600)))
103+
->sub($this->getCutoff((int)($maxAgeHours * 3600)))
101104
->getTimestamp();
102105
}
103106

@@ -108,9 +111,11 @@ private function getCutoffTimestamp(float $maxAgeHours = 12.0): int {
108111
* @param string $ip
109112
* @param array $metadata Optional metadata logged to the database
110113
*/
111-
public function registerAttempt(string $action,
112-
string $ip,
113-
array $metadata = []): void {
114+
public function registerAttempt(
115+
string $action,
116+
string $ip,
117+
array $metadata = []
118+
): void {
114119
// No need to log if the bruteforce protection is disabled
115120
if (!$this->config->getSystemValueBool('auth.bruteforce.protection.enabled', true)) {
116121
return;
@@ -159,7 +164,19 @@ private function isIPWhitelisted(string $ip): bool {
159164
$keys = array_filter($keys, function ($key) {
160165
return str_starts_with($key, 'whitelist_');
161166
});
167+
$subnets = array_map(function ($key) {
168+
return $this->config->getAppValue('bruteForce', $key, null);
169+
}, $keys);
170+
171+
return $this->inSubnets($ip, $subnets);
172+
}
162173

174+
/**
175+
* @param string $ip
176+
* @param string[] $subnets
177+
* @return bool
178+
*/
179+
public function inSubnets(string $ip, array $subnets): bool {
163180
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
164181
$type = 4;
165182
} elseif (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) {
@@ -170,43 +187,44 @@ private function isIPWhitelisted(string $ip): bool {
170187

171188
$ip = inet_pton($ip);
172189

173-
foreach ($keys as $key) {
174-
$cidr = $this->config->getAppValue('bruteForce', $key, null);
175-
176-
$cx = explode('/', $cidr);
177-
$addr = $cx[0];
178-
$mask = (int)$cx[1];
179-
180-
// Do not compare ipv4 to ipv6
181-
if (($type === 4 && !filter_var($addr, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) ||
182-
($type === 6 && !filter_var($addr, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6))) {
183-
continue;
190+
foreach ($subnets as $cidr) {
191+
if ($this->inSubnet($ip, $cidr, $type)) {
192+
return true;
184193
}
194+
}
195+
return false;
196+
}
185197

186-
$addr = inet_pton($addr);
198+
private function inSubnet(string $ip, string $cidr, int $type): bool {
199+
$cx = explode('/', $cidr);
200+
$addr = $cx[0];
187201

188-
$valid = true;
189-
for ($i = 0; $i < $mask; $i++) {
190-
$part = ord($addr[(int)($i / 8)]);
191-
$orig = ord($ip[(int)($i / 8)]);
202+
// Do not compare ipv4 to ipv6
203+
if (($type === 4 && !filter_var($addr, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) ||
204+
($type === 6 && !filter_var($addr, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6))) {
205+
return false;
206+
}
192207

193-
$bitmask = 1 << (7 - ($i % 8));
208+
$addr = inet_pton($addr);
209+
if (count($cx) === 1) {
210+
return $ip === $addr;
211+
}
212+
$mask = (int)$cx[1];
194213

195-
$part = $part & $bitmask;
196-
$orig = $orig & $bitmask;
214+
for ($i = 0; $i < $mask; $i++) {
215+
$part = ord($addr[(int)($i / 8)]);
216+
$orig = ord($ip[(int)($i / 8)]);
197217

198-
if ($part !== $orig) {
199-
$valid = false;
200-
break;
201-
}
202-
}
218+
$bitmask = 1 << (7 - ($i % 8));
203219

204-
if ($valid === true) {
205-
return true;
220+
$part = $part & $bitmask;
221+
$orig = $orig & $bitmask;
222+
223+
if ($part !== $orig) {
224+
return false;
206225
}
207226
}
208-
209-
return false;
227+
return true;
210228
}
211229

212230
/**
@@ -248,7 +266,7 @@ public function getAttempts(string $ip, string $action = '', float $maxAgeHours
248266
$row = $result->fetch();
249267
$result->closeCursor();
250268

251-
return (int) $row['attempts'];
269+
return (int)$row['attempts'];
252270
}
253271

254272
/**
@@ -274,7 +292,7 @@ public function getDelay(string $ip, string $action = ''): int {
274292
if ($delay > self::MAX_DELAY) {
275293
return self::MAX_DELAY_MS;
276294
}
277-
return (int) \ceil($delay * 1000);
295+
return (int)\ceil($delay * 1000);
278296
}
279297

280298
/**
@@ -362,4 +380,17 @@ public function sleepDelayOrThrowOnMax(string $ip, string $action = ''): int {
362380
usleep($delay * 1000);
363381
return $delay;
364382
}
383+
384+
/**
385+
* @return array{'action': string, ip: string, count: int}[]
386+
*/
387+
public function summarizeAttempts(): array {
388+
$query = $this->db->getQueryBuilder();
389+
$query->select(['action', 'ip'])
390+
->selectAlias($query->func()->count('id'), 'count')
391+
->from('bruteforce_attempts')
392+
->groupBy(['action', 'ip'])
393+
->orderBy($query->func()->count('id'));
394+
return $query->executeQuery()->fetchAll();
395+
}
365396
}

0 commit comments

Comments
 (0)