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étrique | Seuil d'alerte | Action |
|---|---|---|
| Taux de rejet (rate limit) | > 20% des requêtes | Vérifier si attaque ou faux positifs |
| Signalements auto-ignorés | > 5/jour/établissement | Contacter l'établissement |
| Tokens expirés scannés | > 50/jour | Vérifier que le cron tourne |