435c871e1f
#28 - TOTP rate limiting (verifyTotp): added totpRateLimiter (10 req/30s), throws TOO_MANY_REQUESTS before DB hit; 16 unit tests including rate-limit exceeded + userId key isolation. #29 - /api/reports/allocations role check: only ADMIN/MANAGER/CONTROLLER may access; returns 403 otherwise; 9 unit tests (401 unauthenticated, 403 for USER/VIEWER, 200 for allowed roles + xlsx format). #31 - pgAdmin credentials moved out of docker-compose.yml into env vars; PGADMIN_PASSWORD is now required (:?) to prevent accidental plaintext exposure in committed files. #34 - Server-side HTML sanitization for comment bodies via stripHtml(): strips all tags + decodes safe entities before persistence; 16 unit tests covering passthrough, injection patterns, entity decoding. #35 - MFA setup prompt banner (MfaPromptBanner): shown to ADMIN/MANAGER users without TOTP enabled; user-scoped localStorage snooze (7 days); links to /account/security; accessibility role=alert; 7 structural unit tests. #33 - Auth anomaly alerting cron (/api/cron/auth-anomaly-check): detects HIGH_GLOBAL_FAILURE_RATE and CONCENTRATED_FAILURES in 30-minute window; CRITICAL notification to ADMINs; fail-closed via verifyCronSecret; 10 unit tests. #32 - MFA enforcement policy: added requireMfaForRoles field to SystemSettings schema + Prisma migration; auth.ts blocks login with MFA_REQUIRED_SETUP signal if role is enforced but TOTP not set up; signin page redirects to /account/security?mfa_required=1; settings schema + view model updated; 11 unit tests. #30 - API keys architecture decision documented in LEARNINGS.md; no code written — product decision required before implementation. Co-Authored-By: claude-flow <ruv@ruv.net>
265 lines
7.3 KiB
TypeScript
265 lines
7.3 KiB
TypeScript
import { Redis } from "ioredis";
|
|
import { logger } from "../lib/logger.js";
|
|
|
|
export interface RateLimitResult {
|
|
allowed: boolean;
|
|
remaining: number;
|
|
resetAt: Date;
|
|
}
|
|
|
|
type RateLimitEntry = {
|
|
count: number;
|
|
resetAt: number;
|
|
};
|
|
|
|
type RateLimitBackendMode = "auto" | "memory" | "redis";
|
|
|
|
type CreateRateLimiterOptions = {
|
|
name?: string;
|
|
backend?: RateLimitBackendMode;
|
|
redisUrl?: string;
|
|
keyPrefix?: string;
|
|
};
|
|
|
|
export interface RateLimiter {
|
|
(key: string): Promise<RateLimitResult>;
|
|
reset(): Promise<void>;
|
|
}
|
|
|
|
type RateLimiterBackend = {
|
|
check: (key: string) => Promise<RateLimitResult>;
|
|
reset: () => Promise<void>;
|
|
};
|
|
|
|
const DEFAULT_REDIS_KEY_PREFIX = "capakraken:ratelimit";
|
|
const DEFAULT_REDIS_BACKEND = process.env["RATE_LIMIT_BACKEND"] as RateLimitBackendMode | undefined;
|
|
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") {
|
|
return requestedBackend;
|
|
}
|
|
if (DEFAULT_REDIS_BACKEND === "memory" || DEFAULT_REDIS_BACKEND === "redis" || DEFAULT_REDIS_BACKEND === "auto") {
|
|
return DEFAULT_REDIS_BACKEND;
|
|
}
|
|
return "auto";
|
|
}
|
|
|
|
function sanitizeKeySegment(value: string): string {
|
|
return value.replace(/[^a-zA-Z0-9:_-]/g, "_");
|
|
}
|
|
|
|
function getRedisClient(redisUrl: string): Redis {
|
|
if (!sharedRedisClient) {
|
|
sharedRedisClient = new Redis(redisUrl, {
|
|
lazyConnect: false,
|
|
enableReadyCheck: false,
|
|
enableOfflineQueue: false,
|
|
maxRetriesPerRequest: 1,
|
|
commandTimeout: 1000,
|
|
});
|
|
sharedRedisClient.on("error", (error: unknown) => {
|
|
logger.warn({ err: error, redisUrl }, "Rate limiter Redis connection emitted an error");
|
|
});
|
|
}
|
|
return sharedRedisClient;
|
|
}
|
|
|
|
function createMemoryBackend(
|
|
windowMs: number,
|
|
maxRequests: number,
|
|
): RateLimiterBackend {
|
|
const store = new Map<string, RateLimitEntry>();
|
|
const cleanupInterval = setInterval(() => {
|
|
const now = Date.now();
|
|
for (const [key, entry] of store) {
|
|
if (entry.resetAt <= now) {
|
|
store.delete(key);
|
|
}
|
|
}
|
|
}, windowMs);
|
|
|
|
if (cleanupInterval.unref) {
|
|
cleanupInterval.unref();
|
|
}
|
|
|
|
return {
|
|
async check(key: string) {
|
|
const now = Date.now();
|
|
const existing = store.get(key);
|
|
|
|
if (!existing || existing.resetAt <= now) {
|
|
const resetAt = now + windowMs;
|
|
store.set(key, { count: 1, resetAt });
|
|
return {
|
|
allowed: true,
|
|
remaining: maxRequests - 1,
|
|
resetAt: new Date(resetAt),
|
|
};
|
|
}
|
|
|
|
existing.count += 1;
|
|
return {
|
|
allowed: existing.count <= maxRequests,
|
|
remaining: Math.max(0, maxRequests - existing.count),
|
|
resetAt: new Date(existing.resetAt),
|
|
};
|
|
},
|
|
|
|
async reset() {
|
|
store.clear();
|
|
},
|
|
};
|
|
}
|
|
|
|
function createRedisBackend(
|
|
windowMs: number,
|
|
maxRequests: number,
|
|
options: Required<Pick<CreateRateLimiterOptions, "name" | "redisUrl" | "keyPrefix">>,
|
|
): RateLimiterBackend {
|
|
const redisKeyPrefix = `${options.keyPrefix}:${sanitizeKeySegment(options.name)}`;
|
|
const warningKey = `${options.name}:${options.redisUrl}`;
|
|
|
|
async function runRedisCheck(key: string): Promise<RateLimitResult> {
|
|
const client = getRedisClient(options.redisUrl);
|
|
const redisKey = `${redisKeyPrefix}:${key}`;
|
|
const result = await client.eval(
|
|
`
|
|
local current = redis.call("INCR", KEYS[1])
|
|
local ttl = redis.call("PTTL", KEYS[1])
|
|
if ttl < 0 then
|
|
redis.call("PEXPIRE", KEYS[1], ARGV[1])
|
|
ttl = tonumber(ARGV[1])
|
|
end
|
|
return {current, ttl}
|
|
`,
|
|
1,
|
|
redisKey,
|
|
String(windowMs),
|
|
) as [number | string, number | string];
|
|
|
|
const count = Number(result[0]);
|
|
const ttlMs = Math.max(0, Number(result[1]));
|
|
return {
|
|
allowed: count <= maxRequests,
|
|
remaining: Math.max(0, maxRequests - count),
|
|
resetAt: new Date(Date.now() + ttlMs),
|
|
};
|
|
}
|
|
|
|
async function resetRedisKeys(): Promise<void> {
|
|
const client = getRedisClient(options.redisUrl);
|
|
const matchPattern = `${redisKeyPrefix}:*`;
|
|
let cursor = "0";
|
|
do {
|
|
const [nextCursor, keys] = await client.scan(
|
|
cursor,
|
|
"MATCH",
|
|
matchPattern,
|
|
"COUNT",
|
|
100,
|
|
);
|
|
cursor = nextCursor;
|
|
if (keys.length > 0) {
|
|
await client.del(...keys);
|
|
}
|
|
} while (cursor !== "0");
|
|
}
|
|
|
|
return {
|
|
async check(key: string) {
|
|
try {
|
|
return await runRedisCheck(key);
|
|
} catch (error) {
|
|
if (!warnedRedisFailures.has(warningKey)) {
|
|
warnedRedisFailures.add(warningKey);
|
|
logger.warn(
|
|
{ err: error, redisUrl: options.redisUrl, limiter: options.name },
|
|
"Rate limiter Redis backend unavailable, falling back to in-memory counters",
|
|
);
|
|
}
|
|
throw error;
|
|
}
|
|
},
|
|
|
|
async reset() {
|
|
await resetRedisKeys();
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Creates a rate limiter.
|
|
* Uses a Redis-backed shared counter when `REDIS_URL` is configured (or `backend: "redis"` is selected),
|
|
* and falls back to in-memory counters when Redis is unavailable or intentionally disabled.
|
|
*/
|
|
export function createRateLimiter(
|
|
windowMs: number,
|
|
maxRequests: number,
|
|
options: CreateRateLimiterOptions = {},
|
|
): RateLimiter {
|
|
const name = options.name ?? `window-${windowMs}-max-${maxRequests}`;
|
|
const memoryBackend = createMemoryBackend(windowMs, maxRequests);
|
|
const backendMode = getBackendMode(options.backend);
|
|
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 check = (async (key: string) => {
|
|
const normalizedKey = key.trim().toLowerCase();
|
|
if (!normalizedKey) {
|
|
return {
|
|
allowed: true,
|
|
remaining: maxRequests,
|
|
resetAt: new Date(Date.now() + windowMs),
|
|
};
|
|
}
|
|
|
|
if (!redisBackend) {
|
|
return memoryBackend.check(normalizedKey);
|
|
}
|
|
|
|
try {
|
|
return await redisBackend.check(normalizedKey);
|
|
} catch {
|
|
return memoryBackend.check(normalizedKey);
|
|
}
|
|
}) as RateLimiter;
|
|
|
|
check.reset = async () => {
|
|
await memoryBackend.reset();
|
|
if (redisBackend) {
|
|
try {
|
|
await redisBackend.reset();
|
|
} catch {
|
|
// Ignore Redis reset errors; tests and local fallback must remain usable.
|
|
}
|
|
}
|
|
};
|
|
|
|
return check;
|
|
}
|
|
|
|
/** General API rate limiter: 100 requests per 15 minutes per user. */
|
|
export const apiRateLimiter = createRateLimiter(15 * 60 * 1000, 100, {
|
|
name: "api",
|
|
});
|
|
|
|
/** Auth rate limiter: 5 attempts per 15 minutes per login identifier. */
|
|
export const authRateLimiter = createRateLimiter(15 * 60 * 1000, 5, {
|
|
name: "auth",
|
|
});
|
|
|
|
/** TOTP verification rate limiter: 10 attempts per 30 seconds per userId.
|
|
* Applied to the public verifyTotp endpoint to prevent brute-force of 6-digit codes. */
|
|
export const totpRateLimiter = createRateLimiter(30 * 1000, 10, {
|
|
name: "totp-verify",
|
|
});
|