From 2874d79c9eda17fb5ecfcbee6b7c937a0a13bbb3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 25 Jul 2026 09:02:25 +0000 Subject: [PATCH] =?UTF-8?q?Correctifs=20de=20s=C3=A9curit=C3=A9=20:=20XSS?= =?UTF-8?q?=20stock=C3=A9,=20SSRF,=20force=20brute,=20al=C3=A9a=20des=20mo?= =?UTF-8?q?ts=20de=20passe?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 à 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 Claude-Session: https://claude.ai/code/session_01QXNXRC4j5VLfKvpyyisrnb --- next.config.mjs | 22 ++++++++++++++++ src/app/actions/public.ts | 4 ++- src/app/backend/actions.ts | 38 ++++++++++++++++++++++----- src/app/backend/page.tsx | 8 +++--- src/auth.ts | 21 ++++++++++++--- src/lib/activity.ts | Bin 0 -> 1869 bytes src/lib/login-throttle.ts | 51 +++++++++++++++++++++++++++++++++++++ src/lib/social.ts | 16 ++++++------ 8 files changed, 137 insertions(+), 23 deletions(-) create mode 100644 src/lib/activity.ts create mode 100644 src/lib/login-throttle.ts diff --git a/next.config.mjs b/next.config.mjs index a481de0..7bc3fe0 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -6,6 +6,28 @@ const nextConfig = { // Les images d'entête/logo sont envoyées en data-URL via server action. serverActions: { bodySizeLimit: "4mb" }, }, + async headers() { + return [ + { + source: "/:path*", + headers: [ + // Empêche l'interprétation d'une réponse selon un type deviné. + { key: "X-Content-Type-Options", value: "nosniff" }, + // Le site ne doit pas être encadré par un tiers (clickjacking). + { key: "X-Frame-Options", value: "SAMEORIGIN" }, + { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" }, + { + key: "Permissions-Policy", + value: "camera=(), microphone=(), geolocation=(), interest-cohort=()", + }, + // frame-ancestors double X-Frame-Options pour les navigateurs récents. + // Une CSP complète (script-src) demanderait des nonces sur les scripts + // d'hydratation de Next : à traiter séparément. + { key: "Content-Security-Policy", value: "frame-ancestors 'self'" }, + ], + }, + ]; + }, }; export default nextConfig; diff --git a/src/app/actions/public.ts b/src/app/actions/public.ts index edb0918..93ac006 100644 --- a/src/app/actions/public.ts +++ b/src/app/actions/public.ts @@ -3,13 +3,15 @@ import { revalidatePath } from "next/cache"; import { db } from "@/db"; import { activityLog, contactMessages, membershipRequests } from "@/db/schema"; +import { sanitizeActivityMessage } from "@/lib/activity"; export type SubmitResult = { ok: boolean; error?: string }; const isEmail = (v: string) => /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(v); async function logActivity(message: string, dot: string) { - await db.insert(activityLog).values({ message, dot }); + // Saisie publique non authentifiée : filtrage obligatoire. + await db.insert(activityLog).values({ message: sanitizeActivityMessage(message), dot }); } // ---- Demande pour devenir membre (public, sans authentification) ---- diff --git a/src/app/backend/actions.ts b/src/app/backend/actions.ts index f9b9eb4..4a662c6 100644 --- a/src/app/backend/actions.ts +++ b/src/app/backend/actions.ts @@ -1,5 +1,6 @@ "use server"; +import { randomInt } from "node:crypto"; import { revalidatePath } from "next/cache"; import { headers } from "next/headers"; import { redirect } from "next/navigation"; @@ -23,6 +24,7 @@ import { socialPosts, users, } from "@/db/schema"; +import { sanitizeActivityMessage } from "@/lib/activity"; import { can, LABEL_TO_ROLE } from "@/lib/rbac"; import { isNetworkConfigured, @@ -62,15 +64,18 @@ function slugify(input: string): string { } async function logActivity(message: string, dot = "#2C6FB3") { - await db.insert(activityLog).values({ message, dot }); + // Le message agrège des saisies de tiers : on ne laisse passer que . + await db.insert(activityLog).values({ message: sanitizeActivityMessage(message), dot }); } // Mot de passe temporaire lisible (sans caractères ambigus). +// `randomInt` et non `Math.random()` : ce dernier n'est pas cryptographique et +// permet de prédire les mots de passe suivants à partir de quelques tirages. function generateTempPassword(): string { const alphabet = "ABCDEFGHJKMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789"; let out = ""; - for (let i = 0; i < 10; i++) { - out += alphabet[Math.floor(Math.random() * alphabet.length)]; + for (let i = 0; i < 12; i++) { + out += alphabet[randomInt(alphabet.length)]; } return out; } @@ -107,6 +112,23 @@ function asNullableString(formData: FormData, key: string) { return asString(formData, key) || null; } +// ~3 Mo de base64, soit environ 2,2 Mo d'image réelle. +const MAX_IMAGE_DATA_URI = 3_000_000; +const IMAGE_DATA_URI = /^data:image\/(png|jpeg|jpg|webp|gif);base64,[A-Za-z0-9+/=\s]+$/; + +/** + * N'accepte qu'une image en data-URI. Une URL http(s) ferait appeler par le + * serveur une adresse choisie par l'utilisateur (SSRF) au moment de la + * publication sur les réseaux. + */ +function asImageDataUri(formData: FormData, key: string): string | null { + const value = String(formData.get(key) ?? "").trim(); + if (!value) return null; + if (value.length > MAX_IMAGE_DATA_URI) throw new Error("Image trop volumineuse (3 Mo maximum)."); + if (!IMAGE_DATA_URI.test(value)) throw new Error("Format d'image non accepté."); + return value; +} + function revalidatePromoPaths(memberId?: number | null) { revalidatePath("/backend/promotions"); revalidatePath("/backend/espace"); @@ -360,7 +382,7 @@ export async function publishPromo(formData: FormData) { const text = String(formData.get("text") ?? "").slice(0, 240); const category = String(formData.get("category") ?? ""); const badge = String(formData.get("badge") ?? "").trim() || null; - const imageUrl = String(formData.get("imageUrl") ?? "") || null; + const imageUrl = asImageDataUri(formData, "imageUrl"); // Réseaux souhaités : rien n'est publié ici, la diffusion attend la validation. await db.insert(promotions).values({ @@ -558,7 +580,7 @@ export async function inviteAdmin(formData: FormData) { if (existing.length > 0) return; // Temporary password — the invitee resets it on first login (out of scope here). - const tempPassword = Math.random().toString(36).slice(2, 12); + const tempPassword = generateTempPassword(); await db.insert(users).values({ name, email, @@ -1047,8 +1069,10 @@ export async function updateOwnProfile(formData: FormData) { website: normalizeWebsite(String(formData.get("website") ?? "")), hours: String(formData.get("hours") ?? "").trim() || null, tags: String(formData.get("tags") ?? "").trim() || null, - coverUrl: String(formData.get("coverUrl") ?? "").trim() || null, - logoUrl: String(formData.get("logoUrl") ?? "").trim() || null, + // Images restreintes aux data-URI : un adhérent ne peut pas faire pointer + // sa fiche publique vers une ressource externe qu'il contrôle. + coverUrl: asImageDataUri(formData, "coverUrl"), + logoUrl: asImageDataUri(formData, "logoUrl"), }) // Sécurité : on ne touche que SA propre fiche, jamais statut/catégorie/mise à l'honneur. .where(eq(members.id, memberId)); diff --git a/src/app/backend/page.tsx b/src/app/backend/page.tsx index ffdd800..959f2a8 100644 --- a/src/app/backend/page.tsx +++ b/src/app/backend/page.tsx @@ -4,6 +4,7 @@ import { desc, eq } from "drizzle-orm"; import { auth } from "@/auth"; import { db } from "@/db"; import { activityLog, contactMessages, members, membershipRequests, promotions } from "@/db/schema"; +import { activityNodes } from "@/lib/activity"; import { can, isStaff } from "@/lib/rbac"; import { expiryStatus, getSocialAccounts, SOCIAL_LABELS } from "@/lib/social-accounts"; @@ -125,10 +126,9 @@ export default async function DashboardPage() { {activity.map((a) => (
-
+
+ {activityNodes(a.message)} +
{timeAgo(a.createdAt)} diff --git a/src/auth.ts b/src/auth.ts index 54e9560..2071b20 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -5,6 +5,11 @@ import { eq } from "drizzle-orm"; import { db } from "@/db"; import { users } from "@/db/schema"; import { authConfig } from "@/auth.config"; +import { clearLoginFailures, isLoginBlocked, recordLoginFailure } from "@/lib/login-throttle"; + +// Empreinte bcrypt d'une valeur quelconque : sert de comparaison factice quand +// l'adresse n'existe pas, pour que le temps de réponse reste identique. +const ABSENT_USER_HASH = "$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy"; export const { handlers, auth, signIn, signOut } = NextAuth({ ...authConfig, @@ -21,12 +26,22 @@ export const { handlers, auth, signIn, signOut } = NextAuth({ const password = String(credentials?.password ?? ""); if (!email || !password) return null; + if (isLoginBlocked(email)) return null; + const rows = await db.select().from(users).where(eq(users.email, email)); const user = rows[0]; - if (!user) return null; - const valid = await bcrypt.compare(password, user.passwordHash); - if (!valid) return null; + // Comparaison menée même sans compte correspondant : sans cela, le + // temps de réponse révélerait quelles adresses existent. + const hash = user?.passwordHash ?? ABSENT_USER_HASH; + const valid = await bcrypt.compare(password, hash); + + if (!user || !valid) { + recordLoginFailure(email); + return null; + } + + clearLoginFailures(email); return { id: String(user.id), diff --git a/src/lib/activity.ts b/src/lib/activity.ts new file mode 100644 index 0000000000000000000000000000000000000000..db1b03f09dc3db869116fffc267e76267418a249 GIT binary patch literal 1869 zcmah~-EQ1O6wdWN#bH%x?XcM5cJrsG1Q1GFR7K(wB}q-C5q3$^k-;fr0mC~Z6Ri2~(_Iizn*NWHA#!Zzjn;jKg%!#w{I0`7j@y&YX3)htIz2ys z@x$3yZ!S*1p(9EUU!^A?-sti;E!Dw{T>>JGwRDYl69$Dw(~n-#;dK8n9ajnC%bQ@_ z!fV(H1zMq%Q$NYm!DYA426siH%T|P7rOS9)(A|vKdEoe7wr<+KyaLV3tAqQ)56W%6 zuS6y{O_j5EjwV?WBU?U*c4}NwuNtC70 zBf}l(JG5l1l~rh4nWK!ymEG5q9$vmazS=)NNy{b8=OO*bateRAyBEF6KacnLYV>9a zJLK{v)X&9SKKJ!nnrPU)0@60Loc;Ea9l2vNxJ&=}tItPcCX+wAY$L#!2hYhHCzm|j zS#ZlJ2BYZ(m)r`=kdtMg5<09s*@Y+ym2H5c99|&E65cP^3_H?ewEB-2AVyLeZf;HA z;oe~7$5tC3vRi&A?SB~T;T3)G7~UU@+=F*)3noz-O;mrzWQ_KXQxzm1%&9raY7m1H z?ijSf)3}B!0b7X5N{kcnL+5OQC+bXOn<(PHyIo;MM5>@DeLdxVlB&c(zHm8?N9WRn z?TiOBhfQW3{%v=m?%Dqj*hutKXrD3Xc$|wNRS;nqqmc;LJuZn({`pmk=7W;+B|d5I k(); + +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); +} diff --git a/src/lib/social.ts b/src/lib/social.ts index a300315..b32a600 100644 --- a/src/lib/social.ts +++ b/src/lib/social.ts @@ -76,14 +76,14 @@ export async function loadPromoImage(imageUrl: string | null): Promise