My App

Anti-spam

Implémentation des 5 couches de protection contre l'abus

Le spam est la menace principale du système. Un utilisateur malveillant peut prendre en photo le QR code et spammer des signalements depuis n'importe où. Voir ADR-05 pour le rationnel des choix.

Vue d'ensemble

flowchart TD
    A[Utilisateur scanne QR] --> B{Token valide ?}
    B -->|Non| C[❌ Token expiré]
    B -->|Oui| D{Rate limit IP ?}
    D -->|Dépassé| E[❌ Trop de requêtes]
    D -->|OK| F{Rate limit device ?}
    F -->|Dépassé| G[❌ Trop de requêtes]
    F -->|OK| H{Cooldown actif ?}
    H -->|Oui| I[❌ Réessayez plus tard]
    H -->|OK| J{Pattern suspect ?}
    J -->|Oui| K[❌ Comportement suspect]
    J -->|Non| L[✅ Signalement accepté]

Couche 1 — Token rotatif

Implémentation : Cron job quotidien qui régénère les tokens expirés.

// Cron job de rotation
async function rotateExpiredTokens() {
  const expired = await db
    .select()
    .from(qrCode)
    .where(lt(qrCode.tokenExpiresAt, new Date()));

  for (const qr of expired) {
    await db
      .update(qrCode)
      .set({
        previousToken: qr.token,
        token: crypto.randomUUID(),
        tokenExpiresAt: addDays(new Date(), 7),
      })
      .where(eq(qrCode.id, qr.id));
  }
}

Redirect graceful : Si un scan arrive avec un previousToken, rediriger vers l'URL avec le nouveau token (valable 24h après rotation).

Couche 2 — Rate limiting IP

Implémentation : Compteur en mémoire (ou Redis si dispo) par hash d'IP.

import { Ratelimit } from "@upstash/ratelimit";

const reportRateLimit = new Ratelimit({
  redis,
  limiter: Ratelimit.slidingWindow(3, "15m"),
  prefix: "report:ip",
});

// Dans le handler
const ipHash = hashIP(request.headers.get("x-forwarded-for"));
const { success } = await reportRateLimit.limit(ipHash);
if (!success) {
  throw new HTTPException(429, { message: "Trop de signalements" });
}

Couche 3 — Device fingerprinting

Client-side : Calcul d'un fingerprint léger (pas de lib externe).

async function getDeviceFingerprint(): Promise<string> {
  const raw = [
    navigator.userAgent,
    `${screen.width}x${screen.height}`,
    Intl.DateTimeFormat().resolvedOptions().timeZone,
    navigator.language,
    navigator.hardwareConcurrency?.toString() ?? "",
  ].join("|");

  const hash = await crypto.subtle.digest(
    "SHA-256",
    new TextEncoder().encode(raw),
  );
  return Array.from(new Uint8Array(hash))
    .map((b) => b.toString(16).padStart(2, "0"))
    .join("");
}

Server-side : Même rate limit que l'IP mais par fingerprint.

Couche 4 — Cooldown par device

Après un signalement réussi, empêcher le même device de signaler le même WC pendant 10 minutes.

// Vérifier le cooldown
const recentReport = await db
  .select()
  .from(report)
  .where(
    and(
      eq(report.toiletId, toiletId),
      eq(report.fingerprint, fingerprint),
      gt(report.createdAt, subMinutes(new Date(), 10)),
    ),
  )
  .limit(1);

if (recentReport.length > 0) {
  throw new HTTPException(429, {
    message: "Vous avez déjà signalé récemment",
  });
}

Couche 5 — Détection de patterns

Analyse asynchrone après l'insertion du signalement :

async function detectAnomalies(report: Report) {
  // Pattern: même device, WC différents (scan de plusieurs QR codes)
  const crossToiletReports = await db
    .select()
    .from(report)
    .where(
      and(
        eq(report.fingerprint, report.fingerprint),
        gt(report.createdAt, subMinutes(new Date(), 10)),
      ),
    );

  if (crossToiletReports.length >= 5) {
    await flagAsSpam(report.id);
    await notifyAdmin("Spam détecté", { fingerprint: report.fingerprint });
  }

  // Pattern: volume anormal sur un WC
  const toiletVolume = await db
    .select({ count: count() })
    .from(report)
    .where(
      and(
        eq(report.toiletId, report.toiletId),
        gt(report.createdAt, subHours(new Date(), 1)),
      ),
    );

  if (toiletVolume[0].count >= 10) {
    await autoIgnoreRecent(report.toiletId);
    await notifyAdmin("Volume anormal", { toiletId: report.toiletId });
  }
}

Monitoring

MétriqueSeuil d'alerteAction
Taux de rejet (rate limit)> 20% des requêtesVérifier si attaque ou faux positifs
Signalements auto-ignorés> 5/jour/établissementContacter l'établissement
Tokens expirés scannés> 50/jourVérifier que le cron tourne

On this page