ZeroToShip

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

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

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

libs/validate.ts
ts
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:

app/api/lead/route.ts
ts
const { data, response } = await parseBody(req, leadSchema);
if (response) return response;

// data.email è una stringa, garantito

Il 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:

app/api/stripe/create-checkout/route.ts
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:

app/api/stripe/create-checkout/route.ts
ts
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:

app/api/webhook/stripe/route.ts
ts
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:

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