Checklist di sicurezza per l'App Router di Next.js
Checklist di sicurezza per le app con App Router di Next.js: server action, route handler, middleware, variabili d'ambiente, CSRF, header, webhook e cache.
· 8 min di lettura · Lina Source LLC
L'App Router ha riportato molto codice dal browser al server. Dal punto di vista della sicurezza è per lo più un vantaggio: query, segreti e logica di business ora girano in un posto che gli utenti non possono leggere. Ha però anche sfumato il confine tra ciò che è un endpoint pubblico e ciò che sembra soltanto una chiamata di funzione. La maggior parte dei bug gravi nelle app Next.js nasce da quella sfumatura.
Questa checklist copre i problemi che vediamo più spesso nelle codebase con App Router, nell'ordine in cui conviene controllarli. Niente di esotico; ogni voce è un punto in cui la comodità del framework nasconde un confine di fiducia.
1. Le server action sono endpoint pubblici
Una funzione contrassegnata con 'use server' viene compilata in un endpoint HTTP. Chiunque possa caricare il tuo sito può trovare l'ID dell'action e chiamarla con argomenti arbitrari, indipendentemente dal fatto che la tua interfaccia renderizzi o meno il pulsante che la usa. Nascondere un form ai non amministratori non protegge l'action che ci sta dietro.
Ogni action richiede gli stessi tre passi di qualsiasi handler API: autenticare chi chiama, validare l'input e autorizzare lo specifico record toccato. Metti questi controlli dentro il corpo dell'action stessa. Un controllo nel componente di pagina che renderizza il form viene eseguito al caricamento della pagina, non quando l'action viene chiamata, quindi non protegge nulla.
'use server';
import { z } from 'zod';
import { revalidatePath } from 'next/cache';
import { auth } from '@/lib/auth';
import { db } from '@/lib/db';
const Input = z.object({
projectId: z.string().uuid(),
name: z.string().trim().min(1).max(100),
});
export async function renameProject(formData: FormData) {
const session = await auth();
if (!session?.user) throw new Error('Unauthorized');
const { projectId, name } = Input.parse({
projectId: formData.get('projectId'),
name: formData.get('name'),
});
// Il controllo di proprietà fa parte della scrittura, non è una lettura separata
const result = await db.project.updateMany({
where: { id: projectId, ownerId: session.user.id },
data: { name },
});
if (result.count === 0) throw new Error('Not found');
revalidatePath('/projects');
}Fai attenzione ai file di helper che esportano molte action. Ogni funzione esportata in un modulo 'use server' è richiamabile, compresa quella che qualcuno ha aggiunto per uno script interno e poi ha dimenticato.
2. I route handler richiedono gli stessi controlli
I route handler in app/api sono più evidentemente degli endpoint, ma condividono la stessa modalità di fallimento: un segmento dinamico viene usato per caricare un record senza verificare chi ne è il proprietario. Nelle versioni recenti di Next.js params è una Promise; usa await, validalo e delimita la query a chi chiama.
// app/api/documents/[id]/route.ts
import { auth } from '@/lib/auth';
import { db } from '@/lib/db';
export async function GET(
_req: Request,
{ params }: { params: Promise<{ id: string }> },
) {
const session = await auth();
if (!session?.user) {
return Response.json({ error: 'unauthorized' }, { status: 401 });
}
const { id } = await params;
const doc = await db.document.findFirst({
where: { id, ownerId: session.user.id },
});
if (!doc) return Response.json({ error: 'not_found' }, { status: 404 });
return Response.json(doc, {
headers: { 'Cache-Control': 'private, no-store' },
});
}Restituisci 404 anziché 403 per i record che non appartengono a chi chiama, così l'endpoint non conferma quali ID esistono. Non fidarti mai di params, searchParams, header o cookie per le decisioni di autorizzazione: sono tutti input controllati dall'attaccante.
3. Il middleware da solo non è un confine di autenticazione
Il middleware (rinominato proxy in Next.js 16) è utile per reindirizzare gli utenti non autenticati e impostare header. Non dovrebbe essere l'unico punto in cui avviene l'autorizzazione. I matcher sono facili da sbagliare, le nuove route vengono aggiunte al di fuori di essi e le server action fanno POST verso il percorso della pagina, che potrebbe non corrispondere al pattern che avevi in mente. Nel 2025, CVE-2025-29927 ha mostrato che un header interno costruito ad arte poteva far saltare del tutto il middleware in alcune versioni di Next.js.
Tratta il middleware come uno strato di comodità. Il vero controllo va messo accanto ai dati: in ogni action e handler oppure, meglio ancora, in un data access layer attraverso cui passa ogni lettura lato server. Un data access layer è un unico modulo riservato al server che espone funzioni come getProjectForUser(projectId) ed esegue al suo interno il controllo di sessione e il filtro di proprietà. Pagine, action e route handler lo chiamano invece di usare direttamente il client del database, così una nuova route non può dimenticare il controllo: non esiste un percorso privo di controlli da dimenticare.
4. Tieni il codice server sul server
Qualsiasi variabile d'ambiente con il prefisso NEXT_PUBLIC_ viene inserita nel bundle JavaScript in fase di build ed è visibile a ogni visitatore. È corretto per una chiave pubblicabile di Stripe o per un ID di analytics, ed è una fuga di informazioni per qualsiasi altra cosa. Cerca NEXT_PUBLIC_ nella codebase e verifica che ciascuna di quelle variabili sarebbe sicura anche su un cartellone pubblicitario. Capita anche l'errore inverso: una variabile senza prefisso viene letta in un componente client, nel browser risulta undefined e qualcuno la rinomina con NEXT_PUBLIC_ per far sparire l'errore. Se un valore serve nel browser, chiediti prima se il browser debba averlo affatto.
// lib/dal.ts
import 'server-only';
import { cache } from 'react';
import { auth } from '@/lib/auth';
import { db } from '@/lib/db';
export const getCurrentUser = cache(async () => {
const session = await auth();
if (!session?.user) return null;
// Restituisci solo i campi che servono all'interfaccia, mai l'intera riga
return db.user.findUnique({
where: { id: session.user.id },
select: { id: true, name: true, plan: true },
});
});Il pacchetto server-only fa fallire la build se un componente client importa il modulo, il che impedisce a client di database e helper che leggono segreti di finire nel browser. Fai attenzione anche a ciò che i server component passano come prop ai componenti client: tutto ciò che attraversa quel confine viene serializzato nella pagina, quindi passare una riga utente completa manda al browser il suo hash di password e i suoi flag interni.
5. CSRF: sappi che cosa copre il framework
Le server action accettano solo POST, e Next.js confronta l'header Origin con l'host prima di eseguirle. Se fai il deploy dietro un proxy o su più domini, configura serverActions.allowedOrigins in modo deliberato anziché allargarlo finché gli errori non spariscono.
I route handler non hanno una protezione del genere. Se un handler POST, PUT o DELETE si autentica con i cookie, un form su un altro sito può comunque inviargli dati. Controllare il content type non basta da solo: un form HTML può inviare corpi urlencoded, multipart e text/plain senza preflight, e un handler che interpreta il corpo in modo permissivo li accetterà. Imposta i cookie di sessione con SameSite=Lax o Strict, non eseguire mai modifiche di stato su GET e verifica l'header Origin sulle mutazioni autenticate tramite cookie. Gli handler che accettano soltanto un bearer token nell'header Authorization non sono esposti al CSRF classico.
6. Header di sicurezza e CSP
Next.js invia pochissimi header di sicurezza per impostazione predefinita. Aggiungili in next.config tramite la funzione headers(), oppure nel middleware quando ti serve un nonce per ogni richiesta per una Content-Security-Policy rigorosa.
- Content-Security-Policy, idealmente basata su nonce, con object-src 'none' e base-uri 'self'.
- Strict-Transport-Security con un max-age lungo, una volta che l'HTTPS è solido ovunque.
- X-Content-Type-Options: nosniff.
- Referrer-Policy: strict-origin-when-cross-origin.
- frame-ancestors nella CSP, oppure X-Frame-Options, per prevenire il clickjacking.
- poweredByHeader: false in next.config, per rimuovere l'header X-Powered-By.
7. Rate limiting
Non esiste un rate limiter integrato. Accesso, registrazione, reimpostazione della password, verifica OTP e qualsiasi cosa che costi denaro a ogni chiamata (email, SMS, richieste IA) hanno bisogno di limiti per utente e per IP. I contatori in memoria non funzionano su deployment serverless o multi-istanza; usa uno store condiviso come Redis o il tuo database. Applica i limiti dentro l'action o l'handler, dove l'utente autenticato è noto, non solo nel middleware. Basa i limiti su qualcosa che un attaccante non possa cambiare a poco prezzo: l'account per le route autenticate e l'email o il numero di telefono di destinazione per i flussi di reimpostazione e OTP, oltre all'IP. Restituisci 429 con un header Retry-After così i client legittimi rallentano correttamente.
8. Verifica i webhook sul corpo grezzo
Le firme dei webhook sono calcolate sui byte esatti inviati dal provider. Interpretare il corpo come JSON e riserializzarlo cambia quei byte e rompe la verifica, il che spinge molti a saltarla. In un route handler, leggi il corpo con req.text() e verifica prima di fare qualsiasi altra cosa.
// app/api/webhooks/stripe/route.ts
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(req: Request) {
const body = await req.text();
const signature = req.headers.get('stripe-signature');
if (!signature) return new Response('Missing signature', { status: 400 });
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!,
);
} catch {
return new Response('Invalid signature', { status: 400 });
}
// Gli handler devono essere idempotenti: i provider riprovano e possono consegnare due volte
await handleStripeEvent(event);
return new Response('ok');
}Salva gli ID degli eventi già elaborati con un vincolo di unicità, così una consegna ripetuta o riprodotta non concede due volte un abbonamento, e gestisci gli eventi che arrivano fuori ordine, dato che i provider non garantiscono l'ordine di consegna.
9. Non mettere in cache globale i dati per utente
L'App Router usa la cache in modo aggressivo, e i valori predefiniti sono cambiati tra una versione e l'altra. Una funzione avvolta in unstable_cache o 'use cache' che restituisce dati relativi «all'utente corrente» ma non include l'ID utente nella chiave di cache servirà i dati di un utente a quello successivo. Lo stesso vale per le pagine renderizzate staticamente che avrebbero dovuto essere dinamiche e per la cache CDN delle risposte API. Verificalo direttamente: accedi come due utenti diversi in due browser e carica le stesse pagine. Qualsiasi cosa mostri al secondo utente i dati del primo è un bug di cache, e di solito un bug grave.
- Passa esplicitamente l'ID dell'utente o del tenant alle funzioni con cache, così diventa parte della chiave.
- Non leggere cookie o header dentro le funzioni con cache; leggili fuori e passa i valori.
- Invia Cache-Control: private, no-store sulle risposte che contengono dati per utente.
- Controlla l'output della build: le route che ti aspetti dinamiche non devono comparire come statiche.
Eseguire la checklist
Percorri la lista per route, non per file: per ogni action e handler, chi può chiamarla, quale input si fida, che cosa mette in cache e che cosa restituisce. CodeAuditAgent può fare una prima passata su un repository GitHub pubblico o su uno snippet incollato, segnalando ogni problema con gravità, CWE, la riga citata e una patch suggerita; le domande di progettazione, come quali route debbano essere pubbliche, richiedono ancora il giudizio del tuo team.