fix(security): harden auth reset, rate limiter fallback, and CI secrets

- Move CI_AUTH_SECRET from plaintext to ${{ secrets.CI_AUTH_SECRET }}
- Wrap password reset (update + session kill + token mark) in $transaction
  to prevent stale sessions on partial failure (CWE-613)
- Rate limiter Redis fallback now uses stricter degraded limits
  (maxRequests/10) and logs at error level instead of warn

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-11 08:03:42 +02:00
parent 98c2554570
commit 110e4ff1aa
5 changed files with 78 additions and 53 deletions
+38 -25
View File
@@ -37,13 +37,19 @@ const DEFAULT_REDIS_URL = process.env["REDIS_URL"]?.trim();
const warnedRedisFailures = new Set<string>();
let sharedRedisClient: Redis | null = null;
function getBackendMode(
requestedBackend: RateLimitBackendMode | undefined,
): RateLimitBackendMode {
if (requestedBackend === "memory" || requestedBackend === "redis" || requestedBackend === "auto") {
function getBackendMode(requestedBackend: RateLimitBackendMode | undefined): RateLimitBackendMode {
if (
requestedBackend === "memory" ||
requestedBackend === "redis" ||
requestedBackend === "auto"
) {
return requestedBackend;
}
if (DEFAULT_REDIS_BACKEND === "memory" || DEFAULT_REDIS_BACKEND === "redis" || DEFAULT_REDIS_BACKEND === "auto") {
if (
DEFAULT_REDIS_BACKEND === "memory" ||
DEFAULT_REDIS_BACKEND === "redis" ||
DEFAULT_REDIS_BACKEND === "auto"
) {
return DEFAULT_REDIS_BACKEND;
}
return "auto";
@@ -69,10 +75,7 @@ function getRedisClient(redisUrl: string): Redis {
return sharedRedisClient;
}
function createMemoryBackend(
windowMs: number,
maxRequests: number,
): RateLimiterBackend {
function createMemoryBackend(windowMs: number, maxRequests: number): RateLimiterBackend {
const store = new Map<string, RateLimitEntry>();
const cleanupInterval = setInterval(() => {
const now = Date.now();
@@ -127,7 +130,7 @@ function createRedisBackend(
async function runRedisCheck(key: string): Promise<RateLimitResult> {
const client = getRedisClient(options.redisUrl);
const redisKey = `${redisKeyPrefix}:${key}`;
const result = await client.eval(
const result = (await client.eval(
`
local current = redis.call("INCR", KEYS[1])
local ttl = redis.call("PTTL", KEYS[1])
@@ -140,7 +143,7 @@ function createRedisBackend(
1,
redisKey,
String(windowMs),
) as [number | string, number | string];
)) as [number | string, number | string];
const count = Number(result[0]);
const ttlMs = Math.max(0, Number(result[1]));
@@ -156,13 +159,7 @@ function createRedisBackend(
const matchPattern = `${redisKeyPrefix}:*`;
let cursor = "0";
do {
const [nextCursor, keys] = await client.scan(
cursor,
"MATCH",
matchPattern,
"COUNT",
100,
);
const [nextCursor, keys] = await client.scan(cursor, "MATCH", matchPattern, "COUNT", 100);
cursor = nextCursor;
if (keys.length > 0) {
await client.del(...keys);
@@ -177,9 +174,9 @@ function createRedisBackend(
} catch (error) {
if (!warnedRedisFailures.has(warningKey)) {
warnedRedisFailures.add(warningKey);
logger.warn(
logger.error(
{ err: error, redisUrl: options.redisUrl, limiter: options.name },
"Rate limiter Redis backend unavailable, falling back to in-memory counters",
"Rate limiter Redis backend unavailable, falling back to degraded in-memory counters",
);
}
throw error;
@@ -208,9 +205,19 @@ export function createRateLimiter(
const redisUrl = options.redisUrl?.trim() || DEFAULT_REDIS_URL;
const keyPrefix = options.keyPrefix ?? DEFAULT_REDIS_KEY_PREFIX;
const shouldUseRedis = backendMode === "redis" || (backendMode === "auto" && Boolean(redisUrl));
const redisBackend = shouldUseRedis && redisUrl
? createRedisBackend(windowMs, maxRequests, { name, redisUrl, keyPrefix })
: null;
const redisBackend =
shouldUseRedis && redisUrl
? createRedisBackend(windowMs, maxRequests, { name, redisUrl, keyPrefix })
: null;
// When Redis is unavailable, apply a stricter limit to compensate for
// per-node isolation (each process keeps independent in-memory counters,
// so the effective cluster-wide limit is maxRequests × nodeCount).
const degradedMemoryBackend = createMemoryBackend(
windowMs,
Math.max(1, Math.floor(maxRequests / 10)),
);
let redisDegraded = false;
const check = (async (key: string) => {
const normalizedKey = key.trim().toLowerCase();
@@ -227,9 +234,15 @@ export function createRateLimiter(
}
try {
return await redisBackend.check(normalizedKey);
const result = await redisBackend.check(normalizedKey);
if (redisDegraded) {
redisDegraded = false;
logger.info({ limiter: name }, "Rate limiter Redis backend recovered");
}
return result;
} catch {
return memoryBackend.check(normalizedKey);
redisDegraded = true;
return degradedMemoryBackend.check(normalizedKey);
}
}) as RateLimiter;