Docs/Sicurezza
// sicurezza
Validazione degli input con Zod
Il body di una richiesta è testo scritto da uno sconosciuto. In TypeScript però await req.json() restituisce any, e da lì in poi il compilatore smette di aiutarti: ti lascia scrivere body.email.toLowerCase() senza sapere se email è una stringa, un numero o un oggetto. Zod chiude quel buco a runtime.
Buona notizia: zod è già tra le dipendenze in package.json. Notizia meno buona: nel repo non lo usa nessuno. Le route validano a mano, e non benissimo.
Cosa non va oggi in /api/lead
export async function POST(req: NextRequest) {
const body = await req.json();
if (!body.email) {
return NextResponse.json({ error: "Email is required" }, { status: 400 });
}
// ...Tre problemi, tutti reali:
req.json()sta fuori dal try. Un body che non è JSON valido fa lanciare la funzione e il client si becca un 500 anonimo, quando la risposta corretta è un 400.!body.emailcontrolla solo che il valore sia truthy. Passano{ "email": 42 },{ "email": "non-una-email" }e{ "email": { "$ne": null } }.- Nessun limite di lunghezza. Un campo da 10 MB arriva fino al database.
La versione con Zod
import { NextResponse, NextRequest } from "next/server";
import { z } from "zod";
const leadSchema = z.object({
email: z.string().trim().toLowerCase().email().max(254),
// campi opzionali che vuoi accettare:
source: z.string().max(60).optional(),
});
export async function POST(req: NextRequest) {
let raw: unknown;
try {
raw = await req.json();
} catch {
return NextResponse.json({ error: "Body non valido." }, { status: 400 });
}
const parsed = leadSchema.safeParse(raw);
if (!parsed.success) {
return NextResponse.json(
{
// "error" è la chiave che legge l'interceptor di libs/api.ts
error: "Controlla i dati inviati.",
// utile in sviluppo, valuta se tenerlo in produzione
issues: parsed.error.flatten().fieldErrors,
},
{ status: 400 }
);
}
// Da qui in poi parsed.data è tipizzato: { email: string; source?: string }
const { email } = parsed.data;
try {
// const supabase = createClient();
// await supabase.from("leads").insert({ email });
return NextResponse.json({});
} catch (e) {
console.error(e);
return NextResponse.json({ error: e.message }, { status: 500 });
}
}Usa sempre safeParse, non parse: parse lancia, e un input sbagliato non è un errore del server — è un 400. Nota anche .trim().toLowerCase(): Zod non valida soltanto, trasforma. L'email che ti arriva è già normalizzata.
Un helper, così non lo riscrivi ogni volta
Questo file non c'è nel repo: crealo.
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
/**
* Legge e valida il body JSON di una route handler.
* Restituisce { data } oppure { response } già pronta da ritornare.
*/
export async function parseBody<T extends z.ZodTypeAny>(
req: NextRequest,
schema: T
): Promise<
{ data: z.infer<T>; response?: never } | { data?: never; response: NextResponse }
> {
let raw: unknown;
try {
raw = await req.json();
} catch {
return {
response: NextResponse.json({ error: "Body non valido." }, { status: 400 }),
};
}
const parsed = schema.safeParse(raw);
if (!parsed.success) {
return {
response: NextResponse.json(
{
error: "Controlla i dati inviati.",
issues: parsed.error.flatten().fieldErrors,
},
{ status: 400 }
),
};
}
return { data: parsed.data };
}Dentro una route diventa due righe:
const { data, response } = await parseBody(req, leadSchema);
if (response) return response;
// data.email è una stringa, garantitoIl caso interessante: create-checkout
app/api/stripe/create-checkout/route.ts oggi controlla che priceId, successUrl, cancelUrl e mode esistano, poi li passa dritti a createCheckout() in libs/stripe.ts. Qui la validazione non è pignoleria, sono soldi: il client decide quale prezzo pagare. Se nella tua dashboard Stripe esiste un price da 1 € creato per un test, qualcuno può passare quell'id e comprarsi il prodotto a 1 €. Stessa storia per gli URL di ritorno: accettarne uno qualsiasi significa avere un redirect aperto sul tuo dominio.
La soluzione è chiudere il priceId alla lista dei piani che hai davvero in config.ts:
import { z } from "zod";
import config from "@/config";
// I soli priceId accettabili sono quelli dichiarati in config.stripe.plans
const priceIds = config.stripe.plans.map((p) => p.priceId) as [
string,
...string[]
];
const checkoutSchema = z.object({
priceId: z.enum(priceIds),
mode: z.enum(["payment", "subscription"]),
successUrl: z.string().url(),
cancelUrl: z.string().url(),
});
export async function POST(req: NextRequest) {
const parsed = checkoutSchema.safeParse(await req.json().catch(() => null));
if (!parsed.success) {
return NextResponse.json(
{ error: "Dati del checkout non validi." },
{ status: 400 }
);
}
const { priceId, mode } = parsed.data;
// Gli URL di ritorno devono stare in casa nostra: niente redirect aperti.
const origin = req.headers.get("origin") ?? "https://" + config.domainName;
const successUrl = new URL("/checkout-success", origin).toString();
const cancelUrl = new URL("/", origin).toString();
// ...il resto della route com'è adesso
}Se preferisci continuare ad accettare gli URL dal client, almeno verificane l'origin con un refine:
const sameOrigin = (allowed: string) =>
z
.string()
.url()
.refine((value) => new URL(value).origin === allowed, {
message: "URL non consentito",
});Il webhook Stripe è l'eccezione
Non validare con Zod il body di app/api/webhook/stripe/route.ts. Quella route legge il corpo come testo grezzo proprio perché deve:
const body = await req.text();
const signature = headersList.get("stripe-signature");
// verify Stripe event is legit
try {
event = stripe.webhooks.constructEvent(body, signature, webhookSecret);
} catch (err) {
return NextResponse.json({ error: err.message }, { status: 400 });
}constructEvent ricalcola la firma HMAC sui byte esatti che ha mandato Stripe: se prima li parsi e li riserializzi, la firma non torna più e il webhook smette di funzionare. La firma è già la validazione — ed è più forte di qualunque schema, perché prova anche chi ha mandato la richiesta.
Bonus: valida anche le variabili d'ambiente
Lo stesso strumento risolve un fastidio classico: scoprire in produzione che una env var mancava. Nel repo libs/resend.ts lo fa già a modo suo — lancia "RESEND_API_KEY is not set" alla prima chiamata. Con Zod puoi verificarle tutte insieme all'avvio:
import { z } from "zod";
const envSchema = z.object({
NEXT_PUBLIC_SUPABASE_URL: z.string().url(),
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY: z.string().min(1),
SUPABASE_SECRET_KEY: z.string().min(1),
STRIPE_SECRET_KEY: z.string().startsWith("sk_"),
STRIPE_WEBHOOK_SECRET: z.string().startsWith("whsec_"),
RESEND_API_KEY: z.string().startsWith("re_"),
});
export const env = envSchema.parse(process.env);Qui parse va bene: se manca qualcosa vuoi che il deploy fallisca subito, non che il primo cliente scopra il buco al posto tuo.
Prossimo passo naturale: chi valida l'input dovrebbe anche limitare quante volte lo riceve. Vai a Rate limiting: API route.