Detects and blocks brute-force login attempts against a Jahia server, in the spirit of fail2ban. Supports IPv4 and IPv6.
findTime), not just consecutive failures.FailureSource, AuthFailureDetector and BanAction SPIs let other modules contribute events, recognise new authentication schemes, or react to bans (block in-process, email, webhook, custom).VALVE_RESULT), HTTP Basic, Personal API tokens (Authorization: APIToken …) and the legacy jahiatoken header are all tracked.X-BFLP-Signature header).3.0.0 is a full rewrite of the module to bring it in line with fail2ban
semantics. The 2.x model — one global threshold, "block after N consecutive
failures", JCR-only state, single-node only, form-login only — has been
replaced end-to-end. There is no automatic migration: re-enter your
settings from the admin UI after upgrading.
| Aspect | 2.0.2 | 3.0.0 |
|---|---|---|
| Trigger | N consecutive failures (default 6) |
Sliding window: maxRetry failures within findTime |
| Configuration scope | Single global threshold | Multiple jails, each with own maxRetry / findTime / banTime / enabled flag |
| Repeat offenders | None — same threshold every time | Recidive escalation: progressively longer bans, capped by maxBanTime |
| Ban duration | Until manual unban / cache flush | Time-based, automatic unban; bans survive restart |
| State backend | JCR cache | Embedded Hazelcast instance, cluster-shared |
| Counter reset | Manual via "Unblock" / "Flush cache" | Sliding window expires naturally; manual unban still available |
2.0.2 only inspected the legacy form-login valve. 3.0.0 tracks every
authenticated request through an ordered chain of detectors:
| Order | Detector | Covers |
|---|---|---|
| 100 | FormLoginFailureDetector |
LoginEngineAuthValveImpl + every SsoValve subclass (anything that sets VALVE_RESULT) |
| 200 | BasicAuthFailureDetector |
Authorization: Basic … |
| 300 | ApiTokenAuthFailureDetector |
Authorization: APIToken … (personal-api-tokens) |
| 400 | JahiaTokenAuthFailureDetector |
legacy jahiatoken header (TokenAuthValveImpl) |
The valve breaks on the first non-null FailureSignal, so each request
records at most one failure. Detector exceptions are isolated — one buggy
custom detector cannot break the chain.
In 2.0.2, Basic auth, Personal API tokens and jahiatoken requests
bypassed the listener entirely. Brute-force against those schemes was
effectively unprotected.
Three OSGi DS service interfaces let other modules plug in without
patching the core:
public interface AuthFailureDetector { // recognise a new auth scheme
FailureSignal detect(AuthFailureContext context);
default int order() { return 500; }
}
public interface FailureSource { /* contribute failure events */ }
public interface BanAction { /* react when an IP is banned */ }
Built-in BanActions shipped: in-process block, throttled email
notification, signed webhook POST. No script/exec action is provided
or supported — by design.
See README → Extending — adding a new auth-failure detector for a
worked example.
ConfigurationAdmin is now the source of truth for global.cfg files under<jahia-var>/karaf/etc/. Bans and the audit log remain JCR-backedyyyy/MM/dd under the audit-logjmix:autoSplitFolders, so the JCR fan-out staysPer-install group password. On first start the module generates a
random 32-byte secret in
<jahia-var>/karaf/etc/bflp-cluster-secret.properties (mode rw-------
where supported). Override with -Dbflp.cluster.password=….
Mutual-TLS between members is opt-in:
| Property | Purpose |
|---|---|
bflp.cluster.keystore |
Path to keystore (.jks / .p12) |
bflp.cluster.keystorePassword |
Keystore password |
bflp.cluster.truststore |
Path to truststore |
bflp.cluster.truststorePassword |
Truststore password |
Without these, cluster traffic between Hazelcast members is plaintext.
trustProxyHeader flag wasX-Forwarded-For — meaning any direct client thattrustedProxyCidrs. A one-time warning isX-BFLP-Signature169.254.169.254). Plainhttp:// receivers are opt-in only via -Dbflp.webhook.allowHttp=true.{enc} prefix). The GraphQL mutation honours a tri-statenull keeps the stored secret, "" clears it, anything elseThe embedded Hazelcast registers a GlobalSerializerConfig with an
explicit allowlist of classes that may be deserialized from cluster
traffic. Unknown types are rejected. This addresses the classic
"deserialize anything off the wire" class of vulnerabilities that comes
with naive Hazelcast embedding.
Detectors that match on bearer-style headers (APIToken, jahiatoken)
deliberately do not populate FailureSignal.username — the token
value is a secret, and username is rendered in the audit UI.
The legacy "Service status + threshold + CIDR whitelist + Tracked IPs"
screen is gone. The new React admin UI has five tabs:
trustProxyHeader + trustedProxyCidrs, recidiveenabled, maxRetry,findTimeSeconds, banTimeSeconds).The 2.x endpoint bruteForceLoginProtectionSettings with fields
nb_failed_login_max / time_to_idle has been removed. Use:
bruteForceLoginProtectionGlobalSettings for the global config.There is no compatibility shim — clients of the 2.x GraphQL API must be
updated.
nb_failed_login_max / time_to_idlebruteForceLoginProtectionSettings isbruteForceLoginProtectionGlobalSettings + jails.ConfigurationAdmin<jahia-var>/karaf/etc/). Existing v3 snapshot installs must alsotrustProxyHeader alone no longer trusts X-Forwarded-For —trustedProxyCidrs must list the legitimate front proxies.Import-Package is now required (see Prerequisites).Jahia cluster mode must be enabled — the bundle imports
com.hazelcast.* packages that Jahia only exports when clustering is
active.
cluster.activated=true indigital-factory-config/jahia/jahia.node.properties, then restart.CLUSTER_ENABLED=true.A single-node "cluster of one" is fine for dev/test. Without cluster
mode the bundle will fail to resolve with:
Unable to resolve brute-force-login-protection:
missing requirement osgi.wiring.package=com.hazelcast.core
In a real cluster, OSGi ConfigurationAdmin storage is per-node — rely
on Karaf Cellar (shipped with Jahia clustering) to replicate
karaf/etc/, or mount a shared digital-factory-data/karaf/etc/. The
module itself does not broadcast settings over Hazelcast.
yyyy/MM/dd).X-BFLP-Signature HMAC-SHA256MessageDigest.isEqual in Java, hmac.compare_digest in Python,crypto.timingSafeEqual in Node.js).Full Changelog: 2_0_2...3_0_0
Go to Administration → Server settings → Configuration → Brute force login protection.
X-Forwarded-For, recidive factor, max ban time, audit log size.enabled, maxRetry, findTimeSeconds, banTimeSeconds.Global settings and jail definitions are persisted as OSGi configuration under <jahia-var>/karaf/etc/. Bans and the audit log remain JCR-backed (they are runtime state, not configuration).
| Kind | PID | Example filename |
|---|---|---|
| Global settings (singleton) | org.jahia.modules.bruteforceloginprotection.global |
org.jahia.modules.bruteforceloginprotection.global.cfg |
| Jail definition (factory) | org.jahia.modules.bruteforceloginprotection.jail |
org.jahia.modules.bruteforceloginprotection.jail-login.cfg |
A jail .cfg MUST contain a name=<jail-id> property — that string is the jail identifier seen by the engine and the admin UI. The filename discriminator after jail- is only used by Felix to uniquely key the configuration on disk.
Operator-pasted plaintext is supported for webhook_secret: the module re-encrypts it on the next save (prefix {enc}). The GraphQL mutation honours the same tri-state contract as before — null keeps the stored secret, "" clears it, any other value replaces it.
Default example .cfg files are shipped under src/main/resources/META-INF/configurations/ and are dropped into karaf/etc/ on first deploy.
OSGi ConfigurationAdmin storage is per-node. In a Jahia cluster you must either:
karaf/etc/ across nodes (the default for clusters already running this module — Cellar is shipped with Jahia clustering), ordigital-factory-data/karaf/etc/ directory.No additional Hazelcast broadcasting of settings is performed by the module.
When verifying the X-BFLP-Signature header on the receiver side, always use a constant-time comparison to prevent timing attacks on the HMAC. Concretely:
java.security.MessageDigest.isEqual(expectedBytes, providedBytes)hmac.compare_digest(expected, provided)crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(provided))The webhook URL is validated server-side to prevent SSRF: only https:// is accepted by default, private/loopback/link-local/multicast targets and the AWS metadata IP are rejected. Set -Dbflp.webhook.allowHttp=true to allow plain http:// for trusted on-premise receivers.
trustProxyHeader alone is no longer sufficient to honour X-Forwarded-For: in addition, the remote socket address of the incoming request must match one of the CIDR entries configured in trustedProxyCidrs. If the list is empty while the flag is on, the module logs a one-time warning and falls back to the raw socket address.
Configure Jahia's mail server settings to receive notification emails.
The auth pipeline valve dispatches every authenticated request through an ordered chain of AuthFailureDetector services. A custom module that wants to track a new authentication scheme just needs to register one:
@Component(service = AuthFailureDetector.class, immediate = true)
public class MySchemeFailureDetector implements AuthFailureDetector {
@Override
public FailureSignal detect(AuthFailureContext context) {
if (context.isAuthenticated()) return null;
String header = context.getRequest().getHeader("X-My-Scheme");
if (header == null) return null;
return FailureSignal.builder("my-scheme-valve")
.extra("authScheme", "my-scheme")
.build();
}
@Override
public int order() { return 50; } // run before built-ins (100-400)
}
Built-in detector orders: form login = 100, HTTP Basic = 200, APIToken = 300, legacy jahiatoken = 400. Use < 100 to pre-empt a built-in or > 1000 to act as a fallback. The valve breaks on the first non-null FailureSignal so each request records at most one failure event.
Never copy bearer-token strings into FailureSignal.username — usernames land in the audit log.
v3.0.0 is a breaking change. Both the JCR settings schema and the GraphQL schema were rewritten — there is no automatic migration. After upgrading the bundle, settings must be re-entered from the admin UI. The old nb_failed_login_max / time_to_idle / bruteForceLoginProtectionSettings GraphQL endpoint no longer exists; use jails and bruteForceLoginProtectionGlobalSettings instead.
In addition, starting from v3.0.0-SNAPSHOT the global settings and jail definitions moved out of JCR and into OSGi ConfigurationAdmin (see Where settings are stored above). Existing in-flight v3 snapshot installs must re-enter their settings — JCR values are no longer read.
brute-force-login-protection-X.X.X.jarMIT License
Copyright (c) 2019 - present Florent BOURASSÉ
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.