From a373bd60ad8bb3405316613f300f2d058e2e207f Mon Sep 17 00:00:00 2001 From: Claude Integration Date: Mon, 1 Jun 2026 20:16:57 +0000 Subject: [PATCH] =?UTF-8?q?feat(hardening):=20rate=20limit=20(signup/reset?= =?UTF-8?q?/bookings)=20+=20t=C3=A2ches=20cron=20+=20backup=20PostgreSQL?= =?UTF-8?q?=20nocturne?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/backup-postgres.sh | 51 ++++++++++++ src/app/api/bookings/route.ts | 9 +++ src/app/api/cron/run/[task]/route.ts | 37 +++++++++ src/app/api/password/reset-request/route.ts | 8 ++ src/app/api/signup/route.ts | 9 +++ src/lib/rate-limit.ts | 86 +++++++++++++++++++++ src/lib/scheduled.ts | 75 ++++++++++++++++++ tests/lib/rate-limit.test.ts | 44 +++++++++++ 8 files changed, 319 insertions(+) create mode 100755 scripts/backup-postgres.sh create mode 100644 src/app/api/cron/run/[task]/route.ts create mode 100644 src/lib/rate-limit.ts create mode 100644 src/lib/scheduled.ts create mode 100644 tests/lib/rate-limit.test.ts diff --git a/scripts/backup-postgres.sh b/scripts/backup-postgres.sh new file mode 100755 index 0000000..fa2d461 --- /dev/null +++ b/scripts/backup-postgres.sh @@ -0,0 +1,51 @@ +#!/bin/bash +# +# Backup nightly du PostgreSQL Karbé vers MinIO. +# Lancé par un systemd timer (karbe-backup.timer). +# +# Rétention 30 jours côté MinIO (s'appuyer sur une lifecycle policy ou un +# nettoyage côté `mc rm` planifié — TODO si on veut être propre). + +set -euo pipefail + +STAMP=$(date -u +%Y%m%d-%H%M%S) +DUMP_DIR=/tmp/karbe-backup +DUMP_FILE="$DUMP_DIR/karbe-${STAMP}.sql.gz" +BUCKET_DEST="karbe-backups/postgres/karbe-${STAMP}.sql.gz" + +mkdir -p "$DUMP_DIR" + +# Dump compressé depuis le conteneur postgres +docker compose -f /home/ubuntu/karbe/docker-compose.prod.yml \ + -f /home/ubuntu/karbe/docker-compose.override.yml \ + exec -T postgres pg_dump -U karbe -d karbe \ + | gzip > "$DUMP_FILE" + +SIZE=$(stat -c %s "$DUMP_FILE") +echo "[$(date -u +%FT%TZ)] dump created size=${SIZE}B path=${DUMP_FILE}" + +# Push vers MinIO via mc Docker +docker run --rm --network karbe-net \ + -v "$DUMP_DIR:/dump" \ + minio/mc:latest sh -c " + mc alias set karbe http://minio:9000 \"\$MINIO_ROOT_USER\" \"\$MINIO_ROOT_PASSWORD\" >/dev/null 2>&1 && \ + mc mb karbe/karbe-backups --ignore-existing >/dev/null 2>&1 && \ + mc cp /dump/karbe-${STAMP}.sql.gz karbe/${BUCKET_DEST} + " \ + -e MINIO_ROOT_USER \ + -e MINIO_ROOT_PASSWORD + +echo "[$(date -u +%FT%TZ)] uploaded to karbe/${BUCKET_DEST}" + +# Nettoyage local +rm -f "$DUMP_FILE" + +# Rétention : supprime les backups > 30 jours dans MinIO +docker run --rm --network karbe-net minio/mc:latest sh -c " + mc alias set karbe http://minio:9000 \"\$MINIO_ROOT_USER\" \"\$MINIO_ROOT_PASSWORD\" >/dev/null 2>&1 && \ + mc rm --recursive --force --older-than 30d karbe/karbe-backups/ 2>/dev/null || true +" \ + -e MINIO_ROOT_USER \ + -e MINIO_ROOT_PASSWORD + +echo "[$(date -u +%FT%TZ)] retention sweep done (>30d removed)" diff --git a/src/app/api/bookings/route.ts b/src/app/api/bookings/route.ts index 8ada7f7..e315ed3 100644 --- a/src/app/api/bookings/route.ts +++ b/src/app/api/bookings/route.ts @@ -17,6 +17,7 @@ import { } from "@/lib/booking"; import { prisma } from "@/lib/prisma"; import { sendBookingRequestToOwner, sendBookingRequestToTenant } from "@/lib/email"; +import { rateLimitRequest } from "@/lib/rate-limit"; export const runtime = "nodejs"; @@ -28,6 +29,14 @@ type CreateBookingBody = { }; export async function POST(request: Request) { + const rl = rateLimitRequest(request, "bookings", 60 * 60 * 1000, 10); + if (!rl.ok) { + return NextResponse.json( + { error: `Trop de tentatives. Réessayez dans ${rl.retryAfter}s.` }, + { status: 429, headers: { "Retry-After": String(rl.retryAfter) } }, + ); + } + const session = await auth(); if (!session?.user?.id) { return NextResponse.json({ error: "Non authentifié." }, { status: 401 }); diff --git a/src/app/api/cron/run/[task]/route.ts b/src/app/api/cron/run/[task]/route.ts new file mode 100644 index 0000000..ff2beba --- /dev/null +++ b/src/app/api/cron/run/[task]/route.ts @@ -0,0 +1,37 @@ +import { NextResponse } from "next/server"; + +import { SCHEDULED_TASKS, type ScheduledTaskName } from "@/lib/scheduled"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +function authorized(req: Request): boolean { + const secret = (process.env.CRON_TOKEN ?? "").trim(); + if (!secret) return false; + const header = req.headers.get("authorization") ?? ""; + const token = header.startsWith("Bearer ") ? header.slice(7) : ""; + return token === secret; +} + +export async function POST(req: Request, ctx: { params: Promise<{ task: string }> }) { + if (!authorized(req)) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + const { task } = await ctx.params; + const fn = SCHEDULED_TASKS[task as ScheduledTaskName]; + if (!fn) { + return NextResponse.json( + { error: `Unknown task. Available: ${Object.keys(SCHEDULED_TASKS).join(", ")}` }, + { status: 404 }, + ); + } + try { + const result = await fn(); + return NextResponse.json({ ok: true, task, result }); + } catch (e) { + return NextResponse.json( + { error: e instanceof Error ? e.message : String(e) }, + { status: 500 }, + ); + } +} diff --git a/src/app/api/password/reset-request/route.ts b/src/app/api/password/reset-request/route.ts index 1eaedc9..9bcbc32 100644 --- a/src/app/api/password/reset-request/route.ts +++ b/src/app/api/password/reset-request/route.ts @@ -5,6 +5,7 @@ import { createPasswordResetToken } from "@/lib/password-reset"; import { prisma } from "@/lib/prisma"; import { sendPasswordReset } from "@/lib/email"; import { recordAudit } from "@/lib/admin/audit"; +import { rateLimitRequest } from "@/lib/rate-limit"; export const runtime = "nodejs"; @@ -15,6 +16,13 @@ const schema = z.object({ const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? "https://karbe.cosmolan.fr"; export async function POST(req: Request) { + const rl = rateLimitRequest(req, "password-reset", 60 * 60 * 1000, 3); + if (!rl.ok) { + return NextResponse.json( + { error: `Trop de tentatives. Réessayez dans ${rl.retryAfter}s.` }, + { status: 429, headers: { "Retry-After": String(rl.retryAfter) } }, + ); + } let body: unknown; try { body = await req.json(); diff --git a/src/app/api/signup/route.ts b/src/app/api/signup/route.ts index b1044b8..1ded993 100644 --- a/src/app/api/signup/route.ts +++ b/src/app/api/signup/route.ts @@ -6,6 +6,7 @@ import { hashPassword } from "@/lib/password"; import { prisma } from "@/lib/prisma"; import { recordAudit } from "@/lib/admin/audit"; import { sendSignupWelcome } from "@/lib/email"; +import { rateLimitRequest } from "@/lib/rate-limit"; export const runtime = "nodejs"; @@ -19,6 +20,14 @@ const schema = z.object({ }); export async function POST(req: Request) { + // 5 inscriptions max par IP par heure. + const rl = rateLimitRequest(req, "signup", 60 * 60 * 1000, 5); + if (!rl.ok) { + return NextResponse.json( + { error: `Trop de tentatives. Réessayez dans ${rl.retryAfter}s.` }, + { status: 429, headers: { "Retry-After": String(rl.retryAfter) } }, + ); + } let body: unknown; try { body = await req.json(); diff --git a/src/lib/rate-limit.ts b/src/lib/rate-limit.ts new file mode 100644 index 0000000..41c27f1 --- /dev/null +++ b/src/lib/rate-limit.ts @@ -0,0 +1,86 @@ +/** + * Token-bucket en mémoire — best-effort par instance. + * + * Pour un déploiement multi-instance, swap pour un store partagé (Redis). + * Ici on tourne en mono-instance Next derrière nginx-proxy-manager, donc + * une Map locale suffit. + * + * Usage : + * const r = await rateLimit({ key: ip + ":signup", windowMs: 60_000, limit: 5 }); + * if (!r.ok) return tooManyRequests(r.retryAfter); + */ + +type Bucket = { + count: number; + resetAt: number; +}; + +const buckets = new Map(); + +const SWEEP_INTERVAL_MS = 60_000; +let lastSweep = 0; + +function sweep(now: number) { + if (now - lastSweep < SWEEP_INTERVAL_MS) return; + lastSweep = now; + for (const [k, b] of buckets) { + if (b.resetAt <= now) buckets.delete(k); + } +} + +export type RateLimitOpts = { + key: string; + /** Fenêtre glissante en ms. */ + windowMs: number; + /** Nombre max d'appels par fenêtre. */ + limit: number; +}; + +export type RateLimitResult = { + ok: boolean; + remaining: number; + retryAfter: number; // secondes +}; + +export function rateLimit(opts: RateLimitOpts): RateLimitResult { + const now = Date.now(); + sweep(now); + + const b = buckets.get(opts.key); + if (!b || b.resetAt <= now) { + buckets.set(opts.key, { count: 1, resetAt: now + opts.windowMs }); + return { ok: true, remaining: opts.limit - 1, retryAfter: 0 }; + } + + if (b.count >= opts.limit) { + return { + ok: false, + remaining: 0, + retryAfter: Math.max(1, Math.ceil((b.resetAt - now) / 1000)), + }; + } + + b.count++; + return { ok: true, remaining: opts.limit - b.count, retryAfter: 0 }; +} + +/** Extract a client IP from a request, fallback to a safe default. */ +export function getClientIp(req: Request): string { + // nginx-proxy-manager pose x-forwarded-for, x-real-ip + const xff = req.headers.get("x-forwarded-for"); + if (xff) return xff.split(",")[0].trim(); + const xri = req.headers.get("x-real-ip"); + if (xri) return xri.trim(); + return "unknown"; +} + +/** Helper pratique : extract IP + applique le bucket. */ +export function rateLimitRequest( + req: Request, + bucket: string, + windowMs: number, + limit: number, +): RateLimitResult { + const ip = getClientIp(req); + return rateLimit({ key: `${ip}:${bucket}`, windowMs, limit }); +} diff --git a/src/lib/scheduled.ts b/src/lib/scheduled.ts new file mode 100644 index 0000000..f9faee8 --- /dev/null +++ b/src/lib/scheduled.ts @@ -0,0 +1,75 @@ +/** + * Tâches planifiées exécutables via /api/cron/run/[task] avec le secret + * CRON_TOKEN. Idempotents, retournent un compteur d'actions. + */ + +import "server-only"; + +import { BookingStatus } from "@/generated/prisma/enums"; +import { prisma } from "@/lib/prisma"; +import { recordAudit } from "@/lib/admin/audit"; +import { purgeExpiredResetTokens } from "@/lib/password-reset"; + +const PENDING_TTL_DAYS = 7; + +/** Annule les bookings PENDING créés il y a plus de N jours. */ +export async function autoCancelStalePending(): Promise<{ cancelled: number }> { + const cutoff = new Date(Date.now() - PENDING_TTL_DAYS * 86_400_000); + const stale = await prisma.booking.findMany({ + where: { status: BookingStatus.PENDING, createdAt: { lt: cutoff } }, + select: { id: true }, + }); + if (stale.length === 0) return { cancelled: 0 }; + await prisma.booking.updateMany({ + where: { id: { in: stale.map((s) => s.id) } }, + data: { status: BookingStatus.CANCELLED }, + }); + await recordAudit({ + scope: "cron", + event: "bookings.auto-cancel-stale", + actorEmail: null, + details: { count: stale.length, cutoff: cutoff.toISOString() }, + }); + return { cancelled: stale.length }; +} + +/** Purge les password reset tokens expirés. */ +export async function purgeResetTokens(): Promise<{ purged: number }> { + const count = await purgeExpiredResetTokens(); + if (count > 0) { + await recordAudit({ + scope: "cron", + event: "password.purge-expired-tokens", + actorEmail: null, + details: { count }, + }); + } + return { purged: count }; +} + +/** Logique simple : retourne juste la liste des bookings dont l'arrivée est dans 3 jours. + * L'envoi email réel est branché plus tard quand RESEND_API_KEY est posée. */ +export async function listUpcomingArrivalsInThreeDays() { + const now = new Date(); + const in3 = new Date(now.getTime() + 3 * 86_400_000); + const in4 = new Date(now.getTime() + 4 * 86_400_000); + return prisma.booking.findMany({ + where: { + status: BookingStatus.CONFIRMED, + startDate: { gte: in3, lt: in4 }, + }, + select: { + id: true, + startDate: true, + tenant: { select: { email: true, firstName: true } }, + carbet: { select: { title: true, slug: true } }, + }, + }); +} + +export const SCHEDULED_TASKS = { + "auto-cancel-stale-pending": autoCancelStalePending, + "purge-reset-tokens": purgeResetTokens, +} as const; + +export type ScheduledTaskName = keyof typeof SCHEDULED_TASKS; diff --git a/tests/lib/rate-limit.test.ts b/tests/lib/rate-limit.test.ts new file mode 100644 index 0000000..4b97e3e --- /dev/null +++ b/tests/lib/rate-limit.test.ts @@ -0,0 +1,44 @@ +import { describe, it, expect } from "vitest"; + +import { rateLimit } from "@/lib/rate-limit"; + +describe("rateLimit", () => { + it("allows up to limit calls in window", () => { + const key = "test:" + Math.random(); + for (let i = 0; i < 5; i++) { + const r = rateLimit({ key, windowMs: 60_000, limit: 5 }); + expect(r.ok).toBe(true); + } + }); + + it("blocks the (limit+1)th call with retryAfter > 0", () => { + const key = "test:" + Math.random(); + for (let i = 0; i < 3; i++) { + rateLimit({ key, windowMs: 60_000, limit: 3 }); + } + const r = rateLimit({ key, windowMs: 60_000, limit: 3 }); + expect(r.ok).toBe(false); + expect(r.retryAfter).toBeGreaterThan(0); + expect(r.remaining).toBe(0); + }); + + it("isolates different keys", () => { + const k1 = "test:" + Math.random(); + const k2 = "test:" + Math.random(); + for (let i = 0; i < 5; i++) { + rateLimit({ key: k1, windowMs: 60_000, limit: 5 }); + } + const r = rateLimit({ key: k2, windowMs: 60_000, limit: 5 }); + expect(r.ok).toBe(true); + }); + + it("resets after window expires", async () => { + const key = "test:" + Math.random(); + rateLimit({ key, windowMs: 10, limit: 1 }); + const blocked = rateLimit({ key, windowMs: 10, limit: 1 }); + expect(blocked.ok).toBe(false); + await new Promise((r) => setTimeout(r, 15)); + const after = rateLimit({ key, windowMs: 10, limit: 1 }); + expect(after.ok).toBe(true); + }); +});