Files
sonum/server/rateLimit.ts

115 lines
3.4 KiB
TypeScript

/**
* Limiteur à fenêtre glissante conçu pour l'instance Docker SONUM.
*
* Les clés sont conservées uniquement pendant leur fenêtre d'observation ; le
* nettoyage périodique évite une croissance non bornée lors de tentatives
* distribuées. Il ne stocke ni mot de passe ni contenu de message.
*/
export type RateLimitRule = {
scope: string;
maxAttempts: number;
windowMs: number;
};
export type RateLimitResult =
| { allowed: true; remaining: number }
| { allowed: false; retryAfterMs: number };
type Entry = {
timestamps: number[];
windowMs: number;
};
export class SlidingWindowRateLimiter {
private readonly entries = new Map<string, Entry>();
private lastCleanupAt = 0;
consume(rule: RateLimitRule, key: string, now = Date.now()): RateLimitResult {
this.cleanup(now);
const entryKey = `${rule.scope}:${key}`;
const cutoff = now - rule.windowMs;
const entry = this.entries.get(entryKey) ?? { timestamps: [], windowMs: rule.windowMs };
entry.timestamps = entry.timestamps.filter((timestamp) => timestamp > cutoff);
entry.windowMs = rule.windowMs;
if (entry.timestamps.length >= rule.maxAttempts) {
this.entries.set(entryKey, entry);
return {
allowed: false,
retryAfterMs: Math.max(1, entry.timestamps[0] + rule.windowMs - now),
};
}
entry.timestamps.push(now);
this.entries.set(entryKey, entry);
return { allowed: true, remaining: rule.maxAttempts - entry.timestamps.length };
}
/** Réinitialise exclusivement la clé concernée après une authentification réussie. */
reset(rule: RateLimitRule, key: string) {
this.entries.delete(`${rule.scope}:${key}`);
}
clear() {
this.entries.clear();
this.lastCleanupAt = 0;
}
private cleanup(now: number) {
// Une purge par minute est suffisante et évite un parcours de Map à chaque requête.
if (now - this.lastCleanupAt < 60_000) return;
this.lastCleanupAt = now;
this.entries.forEach((entry, entryKey) => {
const cutoff = now - entry.windowMs;
entry.timestamps = entry.timestamps.filter((timestamp) => timestamp > cutoff);
if (!entry.timestamps.length) this.entries.delete(entryKey);
});
}
}
export const rateLimiter = new SlidingWindowRateLimiter();
export const LOGIN_IDENTIFIER_RULE: RateLimitRule = {
scope: "login-identifier",
maxAttempts: 5,
windowMs: 15 * 60_000,
};
export const LOGIN_IP_RULE: RateLimitRule = {
scope: "login-ip",
maxAttempts: 20,
windowMs: 15 * 60_000,
};
export const CHANNEL_MESSAGE_RULE: RateLimitRule = {
scope: "channel-message",
maxAttempts: 30,
windowMs: 60_000,
};
export const CONTACT_REQUEST_RULE: RateLimitRule = {
scope: "contact-request",
maxAttempts: 5,
windowMs: 60 * 60_000,
};
export const INTRODUCTION_REQUEST_RULE: RateLimitRule = {
scope: "introduction-request",
maxAttempts: 5,
windowMs: 60 * 60_000,
};
/**
* Traefik renseigne `x-forwarded-for`; le premier maillon représente le client.
* En développement ou sans proxy, une clé neutre est utilisée plutôt qu'une IP
* non fiable issue d'un type Express implicite.
*/
export function getClientIp(headers: Record<string, string | string[] | undefined>): string {
const forwarded = headers["x-forwarded-for"];
const rawValue = Array.isArray(forwarded) ? forwarded[0] : forwarded;
const ip = rawValue?.split(",")[0]?.trim();
return ip || "unknown-client";
}