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 0000000..db1b03f Binary files /dev/null and b/src/lib/activity.ts differ diff --git a/src/lib/login-throttle.ts b/src/lib/login-throttle.ts new file mode 100644 index 0000000..dbc4dcf --- /dev/null +++ b/src/lib/login-throttle.ts @@ -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(); + +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