feat(hardening): rate limit (signup/reset/bookings) + tâches cron + backup PostgreSQL nocturne
All checks were successful
CI / test (pull_request) Successful in 2m10s

This commit is contained in:
Claude Integration 2026-06-01 20:16:57 +00:00
parent f1fb06b0af
commit a373bd60ad
8 changed files with 319 additions and 0 deletions

51
scripts/backup-postgres.sh Executable file
View file

@ -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)"

View file

@ -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 });

View file

@ -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 },
);
}
}

View file

@ -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();

View file

@ -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();

86
src/lib/rate-limit.ts Normal file
View file

@ -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<string, Bucket>();
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 });
}

75
src/lib/scheduled.ts Normal file
View file

@ -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;

View file

@ -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);
});
});