🕯️ fix: Decay Violation Scores With a Configurable TTL (#15153)

This commit is contained in:
Danny Avila 2026-08-24 08:36:21 -04:00 committed by GitHub
parent c52ba4efdb
commit b6e3cf46d2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 116 additions and 3 deletions

View file

@ -677,6 +677,9 @@ BAN_VIOLATIONS=true
BAN_DURATION=1000 * 60 * 60 * 2
BAN_INTERVAL=20
# Violation scores expire after this long (in ms) without new violations; 0 = never expire
VIOLATION_SCORE_TTL=1000 * 60 * 60
LOGIN_VIOLATION_SCORE=1
REGISTRATION_VIOLATION_SCORE=1
CONCURRENT_VIOLATION_SCORE=1

View file

@ -15,6 +15,7 @@ describe('cacheConfig', () => {
delete process.env.REDIS_CLUSTER_SAFE_DELETE;
delete process.env.REDIS_PING_INTERVAL;
delete process.env.FORCED_IN_MEMORY_CACHE_NAMESPACES;
delete process.env.VIOLATION_SCORE_TTL;
// Clear module cache
jest.resetModules();
@ -263,4 +264,39 @@ describe('cacheConfig', () => {
expect(cacheConfig.FORCED_IN_MEMORY_CACHE_NAMESPACES).toEqual(['CONFIG_STORE', 'APP_CONFIG']);
});
});
describe('VIOLATION_SCORE_TTL configuration', () => {
test('should default to one hour when not set', async () => {
const { cacheConfig } = await import('../cacheConfig');
expect(cacheConfig.VIOLATION_SCORE_TTL).toBe(3600000);
});
test('should evaluate math expressions from the environment', async () => {
process.env.VIOLATION_SCORE_TTL = '1000 * 60 * 60 * 24';
const { cacheConfig } = await import('../cacheConfig');
expect(cacheConfig.VIOLATION_SCORE_TTL).toBe(86400000);
});
test('should disable expiry when set to 0', async () => {
process.env.VIOLATION_SCORE_TTL = '0';
const { cacheConfig } = await import('../cacheConfig');
expect(cacheConfig.VIOLATION_SCORE_TTL).toBeUndefined();
});
test('should disable expiry for negative values', async () => {
process.env.VIOLATION_SCORE_TTL = '-1000';
const { cacheConfig } = await import('../cacheConfig');
expect(cacheConfig.VIOLATION_SCORE_TTL).toBeUndefined();
});
test('should fall back to the default on invalid input', async () => {
process.env.VIOLATION_SCORE_TTL = 'not-a-duration';
const { cacheConfig } = await import('../cacheConfig');
expect(cacheConfig.VIOLATION_SCORE_TTL).toBe(3600000);
});
});
});

View file

@ -0,0 +1,56 @@
describe('violationCache TTL defaults', () => {
let originalEnv: NodeJS.ProcessEnv;
beforeEach(() => {
originalEnv = { ...process.env };
delete process.env.USE_REDIS;
delete process.env.REDIS_URI;
delete process.env.VIOLATION_SCORE_TTL;
jest.resetModules();
});
afterEach(() => {
process.env = originalEnv;
jest.resetModules();
});
test('applies the default violation score TTL when none is given', async () => {
const { violationCache } = await import('../../cacheFactory');
const cache = violationCache('logins');
expect(cache.opts.ttl).toBe(3600000);
expect(cache.opts.namespace).toBe('violations:logins');
});
test('an explicit TTL overrides the default', async () => {
const { violationCache } = await import('../../cacheFactory');
const cache = violationCache('logins', 60000);
expect(cache.opts.ttl).toBe(60000);
});
test('honors VIOLATION_SCORE_TTL from the environment', async () => {
process.env.VIOLATION_SCORE_TTL = '1000 * 60 * 5';
const { violationCache } = await import('../../cacheFactory');
expect(violationCache('concurrent').opts.ttl).toBe(300000);
});
test('VIOLATION_SCORE_TTL=0 disables expiry', async () => {
process.env.VIOLATION_SCORE_TTL = '0';
const { violationCache } = await import('../../cacheFactory');
expect(violationCache('concurrent').opts.ttl).toBeUndefined();
});
test('expires violation entries once the TTL elapses', async () => {
const { violationCache } = await import('../../cacheFactory');
const cache = violationCache('expiry-check', 500);
await cache.set('user-1', 3);
await expect(cache.get('user-1')).resolves.toBe(3);
await new Promise((resolve) => setTimeout(resolve, 800));
await expect(cache.get('user-1')).resolves.toBeUndefined();
});
});

View file

@ -1,6 +1,6 @@
import { readFileSync, existsSync } from 'fs';
import { logger } from '@librechat/data-schemas';
import { CacheKeys } from 'librechat-data-provider';
import { Time, CacheKeys } from 'librechat-data-provider';
import { math, isEnabled } from '~/utils';
// To ensure that different deployments do not interfere with each other's cache, we use a prefix for the Redis keys.
@ -48,6 +48,12 @@ if (FORCED_IN_MEMORY_CACHE_NAMESPACES.length > 0) {
}
}
// Violation scores expire after this long without new violations; every violation write
// restarts the countdown. Non-positive values disable expiry, restoring the legacy
// accumulate-forever behavior.
const VIOLATION_SCORE_TTL_MS = math(process.env.VIOLATION_SCORE_TTL, Time.ONE_HOUR);
const VIOLATION_SCORE_TTL = VIOLATION_SCORE_TTL_MS > 0 ? VIOLATION_SCORE_TTL_MS : undefined;
/** Helper function to safely read Redis CA certificate from file
* @returns {string|null} The contents of the CA certificate file, or null if not set or on error
*/
@ -105,6 +111,13 @@ const cacheConfig: {
CI: boolean;
DEBUG_MEMORY_CACHE: boolean;
BAN_DURATION: number; // 2 hours
/**
* TTL in ms for violation scores: a score expires after this long without new violations
* (each violation write restarts the countdown). `undefined` from a non-positive
* setting disables expiry so scores accumulate forever.
* @default 3600000 (1 hour)
*/
VIOLATION_SCORE_TTL: number | undefined;
/**
* Number of keys to delete in each batch during Redis DEL operations.
* In cluster mode, keys are deleted individually in parallel chunks to avoid CROSSSLOT errors.
@ -176,6 +189,7 @@ const cacheConfig: {
DEBUG_MEMORY_CACHE: isEnabled(process.env.DEBUG_MEMORY_CACHE),
BAN_DURATION: math(process.env.BAN_DURATION, 7200000), // 2 hours
VIOLATION_SCORE_TTL,
/**
* Number of keys to delete in each batch during Redis DEL operations.

View file

@ -116,10 +116,14 @@ export const tokenConfigCache = (): Keyv =>
* Creates a cache instance for storing violation data.
* Uses a file-based fallback store if Redis is not enabled.
* @param namespace - The cache namespace for violations.
* @param ttl - Time to live for cache entries.
* @param ttl - Time to live for cache entries. Defaults to `cacheConfig.VIOLATION_SCORE_TTL`
* so violation scores decay instead of accumulating forever; each write restarts the countdown.
* @returns Cache instance for violations.
*/
export const violationCache = (namespace: string, ttl?: number): Keyv => {
export const violationCache = (
namespace: string,
ttl: number | undefined = cacheConfig.VIOLATION_SCORE_TTL,
): Keyv => {
return standardCache(`violations:${namespace}`, ttl, violationFile);
};