ZeroToShip

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.

libs/rate-limit.ts
ts
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.

app/api/lead/route.ts
ts
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.

Supabase → SQL Editor
sql
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:

libs/rate-limit-db.ts
ts
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.

middleware.ts
ts
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.