mirror of
https://github.com/Cacti/cacti.git
synced 2026-10-23 03:27:02 +09:00
Add fail2ban support for authentication failures (#7676)
* Add fail2ban support for authentication failures Emit a fixed, machine-parseable AUTH FAILURE line (user, realm, ip, reason) from the universal login-failure path and the 2FA branch, normalise the human AUTH strings, and ship a fail2ban filter, jail example, and README. The IP comes from get_client_addr(); a crafted username cannot forge fields. Signed-off-by: Thomas Vincent <thomasvincent@gmail.com> * test: resolve includes through Cacti path constants Signed-off-by: Thomas Vincent <thomasvincent@gmail.com> --------- Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
This commit is contained in:
1 parent
a4010e64a6
commit
3045b4665e
8 files changed
+266
-5
No files matched your search
@@ -339,6 +339,7 @@ Cacti CHANGELOG
|
||||
-feature#7370: Allow plugins to register process tables for the technical support view
|
||||
-feature#7372: Add installer domain foundation types and lifecycle tests
|
||||
-feature#7634: Add indexes for host-template and session lookup queries
|
||||
-feature#7681: Add fail2ban support with a stable authentication-failure log line and a bundled filter
|
||||
|
||||
1.2.x
|
||||
-issue#7212: Address a few small memory leaks in realtime graphing
|
||||
|
||||
@@ -116,6 +116,10 @@ if (gnrv('action') == 'login_2fa') {
|
||||
// BAD token
|
||||
cacti_log("DEBUG: User '" . $user['username'] . "' failed to verify 2fa token", false, 'AUTH', POLLER_VERBOSITY_DEBUG);
|
||||
|
||||
// Structured failure line for fail2ban, at normal verbosity (the human line
|
||||
// above is DEBUG-only). A failed second factor is a brute-force signal too.
|
||||
auth_log_failure($user['username'], auth_realm_token((int) ($user['realm'] ?? 0)), '2fa');
|
||||
|
||||
db_execute_prepared('INSERT IGNORE INTO user_log
|
||||
(username, user_id, result, ip, time)
|
||||
VALUES (?, 0, 3, ?, NOW())',
|
||||
|
||||
+7
-1
@@ -271,7 +271,13 @@ if (gnrv('action') == 'login' || $auth_method == AUTH_METHOD_BASIC) {
|
||||
[$username, !empty($id) ? $id : 0, get_client_addr()]
|
||||
);
|
||||
|
||||
cacti_log('LOGIN FAILED: ' . $realm_name . " Login Failed for user '" . $username . "' from IP Address '" . get_client_addr() . "'.", false, 'AUTH');
|
||||
cacti_log('LOGIN FAILED: ' . $realm_name . " login failed for user '" . $username . "' from IP address '" . get_client_addr() . "'.", false, 'AUTH');
|
||||
|
||||
// Single machine-parseable failure line for fail2ban. This is the universal
|
||||
// choke point for a failed login across every realm, so it fires exactly once
|
||||
// per attempt (auth_process_lockout() deliberately does not emit its own).
|
||||
// $frv_realm is the dropdown value the $realm_name switch above keys on.
|
||||
auth_log_failure($username, auth_realm_token((int) $frv_realm), !empty($id) ? 'bad_password' : 'no_such_user');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+54
-4
@@ -3714,6 +3714,56 @@ function auth_process_lockout_check(string $username, int $realm) : bool {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit a stable, machine-parseable authentication-failure line for external tools
|
||||
* (e.g. fail2ban) alongside the existing human-readable AUTH log entries. The
|
||||
* grammar is fixed so a fail2ban failregex can key on it reliably across releases:
|
||||
*
|
||||
* AUTH FAILURE user="<user>" realm="<realm>" ip="<ip>" reason="<reason>"
|
||||
*
|
||||
* The IP comes from get_client_addr(), which is already validated and honours the
|
||||
* trusted-proxy configuration. The username is attacker-influenced, so quotes and
|
||||
* control characters are stripped to keep the grammar intact and prevent a crafted
|
||||
* username from forging log lines or extra fields.
|
||||
*
|
||||
* @param string $username Username supplied by the client.
|
||||
* @param string $realm Realm token: local|ldap|domain.
|
||||
* @param string $reason Reason token: bad_password|no_such_user|2fa.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
function auth_log_failure(string $username, string $realm, string $reason) : void {
|
||||
$clean = static function (string $value) : string {
|
||||
$stripped = preg_replace('/[\x00-\x1f\x7f"]/', '', $value);
|
||||
|
||||
return $stripped ?? '';
|
||||
};
|
||||
|
||||
cacti_log(
|
||||
sprintf(
|
||||
'AUTH FAILURE user="%s" realm="%s" ip="%s" reason="%s"',
|
||||
$clean($username), $clean($realm), get_client_addr(), $clean($reason)
|
||||
),
|
||||
false,
|
||||
'AUTH'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a numeric authentication realm to the token used in auth_log_failure().
|
||||
*
|
||||
* @param int $realm The numeric realm id.
|
||||
*
|
||||
* @return string local|ldap|domain
|
||||
*/
|
||||
function auth_realm_token(int $realm) : string {
|
||||
if ($realm <= 1) {
|
||||
return 'local';
|
||||
}
|
||||
|
||||
return $realm == 2 ? 'ldap' : 'domain';
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when a user login attempt fails to increment or lockout the user
|
||||
* if there is an error, the globals error and error_msg will be set to notify the caller
|
||||
@@ -3742,7 +3792,7 @@ function auth_process_lockout(string $username, int $realm) : void {
|
||||
|
||||
if (cacti_sizeof($user)) {
|
||||
if ($user['enabled'] == '') {
|
||||
cacti_log(sprintf('LOGIN FAILED: Local Login Failed for user %s from IP address %s. User account disabled.', $username, get_client_addr()), false, 'AUTH');
|
||||
cacti_log(sprintf("LOGIN FAILED: Login failed for user '%s' from IP address '%s'. User account disabled.", $username, get_client_addr()), false, 'AUTH');
|
||||
|
||||
$error = true;
|
||||
$error_msg = __('Access Denied! Login Disabled.');
|
||||
@@ -3786,19 +3836,19 @@ function auth_process_lockout(string $username, int $realm) : void {
|
||||
[$username, $user['id'] ?? 0, get_client_addr()]);
|
||||
|
||||
if ($user['locked'] == 'on') {
|
||||
cacti_log(sprintf("LOGIN FAILED: Local Login Failed for user '%s' from IP Address '%s'. Account is locked out.", $username, get_client_addr()), false, 'AUTH');
|
||||
cacti_log(sprintf("LOGIN FAILED: Login failed for user '%s' from IP address '%s'. Account is locked out.", $username, get_client_addr()), false, 'AUTH');
|
||||
|
||||
$error = true;
|
||||
$error_msg = __('Your account has been locked. Please contact your Administrator.');
|
||||
} else {
|
||||
cacti_log(sprintf("LOGIN FAILED: Local Login Failed for user '%s' from IP Address '%s'", $username, get_client_addr()), false, 'AUTH');
|
||||
cacti_log(sprintf("LOGIN FAILED: Login failed for user '%s' from IP address '%s'.", $username, get_client_addr()), false, 'AUTH');
|
||||
|
||||
// error
|
||||
$error = true;
|
||||
$error_msg = __('Access Denied! Login Failed.');
|
||||
}
|
||||
} else {
|
||||
cacti_log(sprintf("LOGIN FAILED: Local Login Failed to find user '%s' from IP Address '%s'", $username, get_client_addr()), false, 'AUTH');
|
||||
cacti_log(sprintf("LOGIN FAILED: Login failed to find user '%s' from IP address '%s'.", $username, get_client_addr()), false, 'AUTH');
|
||||
|
||||
$error = true;
|
||||
$error_msg = __('Access Denied! Login Failed.');
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
# Fail2Ban integration for Cacti
|
||||
|
||||
Cacti already locks a *user account* after repeated failures (Settings > Authentication,
|
||||
`secpass_lockfailed`). Fail2Ban complements that by blocking the offending *IP address*
|
||||
at the firewall, which stops distributed guessing and probing that never trips a single
|
||||
account's lockout.
|
||||
|
||||
## How it works
|
||||
|
||||
On every failed authentication Cacti writes a fixed, machine-parseable line to its log
|
||||
(tag `AUTH`), in addition to the human-readable messages:
|
||||
|
||||
```
|
||||
AUTH FAILURE user="alice" realm="local" ip="203.0.113.7" reason="bad_password"
|
||||
```
|
||||
|
||||
- `realm` — `local`, `ldap`, or `domain`
|
||||
- `reason` — `bad_password`, `no_such_user`, or `2fa`
|
||||
- `ip` — a validated address from Cacti's `get_client_addr()`
|
||||
|
||||
The bundled filter matches this line and extracts the IP.
|
||||
|
||||
## Install
|
||||
|
||||
1. Copy the filter:
|
||||
|
||||
```
|
||||
cp misc/fail2ban/cacti.conf /etc/fail2ban/filter.d/cacti.conf
|
||||
```
|
||||
|
||||
2. Add the jail (edit `logpath` to your install), then reload:
|
||||
|
||||
```
|
||||
cp misc/fail2ban/jail.local /etc/fail2ban/jail.d/cacti.local
|
||||
# edit logpath, then:
|
||||
fail2ban-client reload
|
||||
```
|
||||
|
||||
3. Verify the filter against your log:
|
||||
|
||||
```
|
||||
fail2ban-regex /var/www/html/cacti/log/cacti.log misc/fail2ban/cacti.conf
|
||||
```
|
||||
|
||||
## Logging destination
|
||||
|
||||
The filter reads Cacti's file log (`path_cactilog`, default `<cacti>/log/cacti.log`).
|
||||
If you route Cacti to syslog instead, point `logpath` at the syslog file that receives
|
||||
the `AUTH` entries, or run a jail with `backend = systemd`.
|
||||
|
||||
## Reverse proxy caveat
|
||||
|
||||
The `ip` field is whatever `get_client_addr()` returns. Behind a reverse proxy that is
|
||||
`REMOTE_ADDR` — i.e. the proxy's address — unless you configure Cacti to trust the proxy
|
||||
and read the forwarded client address (see the proxy/`proxy_headers` settings in
|
||||
`include/config.php`). Without that, Fail2Ban would ban the proxy. Configure trusted
|
||||
proxies before enabling this jail in a proxied deployment.
|
||||
@@ -0,0 +1,22 @@
|
||||
# Fail2Ban filter for Cacti authentication failures.
|
||||
#
|
||||
# Cacti emits one stable, machine-parseable line per failed authentication
|
||||
# attempt (added in 1.3), regardless of realm:
|
||||
#
|
||||
# AUTH FAILURE user="<user>" realm="<realm>" ip="<ip>" reason="<reason>"
|
||||
#
|
||||
# realm is one of: local, ldap, domain
|
||||
# reason is one of: bad_password, no_such_user, 2fa
|
||||
#
|
||||
# The ip field always holds a validated address from Cacti's get_client_addr(),
|
||||
# which honours the trusted-proxy configuration. See README.md for the reverse
|
||||
# proxy caveat.
|
||||
|
||||
[Definition]
|
||||
|
||||
failregex = ^.*\bAUTH FAILURE user="[^"]*" realm="[^"]*" ip="<HOST>" reason="[^"]*"\s*$
|
||||
|
||||
ignoreregex =
|
||||
|
||||
# Cacti log lines are prefixed with a local-time "YYYY-MM-DD HH:MM:SS" stamp.
|
||||
datepattern = ^%%Y-%%m-%%d %%H:%%M:%%S
|
||||
@@ -0,0 +1,18 @@
|
||||
# Example Fail2Ban jail for Cacti. Merge this into /etc/fail2ban/jail.local (or
|
||||
# drop it in /etc/fail2ban/jail.d/cacti.local) and adjust logpath to your install.
|
||||
#
|
||||
# Copy misc/fail2ban/cacti.conf to /etc/fail2ban/filter.d/cacti.conf first.
|
||||
|
||||
[cacti]
|
||||
enabled = true
|
||||
filter = cacti
|
||||
port = http,https
|
||||
|
||||
# Path to Cacti's log. This is the file behind the "path_cactilog" setting,
|
||||
# by default <cacti>/log/cacti.log. Point this at your actual install.
|
||||
logpath = /var/www/html/cacti/log/cacti.log
|
||||
|
||||
# Ban an address after this many failures within findtime.
|
||||
maxretry = 5
|
||||
findtime = 10m
|
||||
bantime = 1h
|
||||
@@ -0,0 +1,103 @@
|
||||
<?php
|
||||
/*
|
||||
+-------------------------------------------------------------------------+
|
||||
| Copyright (C) 2004-2026 The Cacti Group |
|
||||
| |
|
||||
| This program is free software; you can redistribute it and/or |
|
||||
| modify it under the terms of the GNU General Public License |
|
||||
| as published by the Free Software Foundation; either version 2 |
|
||||
| of the License, or (at your option) any later version. |
|
||||
+-------------------------------------------------------------------------+
|
||||
| Cacti: The Complete RRDtool-based Graphing Solution |
|
||||
+-------------------------------------------------------------------------+
|
||||
*/
|
||||
|
||||
/*
|
||||
* auth_log_failure() emits a fixed, machine-parseable line for fail2ban on every
|
||||
* failed authentication. The grammar and the bundled fail2ban filter have to stay
|
||||
* in lock-step, and the line must fire from the universal login-failure choke point
|
||||
* and the 2FA failure branch. A crafted username must not be able to forge fields
|
||||
* or extra log lines.
|
||||
*/
|
||||
|
||||
$authSrc = file_get_contents(CACTI_PATH_LIBRARY . '/auth.php');
|
||||
$loginSrc = file_get_contents(CACTI_PATH_BASE . '/auth_login.php');
|
||||
$twofaSrc = file_get_contents(CACTI_PATH_BASE . '/auth_2fa.php');
|
||||
$filter = file_get_contents(CACTI_PATH_BASE . '/misc/fail2ban/cacti.conf');
|
||||
|
||||
// The fail2ban <HOST> token, expanded to a PCRE that matches an IPv4/IPv6 address.
|
||||
function _f2b_regex(string $filter): string {
|
||||
expect($filter)->toContain('failregex =');
|
||||
|
||||
if (!preg_match('/^failregex\s*=\s*(.+)$/m', $filter, $m)) {
|
||||
throw new RuntimeException('no failregex in filter');
|
||||
}
|
||||
|
||||
$host = '(?P<host>[0-9]{1,3}(?:\.[0-9]{1,3}){3}|[0-9A-Fa-f:]+)';
|
||||
|
||||
return '/' . str_replace('<HOST>', $host, trim($m[1])) . '/';
|
||||
}
|
||||
|
||||
test('the structured grammar is fixed and documented in one place', function () use ($authSrc) {
|
||||
// exactly one sprintf template, so the filter can rely on it
|
||||
expect($authSrc)->toContain('AUTH FAILURE user="%s" realm="%s" ip="%s" reason="%s"');
|
||||
expect(substr_count($authSrc, 'AUTH FAILURE user="%s"'))->toBe(1);
|
||||
|
||||
// the helper reads the validated, proxy-aware address
|
||||
$start = strpos($authSrc, 'function auth_log_failure(');
|
||||
$body = substr($authSrc, $start, strpos($authSrc, "\n}", $start) - $start);
|
||||
expect($body)->toContain('get_client_addr()');
|
||||
});
|
||||
|
||||
test('the failure line fires from the universal choke point and the 2FA branch', function () use ($loginSrc, $twofaSrc) {
|
||||
// one emission per login attempt, at auth_login.php's generic failure exit
|
||||
expect($loginSrc)->toContain("auth_log_failure(\$username, auth_realm_token((int) \$frv_realm), !empty(\$id) ? 'bad_password' : 'no_such_user')");
|
||||
// a failed second factor is a brute-force signal too
|
||||
expect($twofaSrc)->toContain("auth_log_failure(\$user['username'], auth_realm_token((int) (\$user['realm'] ?? 0)), '2fa')");
|
||||
});
|
||||
|
||||
test('auth_process_lockout does not emit its own structured line (no double count)', function () use ($authSrc) {
|
||||
$start = strpos($authSrc, 'function auth_process_lockout(');
|
||||
$body = substr($authSrc, $start, strpos($authSrc, "\nfunction ", $start + 1) - $start);
|
||||
|
||||
// local failures reach both auth_process_lockout and the auth_login.php choke
|
||||
// point; only the latter emits, so fail2ban counts one failure per attempt
|
||||
expect($body)->not->toContain('auth_log_failure(');
|
||||
});
|
||||
|
||||
test('the bundled fail2ban filter matches the emitted line and extracts the IP', function () use ($filter) {
|
||||
$regex = _f2b_regex($filter);
|
||||
$stamp = '2026-08-13 09:15:42 - ';
|
||||
|
||||
$v4 = $stamp . 'AUTH FAILURE user="alice" realm="local" ip="203.0.113.7" reason="bad_password"';
|
||||
$v6 = $stamp . 'AUTH FAILURE user="bob" realm="ldap" ip="2001:db8::42" reason="2fa"';
|
||||
|
||||
expect(preg_match($regex, $v4, $m4))->toBe(1);
|
||||
expect($m4['host'])->toBe('203.0.113.7');
|
||||
expect(preg_match($regex, $v6, $m6))->toBe(1);
|
||||
expect($m6['host'])->toBe('2001:db8::42');
|
||||
});
|
||||
|
||||
test('the filter does not match successful logins or unrelated lines', function () use ($filter) {
|
||||
$regex = _f2b_regex($filter);
|
||||
|
||||
expect(preg_match($regex, "2026-08-13 09:15:42 - AUTH LOGIN: User 'alice' authenticated from IP address '203.0.113.7'"))->toBe(0);
|
||||
expect(preg_match($regex, '2026-08-13 09:15:42 - SYSTEM STATS: Time:1.0'))->toBe(0);
|
||||
});
|
||||
|
||||
test('a crafted username cannot forge fields or inject a second line', function () use ($authSrc, $filter) {
|
||||
// mirror the helper's cleaning: strip quotes and control characters
|
||||
$clean = static fn (string $v): string => preg_replace('/[\x00-\x1f\x7f"]/', '', $v);
|
||||
|
||||
$evil = "x\" ip=\"1.2.3.4\" reason=\"bad_password\"\n2026-01-01 00:00:00 - AUTH FAILURE user=\"y";
|
||||
$line = '2026-08-13 09:15:42 - ' . sprintf('AUTH FAILURE user="%s" realm="%s" ip="%s" reason="%s"', $clean($evil), 'local', '203.0.113.7', 'bad_password');
|
||||
|
||||
// the cleaned username has no quote, CR/LF or control chars left
|
||||
expect($clean($evil))->not->toContain('"');
|
||||
expect(preg_match('/[\x00-\x1f\x7f]/', $clean($evil)))->toBe(0);
|
||||
|
||||
// and the resulting single line still parses to the real attacker IP
|
||||
$regex = _f2b_regex($filter);
|
||||
expect(preg_match($regex, $line, $m))->toBe(1);
|
||||
expect($m['host'])->toBe('203.0.113.7');
|
||||
});
|
||||
Reference in new issue
Block a user