You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@ranger.apache.org by ra...@apache.org on 2026/07/24 14:48:47 UTC

(ranger) branch ranger-2.9 updated: RANGER-5690: UnixAuth service lacks rate limiting on authentication attempts (#1106) (#1108)

This is an automated email from the ASF dual-hosted git repository.

ramackri pushed a commit to branch ranger-2.9
in repository https://gitbox.apache.org/repos/asf/ranger.git


The following commit(s) were added to refs/heads/ranger-2.9 by this push:
     new f229b5ecb RANGER-5690: UnixAuth service lacks rate limiting on authentication attempts (#1106) (#1108)
f229b5ecb is described below

commit f229b5ecb17714bb4c8cf3dff6bc6c76a6e8736b
Author: Vyom Mani Tiwari <vy...@gmail.com>
AuthorDate: Fri Jul 24 20:18:43 2026 +0530

    RANGER-5690: UnixAuth service lacks rate limiting on authentication attempts (#1106) (#1108)
    
    (cherry picked from commit f133c2138963fbbff22a93965567db0021e3290d)
---
 .../ranger/authentication/LoginAttemptTracker.java | 140 ++++++++++++++++--
 .../ranger/authentication/PasswordValidator.java   |  37 +++--
 .../authentication/UnixAuthenticationService.java  |  14 +-
 .../authentication/LoginAttemptTrackerTest.java    | 160 +++++++++++++++++++++
 .../authentication/TestPasswordValidator.java      |   9 +-
 5 files changed, 334 insertions(+), 26 deletions(-)

diff --git a/unixauthservice/src/main/java/org/apache/ranger/authentication/LoginAttemptTracker.java b/unixauthservice/src/main/java/org/apache/ranger/authentication/LoginAttemptTracker.java
index b448a7b19..073bcb137 100644
--- a/unixauthservice/src/main/java/org/apache/ranger/authentication/LoginAttemptTracker.java
+++ b/unixauthservice/src/main/java/org/apache/ranger/authentication/LoginAttemptTracker.java
@@ -21,78 +21,196 @@
 
 import java.util.Iterator;
 import java.util.Map;
+import java.util.Set;
 import java.util.concurrent.ConcurrentHashMap;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.atomic.AtomicInteger;
 
 public class LoginAttemptTracker {
     private static final long CLEANUP_INTERVAL_MS = TimeUnit.MINUTES.toMillis(5);
-    private final ConcurrentHashMap<String, Record> attemptsByIp = new ConcurrentHashMap<>();
+
+    private final ConcurrentHashMap<String, IpRecord> attemptsByIp = new ConcurrentHashMap<>();
+    private final ConcurrentHashMap<String, AccountRecord> attemptsByAccount = new ConcurrentHashMap<>();
 
     private final int maxFailures;
     private final long windowMs;
     private final long lockoutMs;
+
+    private final boolean accountFanoutEnabled;
+    private final int accountDistinctIpThreshold;
+    private final long accountWindowMs;
+    private final long accountBaseDelayMs;
+    private final long accountMaxDelayMs;
+
     private volatile long lastCleanupTime;
 
     public LoginAttemptTracker(int maxFailures, long windowMs, long lockoutMs) {
+        this(maxFailures, windowMs, lockoutMs, false, 0, 0, 0, 0);
+    }
+
+    public LoginAttemptTracker(int maxFailures, long windowMs, long lockoutMs,
+                               boolean accountFanoutEnabled, int accountDistinctIpThreshold,
+                               long accountWindowMs, long accountBaseDelayMs, long accountMaxDelayMs) {
         this.maxFailures = maxFailures;
         this.windowMs = windowMs;
         this.lockoutMs = lockoutMs;
         this.lastCleanupTime = System.currentTimeMillis();
+
+        this.accountFanoutEnabled = accountFanoutEnabled;
+        this.accountDistinctIpThreshold = accountDistinctIpThreshold;
+        this.accountWindowMs = accountWindowMs;
+        this.accountBaseDelayMs = accountBaseDelayMs;
+        this.accountMaxDelayMs = accountMaxDelayMs;
     }
 
     public boolean isBlocked(String sourceIp) {
-        Record record = attemptsByIp.get(sourceIp);
+        IpRecord record = attemptsByIp.get(sourceIp);
+
         if (record == null) {
             return false;
         }
+
         return record.lockedUntil > System.currentTimeMillis();
     }
 
+    public long getAccountDelayMs(String account) {
+        if (!accountFanoutEnabled || account == null || account.isEmpty()) {
+            return 0;
+        }
+
+        AccountRecord record = attemptsByAccount.get(account);
+
+        if (record == null) {
+            return 0;
+        }
+
+        int distinctIpCount;
+
+        synchronized (record) {
+            if (System.currentTimeMillis() - record.windowStart > accountWindowMs) {
+                return 0;
+            }
+
+            distinctIpCount = record.failingIps.size();
+        }
+
+        if (distinctIpCount <= accountDistinctIpThreshold) {
+            return 0;
+        }
+
+        long delay = accountBaseDelayMs * (distinctIpCount - accountDistinctIpThreshold);
+
+        return Math.min(delay, accountMaxDelayMs);
+    }
+
     public void recordFailure(String sourceIp) {
+        recordFailure(sourceIp, null);
+    }
+
+    public void recordFailure(String sourceIp, String account) {
         maybeCleanup();
+        recordIpFailure(sourceIp);
+
+        if (accountFanoutEnabled && account != null && !account.isEmpty()) {
+            recordAccountFailure(account, sourceIp);
+        }
+    }
+
+    private void recordIpFailure(String sourceIp) {
         long now = System.currentTimeMillis();
-        Record record = attemptsByIp.computeIfAbsent(sourceIp, k -> new Record(now));
+        IpRecord record = attemptsByIp.computeIfAbsent(sourceIp, k -> new IpRecord(now));
+
         synchronized (record) {
             if (now - record.windowStart > windowMs) {
                 record.windowStart = now;
                 record.failCount.set(0);
             }
+
             record.lastActivity = now;
+
             int count = record.failCount.incrementAndGet();
+
             if (count >= maxFailures) {
                 record.lockedUntil = now + lockoutMs;
             }
         }
     }
 
+    private void recordAccountFailure(String account, String sourceIp) {
+        long now = System.currentTimeMillis();
+        AccountRecord record = attemptsByAccount.computeIfAbsent(account, k -> new AccountRecord(now));
+
+        synchronized (record) {
+            if (now - record.windowStart > accountWindowMs) {
+                record.windowStart = now;
+                record.failingIps.clear();
+            }
+
+            record.lastActivity = now;
+            record.failingIps.add(sourceIp);
+        }
+    }
+
     public void recordSuccess(String sourceIp) {
+        recordSuccess(sourceIp, null);
+    }
+
+    public void recordSuccess(String sourceIp, String account) {
         attemptsByIp.remove(sourceIp);
+
+        if (accountFanoutEnabled && account != null && !account.isEmpty()) {
+            attemptsByAccount.remove(account);
+        }
     }
 
     private void maybeCleanup() {
         long now = System.currentTimeMillis();
+
         if (now - lastCleanupTime < CLEANUP_INTERVAL_MS) {
             return;
         }
+
         lastCleanupTime = now;
-        long cutoff = now - windowMs;
-        Iterator<Map.Entry<String, Record>> it = attemptsByIp.entrySet().iterator();
-        while (it.hasNext()) {
-            Record record = it.next().getValue();
-            if (record.lastActivity < cutoff && record.lockedUntil <= now) {
-                it.remove();
+        long ipCutoff = now - windowMs;
+        Iterator<Map.Entry<String, IpRecord>> ipIt = attemptsByIp.entrySet().iterator();
+        while (ipIt.hasNext()) {
+            IpRecord record = ipIt.next().getValue();
+
+            if (record.lastActivity < ipCutoff && record.lockedUntil <= now) {
+                ipIt.remove();
+            }
+        }
+
+        if (accountFanoutEnabled) {
+            long accountCutoff = now - accountWindowMs;
+            Iterator<Map.Entry<String, AccountRecord>> acctIt = attemptsByAccount.entrySet().iterator();
+            while (acctIt.hasNext()) {
+                AccountRecord record = acctIt.next().getValue();
+                if (record.lastActivity < accountCutoff) {
+                    acctIt.remove();
+                }
             }
         }
     }
 
-    private static final class Record {
+    private static final class IpRecord {
         final AtomicInteger failCount = new AtomicInteger(0);
         volatile long windowStart;
         volatile long lastActivity;
         volatile long lockedUntil;
 
-        Record(long now) {
+        IpRecord(long now) {
+            this.windowStart = now;
+            this.lastActivity = now;
+        }
+    }
+
+    private static final class AccountRecord {
+        final Set<String> failingIps = ConcurrentHashMap.newKeySet();
+        volatile long windowStart;
+        volatile long lastActivity;
+
+        AccountRecord(long now) {
             this.windowStart = now;
             this.lastActivity = now;
         }
diff --git a/unixauthservice/src/main/java/org/apache/ranger/authentication/PasswordValidator.java b/unixauthservice/src/main/java/org/apache/ranger/authentication/PasswordValidator.java
index 0416ca644..c3925dc72 100644
--- a/unixauthservice/src/main/java/org/apache/ranger/authentication/PasswordValidator.java
+++ b/unixauthservice/src/main/java/org/apache/ranger/authentication/PasswordValidator.java
@@ -83,6 +83,7 @@ public void run() {
             reader = new BufferedReader(new InputStreamReader(client.getInputStream()));
             writer = new PrintWriter(new OutputStreamWriter(client.getOutputStream()));
             String request = reader.readLine();
+            userName = parseUserName(request);
             if (loginAttemptTracker.isBlocked(remoteIp)) {
                 LOG.warn("Rejected UnixAuth attempt from [{}] - source IP is temporarily locked out due to repeated failures.", remoteIp);
                 writer.println(LOCKED_OUT_RESPONSE);
@@ -90,22 +91,26 @@ public void run() {
                 return;
             }
             if (request == null) {
-                loginAttemptTracker.recordFailure(remoteIp);
+                loginAttemptTracker.recordFailure(remoteIp, userName);
                 LOG.warn("Rejected UnixAuth attempt from [{}] - connection closed before sending any data.", remoteIp);
                 writer.println(GENERIC_FAILURE_RESPONSE);
                 writer.flush();
                 return;
             }
-            if (request.startsWith("LOGIN:")) {
-                String line       = request.substring(6).trim();
-                int    passwordAt = line.indexOf(' ');
-                if (passwordAt != -1) {
-                    userName = line.substring(0, passwordAt).trim();
+            long accountDelayMs = loginAttemptTracker.getAccountDelayMs(userName);
+
+            if (accountDelayMs > 0) {
+                LOG.warn("Applying {}ms delay for user [{}] from [{}] - failures against this account recently seen from multiple source IPs.", accountDelayMs, userName, remoteIp);
+
+                try {
+                    Thread.sleep(accountDelayMs);
+                } catch (InterruptedException ie) {
+                    Thread.currentThread().interrupt();
                 }
             }
 
             if (validatorProgram == null) {
-                loginAttemptTracker.recordFailure(remoteIp);
+                loginAttemptTracker.recordFailure(remoteIp, userName);
                 LOG.error("Response [{}] for user: {} from [{}] as ValidatorProgram is not defined in configuration.", GENERIC_FAILURE_RESPONSE, userName, remoteIp);
                 writer.println(GENERIC_FAILURE_RESPONSE);
                 writer.flush();
@@ -126,14 +131,14 @@ public void run() {
                     String res = pReader.readLine();
 
                     if (res != null && res.startsWith("OK")) {
-                        loginAttemptTracker.recordSuccess(remoteIp);
+                        loginAttemptTracker.recordSuccess(remoteIp, userName);
                         if (adminRoleNames != null && adminUserList != null) {
                             if (adminUserList.contains(userName)) {
                                 res = res + " " + adminRoleNames;
                             }
                         }
                     } else {
-                        loginAttemptTracker.recordFailure(remoteIp);
+                        loginAttemptTracker.recordFailure(remoteIp, userName);
                         res = GENERIC_FAILURE_RESPONSE;
                     }
 
@@ -148,7 +153,7 @@ public void run() {
                 }
             }
         } catch (Throwable t) {
-            loginAttemptTracker.recordFailure(remoteIp);
+            loginAttemptTracker.recordFailure(remoteIp, userName);
             if (userName != null && writer != null) {
                 LOG.error("Response [{}] for user: {} from [{}], {}", GENERIC_FAILURE_RESPONSE, userName, remoteIp, t.getMessage());
                 writer.println(GENERIC_FAILURE_RESPONSE);
@@ -166,4 +171,16 @@ public void run() {
             }
         }
     }
+
+    private static String parseUserName(String request) {
+        if (request == null || !request.startsWith("LOGIN:")) {
+            return null;
+        }
+        String line = request.substring(6).trim();
+        int passwordAt = line.indexOf(' ');
+        if (passwordAt == -1) {
+            return null;
+        }
+        return line.substring(0, passwordAt).trim();
+    }
 }
diff --git a/unixauthservice/src/main/java/org/apache/ranger/authentication/UnixAuthenticationService.java b/unixauthservice/src/main/java/org/apache/ranger/authentication/UnixAuthenticationService.java
index 8c9f61238..d6e2511bb 100644
--- a/unixauthservice/src/main/java/org/apache/ranger/authentication/UnixAuthenticationService.java
+++ b/unixauthservice/src/main/java/org/apache/ranger/authentication/UnixAuthenticationService.java
@@ -75,6 +75,11 @@ public class UnixAuthenticationService {
     private static final String UNIXAUTH_LOCKOUT_DURATION_MS_PARAM   = "ranger.usersync.unixauth.lockout.duration.ms";
     private static final String UNIXAUTH_REQUIRE_CLIENT_AUTH_PARAM   = "ranger.usersync.unixauth.require.client.auth";
     private static final String UNIXAUTH_SOCKET_TIMEOUT_MS_PARAM     = "ranger.usersync.unixauth.socket.timeout.ms";
+    private static final String UNIXAUTH_ACCOUNT_FANOUT_ENABLED_PARAM        = "ranger.usersync.unixauth.account.fanout.enabled";
+    private static final String UNIXAUTH_ACCOUNT_DISTINCT_IP_THRESHOLD_PARAM = "ranger.usersync.unixauth.account.distinct.ip.threshold";
+    private static final String UNIXAUTH_ACCOUNT_WINDOW_MS_PARAM             = "ranger.usersync.unixauth.account.window.ms";
+    private static final String UNIXAUTH_ACCOUNT_BASE_DELAY_MS_PARAM         = "ranger.usersync.unixauth.account.base.delay.ms";
+    private static final String UNIXAUTH_ACCOUNT_MAX_DELAY_MS_PARAM          = "ranger.usersync.unixauth.account.max.delay.ms";
 
     private static boolean enableUnixAuth;
 
@@ -333,7 +338,14 @@ private void init() {
         long lockoutDurationMs = Long.parseLong(prop.getProperty(UNIXAUTH_LOCKOUT_DURATION_MS_PARAM, "30000"));
         socketTimeoutMs = Integer.parseInt(prop.getProperty(UNIXAUTH_SOCKET_TIMEOUT_MS_PARAM, "10000"));
 
-        loginAttemptTracker = new LoginAttemptTracker(maxFailedAttempts, attemptWindowMs, lockoutDurationMs);
+        boolean accountFanoutEnabled       = Boolean.parseBoolean(prop.getProperty(UNIXAUTH_ACCOUNT_FANOUT_ENABLED_PARAM, "true"));
+        int     accountDistinctIpThreshold = Integer.parseInt(prop.getProperty(UNIXAUTH_ACCOUNT_DISTINCT_IP_THRESHOLD_PARAM, "3"));
+        long    accountWindowMs            = Long.parseLong(prop.getProperty(UNIXAUTH_ACCOUNT_WINDOW_MS_PARAM, "60000"));
+        long    accountBaseDelayMs         = Long.parseLong(prop.getProperty(UNIXAUTH_ACCOUNT_BASE_DELAY_MS_PARAM, "200"));
+        long    accountMaxDelayMs          = Long.parseLong(prop.getProperty(UNIXAUTH_ACCOUNT_MAX_DELAY_MS_PARAM, "2000"));
+
+        loginAttemptTracker = new LoginAttemptTracker(maxFailedAttempts, attemptWindowMs, lockoutDurationMs, accountFanoutEnabled,
+                accountDistinctIpThreshold, accountWindowMs, accountBaseDelayMs, accountMaxDelayMs);
 
         String requireClientAuthProp = prop.getProperty(UNIXAUTH_REQUIRE_CLIENT_AUTH_PARAM, "false");
         requireClientAuth = requireClientAuthProp.trim().equalsIgnoreCase("true");
diff --git a/unixauthservice/src/test/java/org/apache/ranger/authentication/LoginAttemptTrackerTest.java b/unixauthservice/src/test/java/org/apache/ranger/authentication/LoginAttemptTrackerTest.java
index 3ff5fb9ab..7671d0168 100644
--- a/unixauthservice/src/test/java/org/apache/ranger/authentication/LoginAttemptTrackerTest.java
+++ b/unixauthservice/src/test/java/org/apache/ranger/authentication/LoginAttemptTrackerTest.java
@@ -21,6 +21,7 @@
 
 import org.junit.jupiter.api.Test;
 
+import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 
@@ -97,4 +98,163 @@ public void independentIpsTrackedSeparately() {
         assertTrue(tracker.isBlocked("10.0.0.6"));
         assertFalse(tracker.isBlocked("10.0.0.7"), "a different source IP must not be affected");
     }
+
+    // threshold=3, base delay=200ms, max delay=2000ms, account window=60s
+    private static LoginAttemptTracker newFanoutTracker() {
+        return new LoginAttemptTracker(100, 60_000, 30_000, true, 3, 60_000, 200, 2000);
+    }
+
+    @Test
+    public void fanout_legitimateSingleIpNeverDelayed() {
+        LoginAttemptTracker tracker = newFanoutTracker();
+        // simulate a real admin mistyping their password 5 times from ONE ip
+        for (int i = 0; i < 5; i++) {
+            tracker.recordFailure("10.0.0.1", "alice");
+        }
+        assertEquals(0, tracker.getAccountDelayMs("alice"), "single-IP retries must never trigger an account delay");
+    }
+
+    @Test
+    public void fanout_distinctIpsAcrossThresholdTriggerDelay() {
+        LoginAttemptTracker tracker = newFanoutTracker();
+        tracker.recordFailure("10.0.0.1", "bob");
+        tracker.recordFailure("10.0.0.2", "bob");
+        assertEquals(0, tracker.getAccountDelayMs("bob"), "2 distinct IPs, threshold 3 - no delay yet");
+        tracker.recordFailure("10.0.0.3", "bob");
+        assertEquals(0, tracker.getAccountDelayMs("bob"), "3 distinct IPs, at threshold - still no delay");
+        tracker.recordFailure("10.0.0.4", "bob");
+        assertTrue(tracker.getAccountDelayMs("bob") > 0, "4th distinct IP crosses the threshold - delay should apply");
+    }
+
+    @Test
+    public void fanout_delayScalesLinearlyBeforeCap() {
+        LoginAttemptTracker tracker = newFanoutTracker();
+        String account = "carol";
+        for (int i = 0; i < 4; i++) {
+            tracker.recordFailure("10.0.1." + i, account); // 4 distinct -> 1 over threshold
+        }
+        assertEquals(200, tracker.getAccountDelayMs(account), "4 distinct IPs -> exactly 1x base delay");
+        tracker.recordFailure("10.0.1.4", account); // 5 distinct -> 2 over threshold
+        assertEquals(400, tracker.getAccountDelayMs(account), "5 distinct IPs -> exactly 2x base delay");
+        tracker.recordFailure("10.0.1.5", account); // 6 distinct -> 3 over threshold
+        assertEquals(600, tracker.getAccountDelayMs(account), "6 distinct IPs -> exactly 3x base delay");
+        tracker.recordFailure("10.0.1.6", account); // 7 distinct -> 4 over threshold
+        assertEquals(800, tracker.getAccountDelayMs(account), "7 distinct IPs -> exactly 4x base delay");
+    }
+
+    @Test
+    public void fanout_delayCapsInsteadOfGrowingUnbounded() {
+        LoginAttemptTracker tracker = newFanoutTracker();
+        String account = "carol_capped";
+        for (int i = 0; i < 30; i++) {
+            tracker.recordFailure("10.0.1." + i, account); // well past the threshold
+        }
+        assertEquals(2000, tracker.getAccountDelayMs(account), "delay must cap at accountMaxDelayMs regardless of distinct-IP count");
+    }
+
+    @Test
+    public void fanout_successClearsAccountStateImmediately() {
+        LoginAttemptTracker tracker = newFanoutTracker();
+        String account = "dave";
+        for (int i = 0; i < 5; i++) {
+            tracker.recordFailure("10.0.2." + i, account);
+        }
+        assertTrue(tracker.getAccountDelayMs(account) > 0, "delay should be present before success");
+        tracker.recordSuccess("10.0.2.99", account);
+        assertEquals(0, tracker.getAccountDelayMs(account), "delay must clear immediately after a successful login");
+    }
+
+    @Test
+    public void fanout_windowExpiryResetsAccountState() throws InterruptedException {
+        // short account window so the test runs fast
+        LoginAttemptTracker tracker = new LoginAttemptTracker(100, 60_000, 30_000, true, 2, 80, 200, 2000);
+        String account = "erin";
+        tracker.recordFailure("10.0.3.1", account);
+        tracker.recordFailure("10.0.3.2", account);
+        tracker.recordFailure("10.0.3.3", account); // 3 distinct, over threshold of 2 -> delay
+        assertTrue(tracker.getAccountDelayMs(account) > 0, "delay should be present within the window");
+        Thread.sleep(150); // let the account window expire
+        assertEquals(0, tracker.getAccountDelayMs(account), "delay must clear once the window has expired, even without a success");
+    }
+
+    @Test
+    public void fanout_disabledMeansAlwaysZero() {
+        // fan-out disabled entirely
+        LoginAttemptTracker tracker = new LoginAttemptTracker(100, 60_000, 30_000, false, 1, 60_000, 200, 2000);
+        String account = "frank";
+        for (int i = 0; i < 10; i++) {
+            tracker.recordFailure("10.0.4." + i, account);
+        }
+        assertEquals(0, tracker.getAccountDelayMs(account), "fan-out disabled must mean the delay is always 0, regardless of distinct IPs");
+    }
+
+    @Test
+    public void fanout_ipLockoutStillIndependentAndUnaffected() {
+        LoginAttemptTracker tracker = new LoginAttemptTracker(3, 60_000, 30_000, true, 2, 60_000, 200, 2000);
+        for (int i = 0; i < 3; i++) {
+            tracker.recordFailure("10.0.5.1", "grace"); // same IP, 3 failures -> IP lockout still fires
+        }
+        assertTrue(tracker.isBlocked("10.0.5.1"), "existing per-IP hard lockout must still work unchanged alongside account fan-out tracking");
+    }
+
+    @Test
+    public void fanout_sameIpRetriedManyTimesCountsOnce() {
+        LoginAttemptTracker tracker = newFanoutTracker();
+        String account = "henry";
+        for (int i = 0; i < 20; i++) {
+            tracker.recordFailure("10.0.6.1", account); // SAME ip, 20 times
+        }
+        assertEquals(0, tracker.getAccountDelayMs(account), "20 retries from one IP must never cross the distinct-IP threshold");
+    }
+
+    @Test
+    public void fanout_independentAccountsDoNotCrossContaminate() {
+        LoginAttemptTracker tracker = newFanoutTracker();
+        for (int i = 0; i < 10; i++) {
+            tracker.recordFailure("10.0.8." + i, "jack"); // fan-out attack on "jack"
+        }
+        assertTrue(tracker.getAccountDelayMs("jack") > 0, "the attacked account should be delayed");
+        assertEquals(0, tracker.getAccountDelayMs("kate"), "an unrelated account must be completely unaffected");
+    }
+
+    @Test
+    public void fanout_nullOrEmptyAccountIsSafeNoOp() {
+        LoginAttemptTracker tracker = newFanoutTracker();
+        // simulates PasswordValidator's request==null / unparsable-request path
+        for (int i = 0; i < 10; i++) {
+            tracker.recordFailure("10.0.9." + i); // 1-arg overload, no account at all
+        }
+        assertEquals(0, tracker.getAccountDelayMs(null), "no-account failures must never populate any account delay");
+        assertEquals(0, tracker.getAccountDelayMs(""), "empty-string account must also always be a no-op");
+        tracker.recordFailure("10.0.9.1", "");   // explicit empty string account
+        tracker.recordFailure("10.0.9.1", null); // explicit null account
+        assertEquals(0, tracker.getAccountDelayMs(""), "explicit empty/null account args must never throw and never trigger a delay");
+    }
+
+    @Test
+    public void fanout_concurrentFailuresFromManyIpsAreThreadSafe() throws InterruptedException {
+        // Mirrors PasswordValidator's real threading model: one thread per
+        // connection, all potentially hitting the same account concurrently.
+        LoginAttemptTracker tracker = newFanoutTracker();
+        String account = "concurrent_victim";
+        int threadCount = 50;
+        Thread[] threads = new Thread[threadCount];
+        for (int i = 0; i < threadCount; i++) {
+            final String ip = "10.1.0." + i; // 50 distinct IPs
+            threads[i] = new Thread(() -> tracker.recordFailure(ip, account));
+        }
+        for (Thread t : threads) {
+            t.start();
+        }
+        for (Thread t : threads) {
+            t.join(5000);
+        }
+        // 50 distinct IPs, threshold 3 -> 47 over threshold -> would be 47*200=9400ms
+        // uncapped, so this must land exactly at the configured cap.
+        assertEquals(2000, tracker.getAccountDelayMs(account), "50 concurrent distinct-IP failures must land exactly at the cap, with no race corruption");
+        // an unrelated account, hit concurrently at the same time, must still
+        // read back as unaffected - proves no cross-account leakage under
+        // concurrent access either.
+        assertEquals(0, tracker.getAccountDelayMs("bystander"), "an untouched account must remain unaffected during concurrent activity");
+    }
 }
diff --git a/unixauthservice/src/test/java/org/apache/ranger/authentication/TestPasswordValidator.java b/unixauthservice/src/test/java/org/apache/ranger/authentication/TestPasswordValidator.java
index 90262e4f5..af959fd9f 100644
--- a/unixauthservice/src/test/java/org/apache/ranger/authentication/TestPasswordValidator.java
+++ b/unixauthservice/src/test/java/org/apache/ranger/authentication/TestPasswordValidator.java
@@ -216,13 +216,14 @@ public void test07_failedValidatorResponseRecordsFailureOnTracker() throws Excep
             Runtime rt = mock(Runtime.class);
             runtimeStatic.when(Runtime::getRuntime).thenReturn(rt);
             when(rt.exec("/bin/validator")).thenReturn(process);
+
             new PasswordValidator(socket, tracker).run();
         }
 
         String response = out.toString(StandardCharsets.UTF_8.name()).trim();
         assertEquals("FAILED: Authentication failed.", response);
-        verify(tracker, times(1)).recordFailure(anyString());
-        verify(tracker, never()).recordSuccess(anyString());
+        verify(tracker, times(1)).recordFailure(anyString(), anyString());
+        verify(tracker, never()).recordSuccess(anyString(), anyString());
     }
 
     @Test
@@ -252,8 +253,8 @@ public void test08_okValidatorResponseRecordsSuccessOnTracker() throws Exception
 
         String response = out.toString(StandardCharsets.UTF_8.name()).trim();
         assertEquals("OK", response);
-        verify(tracker, times(1)).recordSuccess(anyString());
-        verify(tracker, never()).recordFailure(anyString());
+        verify(tracker, times(1)).recordSuccess(anyString(), anyString());
+        verify(tracker, never()).recordFailure(anyString(), anyString());
     }
 
     private Socket buildSocket(String requestLine, ByteArrayOutputStream responseOut) throws IOException {