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:
Thomas Vincent authored and GitHub committed 2026-08-31 17:02:27 -04:00
1 parent a4010e64a6
commit 3045b4665e
8 files changed
+266 -5

No files matched your search

+1
View File
@@ -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
+4
View File
@@ -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
View File
@@ -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
View File
@@ -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.');
+57
View File
@@ -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.
+22
View File
@@ -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
+18
View File
@@ -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
+103
View File
@@ -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');
});