Docs/Sicurezza
// sicurezza
Rate limiting: API route
Ogni route handler sotto app/api/ è un URL pubblico che chiunque può chiamare in loop. Next.js non ha rate limiting incorporato. Qui trovi due implementazioni concrete — una in memoria, una su Supabase — e dove agganciarle nelle route che esistono già.
Cosa proteggere e cosa no
- Sì:
/api/lead(scrittura pubblica),/api/stripe/create-checkout(crea sessioni su Stripe per ogni chiamata), qualunque endpoint che manda email o chiama un LLM. - No:
/api/webhook/stripe. Non limitarlo mai. È Stripe a chiamarlo, si autentica già da solo con la firma (stripe.webhooks.constructEvent), e se gli rispondi 429 lui riprova e tu rischi di perdere o duplicare un ordine pagato.
1. Il limiter in memoria
Il più semplice che funziona. Questo file non c'è nel repo: crealo.
type Bucket = { count: number; resetAt: number };
const buckets = new Map<string, Bucket>();
export function rateLimit({
key,
limit,
windowMs,
}: {
key: string;
limit: number;
windowMs: number;
}): { ok: boolean; remaining: number; retryAfter: number } {
const now = Date.now();
const bucket = buckets.get(key);
if (!bucket || now > bucket.resetAt) {
buckets.set(key, { count: 1, resetAt: now + windowMs });
return { ok: true, remaining: limit - 1, retryAfter: 0 };
}
bucket.count += 1;
if (bucket.count > limit) {
return {
ok: false,
remaining: 0,
retryAfter: Math.ceil((bucket.resetAt - now) / 1000),
};
}
return { ok: true, remaining: limit - bucket.count, retryAfter: 0 };
}
// In Next.js 15 NextRequest.ip non esiste più: l'IP si legge dagli header
// che mette il proxy. Su Vercel sono questi due.
export function getIp(req: Request): string {
const forwarded = req.headers.get("x-forwarded-for");
if (forwarded) return forwarded.split(",")[0].trim();
return req.headers.get("x-real-ip") ?? "unknown";
}2. Agganciarlo a /api/lead
Questa è la route reale del repo con il limiter dentro. Nota che ho spostato anche req.json() dentro un try: nella versione attuale sta fuori, e un body malformato fa esplodere la funzione con un 500 non gestito.
import { NextResponse, NextRequest } from "next/server";
import { rateLimit, getIp } from "@/libs/rate-limit";
export async function POST(req: NextRequest) {
const ip = getIp(req);
// 5 lead per IP ogni 10 minuti
const { ok, retryAfter } = rateLimit({
key: "lead:" + ip,
limit: 5,
windowMs: 10 * 60 * 1000,
});
if (!ok) {
return NextResponse.json(
{ error: "Troppe richieste. Riprova tra qualche minuto." },
{ status: 429, headers: { "Retry-After": String(retryAfter) } }
);
}
let body: any;
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "Body non valido" }, { status: 400 });
}
if (!body.email) {
return NextResponse.json({ error: "Email is required" }, { status: 400 });
}
try {
// ...la tua logica
return NextResponse.json({});
} catch (e) {
console.error(e);
return NextResponse.json({ error: e.message }, { status: 500 });
}
}La forma della risposta non è casuale. L'interceptor in libs/api.ts legge error?.response?.data?.error e lo mostra in un toast: se rispondi con { error: "..." }, il messaggio arriva all'utente senza scrivere una riga lato client.
3. Perché in memoria non basta
Su Vercel ogni funzione gira in più istanze e le istanze fredde nascono con la Map vuota. Quindi il limiter in memoria conta solo le richieste che finiscono sulla stessa istanza: rallenta uno script ingenuo, non ferma un attacco distribuito. Quando ti serve il conteggio vero, il contatore deve stare in un posto condiviso. Hai già Supabase, usa quello.
create table if not exists rate_limits (
id bigint generated always as identity primary key,
key text not null,
created_at timestamptz not null default now()
);
create index if not exists rate_limits_key_created_idx
on rate_limits (key, created_at desc);
-- Nessuna policy: ci accede solo la SUPABASE_SECRET_KEY lato server.
alter table rate_limits enable row level security;
-- Conta e registra in una sola andata al database.
create or replace function check_rate_limit(
p_key text,
p_limit int,
p_window_seconds int
) returns boolean
language plpgsql
security definer
as $$
declare
hits int;
begin
-- pulizia opportunistica delle righe vecchie
delete from rate_limits
where created_at < now() - make_interval(secs => p_window_seconds * 10);
select count(*) into hits
from rate_limits
where key = p_key
and created_at > now() - make_interval(secs => p_window_seconds);
if hits >= p_limit then
return false;
end if;
insert into rate_limits (key) values (p_key);
return true;
end;
$$;Poi il wrapper. Anche questo file va creato:
import { SupabaseClient } from "@supabase/supabase-js";
// Stesso pattern usato dal webhook Stripe: client privato con la secret key.
const admin = new SupabaseClient(
process.env.NEXT_PUBLIC_SUPABASE_URL,
process.env.SUPABASE_SECRET_KEY
);
export async function rateLimitDb(
key: string,
limit: number,
windowSeconds: number
): Promise<boolean> {
const { data, error } = await admin.rpc("check_rate_limit", {
p_key: key,
p_limit: limit,
p_window_seconds: windowSeconds,
});
if (error) {
console.error("rate limit:", error.message);
// Fail open: se il database ha un problema non buttiamo giù il sito.
// Se l'endpoint costa soldi (email, LLM), qui conviene invece fail closed.
return true;
}
return data === true;
}Costa una query per richiesta. Su un endpoint che manda email o chiama un modello è irrilevante; su un endpoint chiamato mille volte al secondo useresti Redis. Per il volume tipico di un SaaS che parte, questo basta e avanza.
4. Oppure nel middleware
middleware.ts oggi fa solo una cosa: chiama updateSession() per rinfrescare la sessione Supabase. Il matcher include già le route /api/*, quindi è un buon punto per un filtro globale — ma usa solo il limiter in memoria: il middleware gira su ogni richiesta e una query al database lì dentro la paghi ovunque.
import { NextResponse, type NextRequest } from "next/server";
import { updateSession } from "@/libs/supabase/middleware";
import { rateLimit, getIp } from "@/libs/rate-limit";
export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// Il webhook Stripe non si tocca: si autentica con la firma e va
// lasciato passare sempre, altrimenti Stripe riprova e duplichi gli ordini.
const isApi =
pathname.startsWith("/api/") && !pathname.startsWith("/api/webhook/");
if (isApi) {
const { ok, retryAfter } = rateLimit({
key: "api:" + getIp(request),
limit: 60,
windowMs: 60 * 1000,
});
if (!ok) {
return NextResponse.json(
{ error: "Troppe richieste." },
{ status: 429, headers: { "Retry-After": String(retryAfter) } }
);
}
}
return await updateSession(request);
}
export const config = {
matcher: [
"/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)",
],
};Un tetto largo nel middleware (60 richieste al minuto per IP) più un tetto stretto nelle singole route (5 lead ogni 10 minuti) è la combinazione giusta: il primo ferma il rumore, il secondo protegge le operazioni che costano.
5. Il livello sotto: la piattaforma
Se sei su Vercel, nel pannello del progetto sotto Firewall puoi definire regole di rate limiting per path senza scrivere codice. Il vantaggio è che bloccano prima che la tua funzione parta, quindi non paghi l'invocazione. È il posto giusto per i limiti grossolani; i limiti per utente restano nel codice, perché solo lì sai chi è l'utente.