mirror of
https://github.com/R0m1k3/PleinR.git
synced 2026-10-18 04:37:43 +02:00
Correctifs de sécurité : XSS stocké, SSRF, force brute, aléa des mots de passe
Audit de l'application. Cinq corrections, la première critique. XSS stocké non authentifié (critique) - Le journal d'activité était rendu avec `dangerouslySetInnerHTML` alors qu'il agrège des saisies de tiers : titre de promotion d'un adhérent, et surtout nom laissé dans le formulaire de contact PUBLIC. N'importe quel visiteur pouvait donc faire exécuter du script dans le navigateur d'un administrateur, et prendre la main sur son compte. - Défense aux deux bouts : `sanitizeActivityMessage` ne laisse passer que <strong> à l'écriture, et `activityNodes` rend le message en éléments React — un message déjà stocké en base ne peut plus rien injecter. SSRF - `loadPromoImage` allait chercher toute URL http(s) placée dans le champ image d'une promotion, et en poussait le contenu vers Facebook ou LinkedIn : de quoi atteindre un service interne ou les métadonnées de l'hébergeur. Seules les data-URI sont désormais acceptées, ce qui couvre tous les usages réels. - Les images sont en plus validées à l'écriture (type et taille), côté promotion comme côté profil adhérent. Authentification - Aucun frein sur la connexion : blocage progressif après 8 échecs par compte. - La comparaison bcrypt est menée même sans compte correspondant, sinon le temps de réponse révélait quelles adresses existent. Aléa - Les mots de passe temporaires venaient de `Math.random()`, prédictible : passage à `randomInt` de node:crypto, et longueur portée à 12. En-têtes - nosniff, X-Frame-Options, Referrer-Policy, Permissions-Policy et frame-ancestors. Une CSP complète demandera des nonces sur les scripts Next. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QXNXRC4j5VLfKvpyyisrnb
This commit is contained in:
8 files changed
+137
-23
No files matched your search
Binary file not shown.
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Limitation des tentatives de connexion.
|
||||
*
|
||||
* L'application n'avait aucun frein : un mot de passe d'administrateur pouvait
|
||||
* être cherché par force brute aussi vite que le serveur répond. Le compteur
|
||||
* vit en mémoire du processus, ce qui suffit ici (un seul conteneur applicatif)
|
||||
* et se réinitialise au redémarrage.
|
||||
*/
|
||||
|
||||
const WINDOW_MS = 15 * 60 * 1000;
|
||||
const MAX_FAILURES = 8;
|
||||
|
||||
type Entry = { failures: number; firstAt: number; blockedUntil: number };
|
||||
|
||||
const attempts = new Map<string, Entry>();
|
||||
|
||||
function prune(now: number) {
|
||||
if (attempts.size < 500) return;
|
||||
for (const [key, entry] of attempts) {
|
||||
if (now - entry.firstAt > WINDOW_MS && now > entry.blockedUntil) attempts.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
/** true si la clé est actuellement bloquée. */
|
||||
export function isLoginBlocked(key: string): boolean {
|
||||
const entry = attempts.get(key);
|
||||
return !!entry && Date.now() < entry.blockedUntil;
|
||||
}
|
||||
|
||||
export function recordLoginFailure(key: string): void {
|
||||
const now = Date.now();
|
||||
prune(now);
|
||||
const entry = attempts.get(key);
|
||||
|
||||
if (!entry || now - entry.firstAt > WINDOW_MS) {
|
||||
attempts.set(key, { failures: 1, firstAt: now, blockedUntil: 0 });
|
||||
return;
|
||||
}
|
||||
|
||||
entry.failures += 1;
|
||||
if (entry.failures >= MAX_FAILURES) {
|
||||
// Blocage progressif : chaque échec au-delà du seuil éloigne la prochaine
|
||||
// tentative, sans jamais verrouiller définitivement un compte légitime.
|
||||
const extra = entry.failures - MAX_FAILURES;
|
||||
entry.blockedUntil = now + Math.min(WINDOW_MS, 60_000 * 2 ** Math.min(extra, 4));
|
||||
}
|
||||
}
|
||||
|
||||
export function clearLoginFailures(key: string): void {
|
||||
attempts.delete(key);
|
||||
}
|
||||
+8
-8
@@ -76,14 +76,14 @@ export async function loadPromoImage(imageUrl: string | null): Promise<LoadedIma
|
||||
return { bytes, contentType, fileName: `promo.${extensionFor(contentType)}` };
|
||||
}
|
||||
|
||||
if (/^https?:\/\//i.test(imageUrl)) {
|
||||
const res = await fetch(imageUrl);
|
||||
if (!res.ok) return null;
|
||||
const contentType = res.headers.get("content-type")?.split(";")[0] ?? "image/jpeg";
|
||||
const bytes = new Uint8Array(await res.arrayBuffer());
|
||||
return { bytes, contentType, fileName: `promo.${extensionFor(contentType)}` };
|
||||
}
|
||||
|
||||
// Volontairement : aucune récupération par URL.
|
||||
//
|
||||
// `imageUrl` provient d'un champ de formulaire alimenté par l'adhérent. Aller
|
||||
// chercher l'adresse qu'il indique donnerait au serveur l'ordre d'appeler une
|
||||
// cible arbitraire (SSRF) — services internes, métadonnées d'hébergeur — dont
|
||||
// le contenu partirait ensuite sur Facebook ou LinkedIn. Toutes les images de
|
||||
// l'application sont des data-URI produites par l'upload, ce chemin est donc
|
||||
// le seul légitime.
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user