From 813b919976e44d0aa0870fbc25aa6d0fd9627fcc Mon Sep 17 00:00:00 2001 From: sara abodeeb Date: Tue, 14 Apr 2026 04:54:23 +0200 Subject: [PATCH 1/6] feat(auth): implement smart rate-limiting and anomaly aggregation for login failures --- .../app/authentication/server/hooks/login.ts | 9 ++- .../server/lib/LoginAnomalyAggregator.ts | 80 +++++++++++++++++++ 2 files changed, 88 insertions(+), 1 deletion(-) create mode 100644 apps/meteor/app/authentication/server/lib/LoginAnomalyAggregator.ts diff --git a/apps/meteor/app/authentication/server/hooks/login.ts b/apps/meteor/app/authentication/server/hooks/login.ts index 191639e208dee..c3b8e6f65cb40 100644 --- a/apps/meteor/app/authentication/server/hooks/login.ts +++ b/apps/meteor/app/authentication/server/hooks/login.ts @@ -5,6 +5,7 @@ import { settings } from '../../../settings/server'; import type { ILoginAttempt } from '../ILoginAttempt'; import { logFailedLoginAttempts } from '../lib/logLoginAttempts'; import { saveFailedLoginAttempts, saveSuccessfulLogin } from '../lib/restrictLoginAttempts'; +import { anomalyAggregator } from '../lib/LoginAnomalyAggregator'; const ignoredErrorTypes = ['totp-required', 'error-login-blocked-for-user']; @@ -18,7 +19,13 @@ Accounts.onLoginFailure(async (login: ILoginAttempt) => { await saveFailedLoginAttempts(login); } - logFailedLoginAttempts(login); +logFailedLoginAttempts(login); + const ip = login.connection.clientAddress; + const userId = login.user?._id; + + if (ip) { + anomalyAggregator.logFailure(ip, userId); + } }); callbacks.add('afterValidateLogin', (login: ILoginAttempt) => { diff --git a/apps/meteor/app/authentication/server/lib/LoginAnomalyAggregator.ts b/apps/meteor/app/authentication/server/lib/LoginAnomalyAggregator.ts new file mode 100644 index 0000000000000..d6233413d9c13 --- /dev/null +++ b/apps/meteor/app/authentication/server/lib/LoginAnomalyAggregator.ts @@ -0,0 +1,80 @@ + +interface FailedAttemptInfo { + ip: string; + userId?: string; + count: number; + firstAttempt: Date; + lastAttempt: Date; + timeoutId?: NodeJS.Timeout; +} + +class LoginAnomalyAggregator { + + private buffer: Map = new Map(); + + + private readonly FLUSH_WINDOW_MS = 45 * 1000; + + + public logFailure(ip: string, userId?: string): void { + const key = `${ip}_${userId || 'unknown'}`; + const now = new Date(); + + if (this.buffer.has(key)) { + + const entry = this.buffer.get(key)!; + entry.count += 1; + entry.lastAttempt = now; + + + if (entry.timeoutId) { + clearTimeout(entry.timeoutId); + } + + entry.timeoutId = setTimeout(() => this.flushEvent(key), this.FLUSH_WINDOW_MS); + } else { + + const entry: FailedAttemptInfo = { + ip, + userId, + count: 1, + firstAttempt: now, + lastAttempt: now, + }; + + entry.timeoutId = setTimeout(() => this.flushEvent(key), this.FLUSH_WINDOW_MS); + this.buffer.set(key, entry); + } + } + + + private async flushEvent(key: string): Promise { + const entry = this.buffer.get(key); + if (!entry) return; + + + this.buffer.delete(key); + + + if (entry.count > 3) { + await this.recordAnomaly(entry); + } + } + + +private async recordAnomaly(entry: Omit): Promise { + const severity = entry.count > 20 ? 'high' : 'medium'; + const durationInSeconds = Math.round((entry.lastAttempt.getTime() - entry.firstAttempt.getTime()) / 1000); + + console.error( + `🔥 [Auth Anomaly Aggregator] ` + + `IP: ${entry.ip} | ` + + `User: ${entry.userId || 'N/A'} | ` + + `Attempts: ${entry.count} | ` + + `Window: ${durationInSeconds}s | ` + + `Severity: ${severity}` + ); + } +} + +export const anomalyAggregator = new LoginAnomalyAggregator(); \ No newline at end of file From caf2e405d5768c56b71bd3391eacc7d72b8671a9 Mon Sep 17 00:00:00 2001 From: sara abodeeb Date: Tue, 14 Apr 2026 05:26:18 +0200 Subject: [PATCH 2/6] fix: refine aggregation logic and address AI reviewer feedback --- .../app/authentication/server/hooks/login.ts | 34 +++++++++---------- .../server/lib/LoginAnomalyAggregator.ts | 31 +++++------------ 2 files changed, 26 insertions(+), 39 deletions(-) diff --git a/apps/meteor/app/authentication/server/hooks/login.ts b/apps/meteor/app/authentication/server/hooks/login.ts index c3b8e6f65cb40..13917953c9c59 100644 --- a/apps/meteor/app/authentication/server/hooks/login.ts +++ b/apps/meteor/app/authentication/server/hooks/login.ts @@ -10,28 +10,28 @@ import { anomalyAggregator } from '../lib/LoginAnomalyAggregator'; const ignoredErrorTypes = ['totp-required', 'error-login-blocked-for-user']; Accounts.onLoginFailure(async (login: ILoginAttempt) => { - // do not save specific failed login attempts - if ( - settings.get('Block_Multiple_Failed_Logins_Enabled') && - login.error?.error && - !ignoredErrorTypes.includes(String(login.error.error)) - ) { - await saveFailedLoginAttempts(login); - } - -logFailedLoginAttempts(login); const ip = login.connection.clientAddress; const userId = login.user?._id; - if (ip) { - anomalyAggregator.logFailure(ip, userId); + if ( + settings.get('Block_Multiple_Failed_Logins_Enabled') && + login.error?.error && + !ignoredErrorTypes.includes(String(login.error.error)) + ) { + await saveFailedLoginAttempts(login); + + if (ip) { + anomalyAggregator.logFailure(ip, userId); + } } + + logFailedLoginAttempts(login); }); callbacks.add('afterValidateLogin', (login: ILoginAttempt) => { - if (!settings.get('Block_Multiple_Failed_Logins_Enabled')) { - return; - } + if (!settings.get('Block_Multiple_Failed_Logins_Enabled')) { + return; + } - return saveSuccessfulLogin(login); -}); + return saveSuccessfulLogin(login); +}); \ No newline at end of file diff --git a/apps/meteor/app/authentication/server/lib/LoginAnomalyAggregator.ts b/apps/meteor/app/authentication/server/lib/LoginAnomalyAggregator.ts index d6233413d9c13..481e4e65a069e 100644 --- a/apps/meteor/app/authentication/server/lib/LoginAnomalyAggregator.ts +++ b/apps/meteor/app/authentication/server/lib/LoginAnomalyAggregator.ts @@ -1,39 +1,30 @@ - interface FailedAttemptInfo { ip: string; userId?: string; count: number; firstAttempt: Date; lastAttempt: Date; - timeoutId?: NodeJS.Timeout; + timeoutId?: any; } class LoginAnomalyAggregator { - private buffer: Map = new Map(); - - - private readonly FLUSH_WINDOW_MS = 45 * 1000; - + private readonly FLUSH_WINDOW_MS = 45 * 1000; + private readonly MAX_BUFFER_SIZE = 1000; public logFailure(ip: string, userId?: string): void { const key = `${ip}_${userId || 'unknown'}`; const now = new Date(); - if (this.buffer.has(key)) { + if (this.buffer.size >= this.MAX_BUFFER_SIZE && !this.buffer.has(key)) { + return; + } + if (this.buffer.has(key)) { const entry = this.buffer.get(key)!; entry.count += 1; entry.lastAttempt = now; - - - if (entry.timeoutId) { - clearTimeout(entry.timeoutId); - } - - entry.timeoutId = setTimeout(() => this.flushEvent(key), this.FLUSH_WINDOW_MS); } else { - const entry: FailedAttemptInfo = { ip, userId, @@ -47,23 +38,19 @@ class LoginAnomalyAggregator { } } - private async flushEvent(key: string): Promise { const entry = this.buffer.get(key); if (!entry) return; - this.buffer.delete(key); - if (entry.count > 3) { await this.recordAnomaly(entry); } } - -private async recordAnomaly(entry: Omit): Promise { - const severity = entry.count > 20 ? 'high' : 'medium'; + private async recordAnomaly(entry: Omit): Promise { + const severity = entry.count > 10 ? 'high' : 'medium'; const durationInSeconds = Math.round((entry.lastAttempt.getTime() - entry.firstAttempt.getTime()) / 1000); console.error( From 5554c576a17aa393352b363c644b90cea176ce6d Mon Sep 17 00:00:00 2001 From: sara abodeeb Date: Tue, 14 Apr 2026 05:36:58 +0200 Subject: [PATCH 3/6] fix: implement LRU-style eviction to prevent buffer blind spots --- .../app/authentication/server/lib/LoginAnomalyAggregator.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/meteor/app/authentication/server/lib/LoginAnomalyAggregator.ts b/apps/meteor/app/authentication/server/lib/LoginAnomalyAggregator.ts index 481e4e65a069e..a103ba06ceed2 100644 --- a/apps/meteor/app/authentication/server/lib/LoginAnomalyAggregator.ts +++ b/apps/meteor/app/authentication/server/lib/LoginAnomalyAggregator.ts @@ -17,7 +17,10 @@ class LoginAnomalyAggregator { const now = new Date(); if (this.buffer.size >= this.MAX_BUFFER_SIZE && !this.buffer.has(key)) { - return; + const oldestKey = this.buffer.keys().next().value; + if (oldestKey) { + this.buffer.delete(oldestKey); + } } if (this.buffer.has(key)) { From 5ec70a208ecad4b18d3d96821299751fcf135ccc Mon Sep 17 00:00:00 2001 From: sara abodeeb Date: Tue, 14 Apr 2026 05:45:39 +0200 Subject: [PATCH 4/6] fix: implement LRU-style eviction to prevent buffer blind spots --- .../server/lib/LoginAnomalyAggregator.ts | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/apps/meteor/app/authentication/server/lib/LoginAnomalyAggregator.ts b/apps/meteor/app/authentication/server/lib/LoginAnomalyAggregator.ts index a103ba06ceed2..94d66cd651fd4 100644 --- a/apps/meteor/app/authentication/server/lib/LoginAnomalyAggregator.ts +++ b/apps/meteor/app/authentication/server/lib/LoginAnomalyAggregator.ts @@ -9,7 +9,9 @@ interface FailedAttemptInfo { class LoginAnomalyAggregator { private buffer: Map = new Map(); + private readonly FLUSH_WINDOW_MS = 45 * 1000; + private readonly MAX_BUFFER_SIZE = 1000; public logFailure(ip: string, userId?: string): void { @@ -19,7 +21,7 @@ class LoginAnomalyAggregator { if (this.buffer.size >= this.MAX_BUFFER_SIZE && !this.buffer.has(key)) { const oldestKey = this.buffer.keys().next().value; if (oldestKey) { - this.buffer.delete(oldestKey); + void this.flushEvent(oldestKey); } } @@ -36,14 +38,22 @@ class LoginAnomalyAggregator { lastAttempt: now, }; - entry.timeoutId = setTimeout(() => this.flushEvent(key), this.FLUSH_WINDOW_MS); + entry.timeoutId = setTimeout(() => { + void this.flushEvent(key); + }, this.FLUSH_WINDOW_MS); this.buffer.set(key, entry); } } private async flushEvent(key: string): Promise { const entry = this.buffer.get(key); - if (!entry) return; + if (!entry) { + return; + } + + if (entry.timeoutId) { + clearTimeout(entry.timeoutId); + } this.buffer.delete(key); @@ -62,7 +72,7 @@ class LoginAnomalyAggregator { `User: ${entry.userId || 'N/A'} | ` + `Attempts: ${entry.count} | ` + `Window: ${durationInSeconds}s | ` + - `Severity: ${severity}` + `Severity: ${severity}`, ); } } From 05db53116274b3f2b2f387f73b0d72d8c5c95055 Mon Sep 17 00:00:00 2001 From: sara abodeeb Date: Tue, 14 Apr 2026 05:57:15 +0200 Subject: [PATCH 5/6] feat: track IP and User independently to detect spray attacks --- .../server/lib/LoginAnomalyAggregator.ts | 33 ++++++++++--------- 1 file changed, 18 insertions(+), 15 deletions(-) diff --git a/apps/meteor/app/authentication/server/lib/LoginAnomalyAggregator.ts b/apps/meteor/app/authentication/server/lib/LoginAnomalyAggregator.ts index 94d66cd651fd4..6bbb76d212052 100644 --- a/apps/meteor/app/authentication/server/lib/LoginAnomalyAggregator.ts +++ b/apps/meteor/app/authentication/server/lib/LoginAnomalyAggregator.ts @@ -1,6 +1,5 @@ interface FailedAttemptInfo { - ip: string; - userId?: string; + key: string; count: number; firstAttempt: Date; lastAttempt: Date; @@ -9,19 +8,27 @@ interface FailedAttemptInfo { class LoginAnomalyAggregator { private buffer: Map = new Map(); - private readonly FLUSH_WINDOW_MS = 45 * 1000; - private readonly MAX_BUFFER_SIZE = 1000; public logFailure(ip: string, userId?: string): void { - const key = `${ip}_${userId || 'unknown'}`; const now = new Date(); + + const keys = [`ip:${ip}`]; + if (userId) { + keys.push(`user:${userId}`); + } + for (const key of keys) { + this.processFailureForKey(key, now); + } + } + + private processFailureForKey(key: string, now: Date): void { if (this.buffer.size >= this.MAX_BUFFER_SIZE && !this.buffer.has(key)) { const oldestKey = this.buffer.keys().next().value; if (oldestKey) { - void this.flushEvent(oldestKey); + void this.flushEvent(oldestKey); } } @@ -31,8 +38,7 @@ class LoginAnomalyAggregator { entry.lastAttempt = now; } else { const entry: FailedAttemptInfo = { - ip, - userId, + key, count: 1, firstAttempt: now, lastAttempt: now, @@ -47,9 +53,7 @@ class LoginAnomalyAggregator { private async flushEvent(key: string): Promise { const entry = this.buffer.get(key); - if (!entry) { - return; - } + if (!entry) return; if (entry.timeoutId) { clearTimeout(entry.timeoutId); @@ -63,16 +67,15 @@ class LoginAnomalyAggregator { } private async recordAnomaly(entry: Omit): Promise { - const severity = entry.count > 10 ? 'high' : 'medium'; + const severity = entry.count >= 10 ? 'high' : 'medium'; const durationInSeconds = Math.round((entry.lastAttempt.getTime() - entry.firstAttempt.getTime()) / 1000); console.error( `🔥 [Auth Anomaly Aggregator] ` + - `IP: ${entry.ip} | ` + - `User: ${entry.userId || 'N/A'} | ` + + `Target: ${entry.key} | ` + `Attempts: ${entry.count} | ` + `Window: ${durationInSeconds}s | ` + - `Severity: ${severity}`, + `Severity: ${severity}` ); } } From ecff4b19f8797311a4773638368178bb2eb72e3d Mon Sep 17 00:00:00 2001 From: sara abodeeb Date: Tue, 14 Apr 2026 06:08:47 +0200 Subject: [PATCH 6/6] chore: improve type safety for timeoutId in LoginAnomalyAggregator --- .../app/authentication/server/lib/LoginAnomalyAggregator.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/meteor/app/authentication/server/lib/LoginAnomalyAggregator.ts b/apps/meteor/app/authentication/server/lib/LoginAnomalyAggregator.ts index 6bbb76d212052..d6df7ec531883 100644 --- a/apps/meteor/app/authentication/server/lib/LoginAnomalyAggregator.ts +++ b/apps/meteor/app/authentication/server/lib/LoginAnomalyAggregator.ts @@ -3,7 +3,7 @@ interface FailedAttemptInfo { count: number; firstAttempt: Date; lastAttempt: Date; - timeoutId?: any; + timeoutId?: ReturnType; } class LoginAnomalyAggregator {