Commit 96ef0dd8 authored by Kourser's avatar Kourser
Browse files

Plafonnement : une porte de sortie plutôt qu'un interrupteur

Le plafond anti-force-brute est posé par ADRESSE IP. Une cellule de crise, ce
sont des gens dans la même pièce derrière le même routeur : ils n'en partagent
qu'une. Dix tentatives par quart d'heure pour tout un service, quelques fautes
de frappe un lundi matin, et les suivants restaient à la porte quinze minutes —
sans recours, le compteur ne vivant qu'en mémoire du processus.

Un administrateur d'instance peut désormais remettre les compteurs à zéro depuis
la supervision. L'action est tracée au journal.

La protection n'est PAS affaiblie : elle reste identique partout, production
comprise. La désactiver hors production aurait laissé une instance sans
protection le jour où NODE_ENV n'est pas posé — et l'aurait rendue invérifiable
ailleurs, alors que le cahier de test la spécifie. Même raisonnement que
reset-mfa.js : rendre une protection obligatoire sans prévoir sa reprise en
main, c'est préparer une panne.

Les compteurs vivaient DANS le garde, lui-même fourni par quatre modules :
chacun tenait son propre décompte, sans le savoir, et aucune remise à zéro
n'aurait pu les atteindre tous. Ils vivent maintenant dans un service unique ;
le garde n'est plus qu'une règle.

La recette distingue enfin « sans objet » de « n'a pas pu être joué », et sort le
second en évidence sous le bilan. Un cas de sécurité qui ne s'exécute pas en
silence est plus dangereux qu'un cas qui échoue : on lit « tout conforme » et
l'on croit que tout a été vérifié. Le lot instance débloque en sortant, ce qui
éprouve la route et rend au lot MCP le budget dont son cas d'observateur avait
besoin — il tourne pour la première fois en suite complète.

Co-Authored-By: default avatarClaude Opus 5 <noreply@anthropic.com>
parent c959b216
Loading
Loading
Loading
Loading
+9 −0
Changes for SECURITY.md: 9 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -221,6 +221,15 @@ Trois conséquences de sécurité, toutes appliquées côté serveur :
  justement quand on soupçonne une compromission — laissait la session de l'attaquant vivre jusqu'à
  huit heures. Les jetons émis avant ce changement ne portent pas le compteur et restent valides
  jusqu'à leur expiration : un correctif de sécurité ne doit pas déconnecter tout le monde d'un coup.
- **Le plafond par IP se débloque, il ne se désactive pas.** Une cellule de crise est un groupe de
  personnes dans la même pièce, derrière le même routeur : elles partagent **une seule** adresse vue
  par l'application. Dix tentatives par quart d'heure pour tout un service, quelques fautes de frappe
  un lundi matin, et les suivantes sont bloquées — sans recours, le compteur ne vivant qu'en mémoire
  du processus. Un administrateur d'instance peut désormais remettre les compteurs à zéro depuis la
  supervision ; l'action est **tracée** au journal d'instance. La protection reste identique partout,
  production comprise : l'affaiblir selon l'environnement aurait laissé une instance sans protection
  le jour où `NODE_ENV` n'est pas posé. Même raisonnement que `reset-mfa.js` — rendre une protection
  obligatoire sans prévoir sa reprise en main, c'est préparer une panne.
- **Politique de mot de passe unique** (`apps/api/src/auth/password-policy.ts`) : 10 caractères
  minimum, 72 maximum à la définition — au-delà, bcrypt tronque en silence. Elle était recopiée
  dans trois DTO et avait déjà dérivé : la création par un administrateur acceptait 8 caractères.
+2 −0
Changes for apps/api/src/app.module.ts: 2 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -9,6 +9,7 @@ import { TenantStatusModule } from './common/tenant-status.module';
import { SessionEpochModule } from './common/session-epoch.module';
import { AppUrlModule } from './common/app-url.module';
import { StoredFilesModule } from './common/stored-files.module';
import { RateLimitModule } from './common/rate-limit.service';
import { InstanceModule } from './instance/instance.module';
import { BugsModule } from './bugs/bugs.module';
import { OrganisationModule } from './organisation/organisation.module';
@@ -50,6 +51,7 @@ function staticWebModules(): DynamicModule[] {
    SessionEpochModule,
    AppUrlModule,
    StoredFilesModule,
    RateLimitModule,
    PlatformMailModule,
    HealthModule,
    AuthModule,
+12 −17
Changes for apps/api/src/common/rate-limit.guard.ts: 12 added lines, 17 removed lines.
Original line number Diff line number Diff line
@@ -9,7 +9,8 @@ import {
import { Reflector } from '@nestjs/core';
import { createHash } from 'node:crypto';
import type { Request } from 'express';
import { consume, type Bucket, type RateLimitRule } from './rate-limit';
import type { RateLimitRule } from './rate-limit';
import { RateLimitService } from './rate-limit.service';

export const RATE_LIMIT_KEY = 'rate_limit';

@@ -36,9 +37,6 @@ export interface RateLimitMeta extends RateLimitRule {
export const RateLimit = (max: number, windowMs: number, perField?: PerFieldRule) =>
  SetMetadata(RATE_LIMIT_KEY, { max, windowMs, perField } satisfies RateLimitMeta);

/** Nombre de seaux conserves. Au-dela, les plus anciens sont oublies. */
const MAX_BUCKETS = 10_000;

/**
 * Applique la limitation de debit d'une route publique.
 *
@@ -50,9 +48,15 @@ const MAX_BUCKETS = 10_000;
 */
@Injectable()
export class RateLimitGuard implements CanActivate {
  private readonly buckets = new Map<string, Bucket>();

  constructor(private readonly reflector: Reflector) {}
  constructor(
    private readonly reflector: Reflector,
    /**
     * Les compteurs vivent AILLEURS, dans un service global. Ce garde est fourni
     * par quatre modules ; s'il portait son propre `Map`, chacun compterait dans
     * son coin et aucune remise a zero ne pourrait les atteindre tous.
     */
    private readonly compteurs: RateLimitService,
  ) {}

  canActivate(context: ExecutionContext): boolean {
    const rule = this.reflector.get<RateLimitMeta | undefined>(
@@ -87,16 +91,7 @@ export class RateLimitGuard implements CanActivate {

  /** Consomme un jeton du seau `key`, ou leve 429. */
  private enforce(key: string, rule: RateLimitRule): void {
    const res = consume(this.buckets.get(key), Date.now(), rule);

    if (this.buckets.size >= MAX_BUCKETS && !this.buckets.has(key)) {
      // Borne memoire : on evince le plus ancien seau insere (Map conserve
      // l'ordre d'insertion). Grossier, mais suffisant pour un garde-fou.
      const oldest = this.buckets.keys().next().value;
      if (oldest !== undefined) this.buckets.delete(oldest);
    }
    this.buckets.set(key, res.bucket);

    const res = this.compteurs.consume(key, rule);
    if (!res.allowed) {
      throw new HttpException(
        `Trop de tentatives. Réessayez dans ${Math.ceil(res.retryAfterMs / 1000)} s.`,
+41 −0
Changes for apps/api/src/common/rate-limit.service.spec.ts: 41 added lines, 0 removed lines.
Original line number Diff line number Diff line
import { RateLimitService } from './rate-limit.service';

const REGLE = { max: 3, windowMs: 60_000 };

describe('RateLimitService', () => {
  it('laisse passer jusqu’au plafond, puis refuse', () => {
    const s = new RateLimitService();
    for (let i = 0; i < 3; i += 1) expect(s.consume('ip:1', REGLE).allowed).toBe(true);
    const refus = s.consume('ip:1', REGLE);
    expect(refus.allowed).toBe(false);
    expect(refus.retryAfterMs).toBeGreaterThan(0);
  });

  it('compte séparément deux clés', () => {
    const s = new RateLimitService();
    for (let i = 0; i < 3; i += 1) s.consume('ip:1', REGLE);
    expect(s.consume('ip:2', REGLE).allowed).toBe(true);
  });

  it('remet tout à zéro et rend le nombre effacé', () => {
    const s = new RateLimitService();
    s.consume('ip:1', REGLE);
    s.consume('ip:2', REGLE);
    expect(s.reset()).toBe(2);
    expect(s.taille).toBe(0);
  });

  it('rouvre la porte après une remise à zéro', () => {
    // Le cas qui justifie la reprise en main : un service entier derrière un
    // même routeur, bloqué, et un administrateur qui le débloque.
    const s = new RateLimitService();
    for (let i = 0; i < 3; i += 1) s.consume('ip:bureau', REGLE);
    expect(s.consume('ip:bureau', REGLE).allowed).toBe(false);
    s.reset();
    expect(s.consume('ip:bureau', REGLE).allowed).toBe(true);
  });

  it('remettre à zéro un compteur vide ne lève pas', () => {
    expect(new RateLimitService().reset()).toBe(0);
  });
});
+75 −0
Changes for apps/api/src/common/rate-limit.service.ts: 75 added lines, 0 removed lines.
Original line number Diff line number Diff line
import { Global, Injectable, Logger, Module } from '@nestjs/common';
import { consume, type Bucket, type RateLimitRule } from './rate-limit';

/** Nombre de seaux conserves. Au-dela, les plus anciens sont oublies. */
const MAX_BUCKETS = 10_000;

/**
 * Compteurs de plafonnement — un seul jeu pour toute l'application.
 *
 * Ils vivaient DANS le garde. Or `RateLimitGuard` est fourni par quatre modules
 * (auth, instance, account, et les gardes poses a la volee) : chacun avait donc
 * son propre `Map`, sans le savoir. Le comptage restait juste — une route est
 * toujours gardee par la meme instance — mais il n'y avait aucun endroit d'ou
 * remettre l'ensemble a zero.
 *
 * Le service porte l'etat, le garde n'est plus qu'une regle. C'est ce qui rend la
 * reprise en main possible (cf. `reset`).
 */
@Injectable()
export class RateLimitService {
  private readonly logger = new Logger('RateLimit');
  private readonly buckets = new Map<string, Bucket>();

  /**
   * Consomme une tentative. Rend le verdict plutot que de lever : c'est au garde
   * de choisir la forme du refus, pas au compteur.
   */
  consume(key: string, rule: RateLimitRule): { allowed: boolean; retryAfterMs: number } {
    const res = consume(this.buckets.get(key), Date.now(), rule);

    if (this.buckets.size >= MAX_BUCKETS && !this.buckets.has(key)) {
      // Borne memoire : on evince le plus ancien seau insere (Map conserve
      // l'ordre d'insertion). Grossier, mais suffisant pour un garde-fou.
      const oldest = this.buckets.keys().next().value;
      if (oldest !== undefined) this.buckets.delete(oldest);
    }
    this.buckets.set(key, res.bucket);
    return { allowed: res.allowed, retryAfterMs: res.retryAfterMs };
  }

  /**
   * Remet les compteurs a zero. Rend le nombre de seaux effaces.
   *
   * POURQUOI CETTE REPRISE EN MAIN EXISTE. Le plafond est pose par ADRESSE IP, et
   * une cellule de crise est un groupe de gens dans la meme piece, derriere le
   * meme routeur : ils partagent une seule adresse vue par l'application. Dix
   * tentatives par quart d'heure pour tout un service, quelques fautes de frappe
   * un lundi matin, et les suivants sont bloques — sans que personne puisse rien
   * y faire, le compteur ne vivant qu'en memoire du processus.
   *
   * Meme raisonnement que `reset-mfa.js` : rendre une protection obligatoire sans
   * prevoir sa reprise en main, c'est preparer une panne. La protection n'est pas
   * affaiblie — elle est identique partout, y compris hors production — elle a
   * seulement une porte de sortie, tracee.
   */
  reset(): number {
    const efface = this.buckets.size;
    this.buckets.clear();
    this.logger.warn(`Plafonnement remis a zero : ${efface} compteur(s) efface(s)`);
    return efface;
  }

  /** Nombre de compteurs actifs, pour la supervision. */
  get taille(): number {
    return this.buckets.size;
  }
}

/** Global : le garde est pose sur des routes de plusieurs modules. */
@Global()
@Module({
  providers: [RateLimitService],
  exports: [RateLimitService],
})
export class RateLimitModule {}
Loading