Un audit di sicurezza in più fasi: rafforzare un’applicazione Next.js
Gli audit di sicurezza delle applicazioni web tendono a individuare le stesse categorie di problemi, indipendentemente dallo stack: lacune nelle autorizzazioni, validazione degli input mancante, header HTTP di sicurezza inadeguati e vulnerabilità legate ai tempi di risposta nei flussi di autenticazione. Le manifestazioni specifiche cambiano secondo il framework, ma i meccanismi di fondo restano gli stessi. Ciò che varia è il rigore con cui viene strutturato l’audit: una revisione non strutturata tende a rilevare i problemi evidenti e a perdere quelli più sottili.
Abbiamo condotto un audit in più fasi su un’applicazione Next.js in produzione con Supabase come backend. L’applicazione gestiva l’autenticazione degli utenti, conservava record associati agli utenti ed esponeva diverse route API usate sia dal frontend sia da webhook esterni. Nessun rilievo era catastrofico, ma diversi problemi avrebbero potuto trasformarsi in incidenti gravi se scoperti da qualcuno con intenzioni malevole. Ecco cosa ha esaminato l’audit, cosa ha rilevato e cosa è cambiato di conseguenza.
Prima fase: autenticazione e autorizzazione
La prima fase si è concentrata sul livello di autenticazione e sulle policy di sicurezza a livello di riga, Row Level Security (RLS), che regolano l’accesso ai dati. Sono i controlli più critici in qualsiasi applicazione Supabase: se l’autenticazione non funziona o RLS è configurata male, nessuna validazione degli input o limitazione della frequenza delle richieste può compensare il problema.
L’audit è iniziato con un inventario completo delle tabelle Supabase e del loro stato RLS. Ogni tabella in uno schema esposto dovrebbe avere RLS abilitata. Con i permessi predefiniti di Supabase, qualsiasi tabella in uno schema esposto come public senza RLS abilitata è accessibile in lettura e scrittura a chiunque disponga della chiave anon, pubblica per scelta progettuale e incorporata nel bundle lato client. In questa applicazione, una tabella aveva RLS disabilitata. Era una tabella di consultazione trattata come dati di riferimento e, quando era stata creata, conteneva solo valori statici. Nel tempo erano state aggiunte righe con metadati associati agli utenti che avrebbero dovuto essere protetti. La correzione era semplice: abilitare RLS e aggiungere una policy. Il problema di processo sottostante era però significativo: le nuove tabelle dovrebbero avere RLS abilitata per impostazione predefinita, anziché come intervento successivo.
Sono state poi verificate le policy RLS delle tabelle protette. Un errore comune è una policy che controlla correttamente l’ID utente, ma sulla colonna sbagliata. Per esempio, una policy sulla tabella documents che verifica auth.uid() = created_by limita correttamente l’accesso al creatore; se però l’applicazione supporta anche documenti condivisi, una riga relativa a un collaboratore nella tabella di collegamento document_collaborators richiede una propria policy. Il controllo sulla tabella principale non si estende automaticamente ai dati collegati tramite join.
Una policy conteneva un errore logico sfuggito alla revisione del codice: usava USING dove avrebbe dovuto usare sia USING sia WITH CHECK. La clausola USING determina quali righe esistenti un comando può vedere, aggiornare o eliminare; WITH CHECK determina quali righe nuove o modificate possono essere scritte. Quando una policy che comprende le scritture (FOR ALL o FOR UPDATE) omette WITH CHECK, PostgreSQL riutilizza l’espressione USING per le scritture. Una clausola USING formulata come regola di lettura permetteva quindi a qualsiasi utente autenticato di scrivere righe che poteva leggere, comportamento che non era quello previsto. È una sottigliezza della RLS di PostgreSQL facile da trascurare.
Seconda fase: validazione degli input
La seconda fase ha esaminato ogni punto in cui i dati forniti dagli utenti entrano nel sistema: i gestori delle route API in app/api/, le server action e gli invii dei moduli. L’obiettivo era verificare che tutti gli input fossero validati prima dell’uso e che gli errori di validazione producessero messaggi informativi anziché eccezioni non gestite.
L’applicazione usava già Zod per una parte della validazione, ma in modo incoerente. Alcune route avevano schemi completi con schema.parse() all’inizio del gestore. Altre leggevano direttamente i campi di req.body senza validarli, affidandosi ad asserzioni di tipo TypeScript che non offrono alcuna sicurezza in fase di esecuzione:
// Unsafe: TypeScript type assertion, no runtime validation
const { userId, documentId } = req.body as { userId: string; documentId: string };
// Safe: Zod parse, throws on invalid input
const schema = z.object({
userId: z.string().uuid(),
documentId: z.string().uuid(),
});
const { userId, documentId } = schema.parse(req.body);
L’intervento è consistito nell’aggiungere schemi Zod a ogni route API priva di validazione e nell’introdurre un middleware condiviso attraversato da tutte le route. Questo modello è importante perché impedisce che il livello di validazione venga aggirato da un percorso di codice aggiunto senza conoscere il requisito.
Una route accettava un parametro redirectUrl usato in una chiamata res.redirect(). Si tratta di una vulnerabilità di reindirizzamento aperto: un aggressore può creare un link all’applicazione che, dopo un’interazione apparentemente legittima, reindirizza gli utenti a un sito di phishing. La correzione consiste nel verificare la destinazione rispetto a un elenco di percorsi consentiti:
const ALLOWED_REDIRECT_PATHS = ['/dashboard', '/settings', '/profile'];
const redirectPath = schema.parse(req.query).redirectUrl;
if (!ALLOWED_REDIRECT_PATHS.includes(redirectPath)) {
return res.redirect('/dashboard');
}
return res.redirect(redirectPath);
Terza fase: header HTTP di sicurezza e CSP
L’applicazione era del tutto priva di header Content Security Policy (CSP). Senza una CSP, un’iniezione XSS riuscita, tramite contenuti generati dagli utenti o uno script di terzi compromesso, può eseguire JavaScript arbitrario nel contesto dell’applicazione, con accesso ai token di autenticazione e ai dati degli utenti.
Aggiungere una CSP a un’applicazione Next.js richiede attenzione perché Next.js incorpora alcuni script in linea per impostazione predefinita. L’approccio consigliato usa nonce: valori casuali crittograficamente sicuri, generati per ogni richiesta, aggiunti all’header CSP e applicati a ogni script in linea. Next.js applica il nonce ai propri script leggendo la policy dagli header della richiesta, quindi il middleware in produzione deve anche inoltrare la CSP (e di solito un header x-nonce) tramite NextResponse.next({ request: { headers } }), come mostra la guida CSP di Next.js; il frammento seguente imposta solo gli header di risposta. Inoltre importa il modulo crypto di Node, che non è disponibile nel runtime Edge; in quell’ambiente occorre usare l’API Web Crypto globale (per esempio crypto.randomUUID()):
// middleware.ts
import { NextResponse } from 'next/server';
import crypto from 'crypto';
export function middleware(request: Request) {
const nonce = crypto.randomBytes(16).toString('base64');
const cspHeader = [
`default-src 'self'`,
`script-src 'self' 'nonce-${nonce}' 'strict-dynamic'`,
`style-src 'self' 'unsafe-inline'`,
`img-src 'self' data: https:`,
`connect-src 'self' https://api.example.com`,
`frame-ancestors 'none'`,
].join('; ');
const response = NextResponse.next();
response.headers.set('Content-Security-Policy', cspHeader);
response.headers.set('X-Frame-Options', 'DENY');
response.headers.set('X-Content-Type-Options', 'nosniff');
response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
return response;
}
La prima distribuzione della CSP ha usato la modalità Content-Security-Policy-Report-Only, che segnala le violazioni senza bloccarle. Sono così emersi diversi script in linea legittimi e origini di terze parti da inserire nell’elenco consentito prima di attivare il blocco. Eseguire la modalità di sola segnalazione per una settimana prima di applicare la policy è un approccio comune: saltare questo passaggio in genere compromette funzionalità che non erano emerse nei test.
L’audit ha inoltre rilevato che l’header Strict-Transport-Security era impostato dal bilanciatore di carico, ma non dall’applicazione. È accettabile se il bilanciatore è l’unico punto di ingresso, ma va verificato esplicitamente. La difesa in profondità richiede che l’applicazione imposti i propri header di sicurezza anche quando li fornisce già l’infrastruttura.
Quarta fase: limiti alla frequenza delle richieste
Gli endpoint di autenticazione, per accesso, registrazione e reimpostazione della password, non avevano limiti alla frequenza delle richieste. Senza questi limiti accettano richieste illimitate, consentendo attacchi di forza bruta alle password e l’individuazione degli indirizzi email validi attraverso le differenze nelle risposte.
Nelle route API Next.js, la limitazione della frequenza delle richieste può essere implementata con un contatore basato su Redis; l’esempio seguente usa una finestra fissa, più semplice di una finestra mobile. Poiché esegue INCR e EXPIRE con chiamate separate, un errore tra le due può lasciare una chiave senza scadenza. Il codice in produzione dovrebbe quindi eseguire entrambe le operazioni in modo atomico (per esempio in una transazione o uno script Lua). La chiave è tipicamente l’indirizzo IP o, per gli endpoint autenticati, l’ID utente. Per gli endpoint di autenticazione è appropriato un limite basato sull’IP, perché l’utente non è ancora autenticato:
// lib/rateLimit.ts
import { Redis } from '@upstash/redis';
const redis = new Redis({ url: process.env.UPSTASH_URL!, token: process.env.UPSTASH_TOKEN! });
export async function checkRateLimit(identifier: string, limit: number, windowSeconds: number) {
const key = `rate_limit:${identifier}`;
const count = await redis.incr(key);
if (count === 1) {
await redis.expire(key, windowSeconds);
}
return { allowed: count <= limit, remaining: Math.max(0, limit - count) };
}
Sono stati applicati limiti prudenti: i tentativi di accesso sono limitati per IP in ogni finestra di quindici minuti; le richieste di reimpostazione della password sono limitate per indirizzo email ogni ora. Questi limiti sono abbastanza permissivi da essere raggiunti solo raramente dagli utenti legittimi durante l’uso normale e abbastanza restrittivi da rendere impraticabili gli attacchi automatizzati di forza bruta.
Quinta fase: protezione dagli attacchi basati sui tempi di risposta
Gli attacchi basati sui tempi di risposta degli endpoint di autenticazione consentono di dedurre se un indirizzo email è registrato, misurando la differenza tra «email non trovata» ed «email trovata, password errata». Una ricerca che trova un utente richiede un po’ più tempo di una che non lo trova, perché il confronto della password, tramite bcrypt o Argon2, viene eseguito solo quando l’utente esiste. Questa differenza, tipicamente tra 50 e 200 ms, è misurabile con un numero sufficiente di richieste.
La mitigazione standard consiste nell’eseguire sempre il confronto con l’hash della password, usando un hash fittizio quando l’utente non esiste:
const DUMMY_HASH = '$2b$12$dummyhashvaluethatisvalidbutnevermatchesanypassword';
export async function verifyLogin(email: string, password: string) {
const user = await db.user.findUnique({ where: { email } });
// Always run bcrypt comparison to normalize response time
const hashToCompare = user?.passwordHash ?? DUMMY_HASH;
const isValid = await bcrypt.compare(password, hashToCompare);
if (!user || !isValid) {
return null; // Same code path for both failure cases
}
return user;
}
Il valore DUMMY_HASH qui sopra è un segnaposto: non è un hash bcrypt valido di 60 caratteri e librerie come bcryptjs restituiscono immediatamente false per hash malformati, vanificando la normalizzazione dei tempi. In pratica, occorre generare un hash reale una sola volta all’avvio (per esempio con bcrypt.hash() su una stringa casuale), usando lo stesso fattore di costo degli hash memorizzati. Il problema non riguardava l’endpoint di accesso integrato di Supabase; si presentava in una route di autenticazione personalizzata, aggiunta dall’applicazione per gestire un’integrazione legacy. Qualsiasi logica di autenticazione personalizzata che non usi le funzioni di autenticazione di Supabase deve normalizzare esplicitamente i tempi di risposta.
Cosa è cambiato e cosa è rimasto invariato
Ogni rilievo delle cinque fasi è stato risolto prima di considerare concluso l’audit. Le priorità erano chiare: la configurazione errata di RLS e il reindirizzamento aperto sono stati classificati come critici e corretti immediatamente. La distribuzione della CSP e i limiti alla frequenza delle richieste hanno avuto priorità alta e sono stati introdotti nello stesso sprint. La normalizzazione dei tempi di risposta sulla route legacy ha avuto priorità media ed è stata distribuita nello sprint successivo.
L’audit ha prodotto anche modifiche ai processi: le nuove tabelle richiedono ora una revisione delle policy RLS prima di poter integrare la pull request; le route API hanno un wrapper di validazione Zod condiviso, incluso nel modello di route; l’header CSP viene generato da una configurazione centralizzata anziché da stringhe in linea.
Il risultato più rilevante non è stata una singola correzione, ma l’inventario. Sapere esattamente quali dati contiene ogni tabella, quale policy RLS la governa e quali route API vi accedono è la base per prendere decisioni di sicurezza corrette mentre l’applicazione evolve. Senza questo inventario, la sicurezza è reattiva: si corregge ciò che si trova. Con l’inventario diventa una proprietà su cui si può ragionare consapevolmente.
Approfondimenti correlati
- Ripristino di emergenza per servizi gestiti sulla propria infrastruttura — il livello di backup e ripristino che affianca il rafforzamento della sicurezza: cosa succede quando i controlli falliscono nonostante tutti gli sforzi.
- Sviluppare un’app di preparazione agli esami con migliaia di domande — le policy RLS di Supabase in un’applicazione di produzione, incluso il modello di accesso multi-tenant.