
Sicurezza di Server Actions e React Server Components: data leak, validazione e taint API
Come proteggere Server Actions e React Server Components: endpoint impliciti, validazione degli input, autorizzazione, variabili d'ambiente, taint API e CVE recenti.
Con React 19 e le architetture full-stack, il confine tra client e server è diventato una riga di testo: 'use client' da una parte, 'use server' dall’altra. È comodo — un componente legge dal database, un form chiama una funzione sul server senza scrivere un’API — ma è proprio questa comodità a spostare il problema. Il confine c’è ancora, solo che non lo vedi più. E quello che non vedi, di solito, non lo proteggi.
In questo articolo guardiamo i due rischi principali delle applicazioni costruite con React Server Components: dati sensibili che finiscono al client senza che nessuno lo abbia deciso e Server Actions che sono endpoint pubblici senza sembrarlo. Gli esempi usano Next.js App Router, il framework RSC più diffuso, ma i principi valgono per qualsiasi implementazione.
Il modello mentale: due flussi che attraversano la rete
Un’app RSC ha due canali tra server e browser, ed entrambi sono serializzazione su HTTP:
| Direzione | Cosa viaggia | Rischio principale |
|---|---|---|
| Server → client | Props dei Client Component, valori di ritorno, closure | Data leak: mandi più di quanto serve |
| Client → server | Argomenti delle Server Actions, FormData |
Input non fidato su un endpoint pubblico |
Tutto quello che un Server Component passa a un Client Component finisce nel payload RSC: puoi leggerlo nel tab Network del browser, anche se il componente non lo mostra a schermo. E tutto quello che una Server Action riceve arriva da una richiesta POST che chiunque può costruire a mano, con curl, senza passare dalla tua UI.
Tenere a mente queste due frasi risolve già metà dei problemi.
Rischio 1: i dati che “scivolano” al client
Il pattern sbagliato più comune
// app/profile/[slug]/page.tsx — Server Component
import { db } from "@/lib/db";
import ProfileCard from "./profile-card";
export default async function Page({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const user = await db.user.findUnique({ where: { slug } });
// ❌ l'intero record attraversa il confine: passwordHash, email,
// stripeCustomerId, flag interni... tutto nel payload RSC
return <ProfileCard user={user} />;
}
// app/profile/[slug]/profile-card.tsx
"use client";
export default function ProfileCard({ user }: { user: User }) {
return <h2>{user.name}</h2>; // usa solo il nome, ma riceve tutto
}
ProfileCard mostra solo il nome, ma il browser riceve l’oggetto intero. Nessun errore, nessun warning: il codice funziona, i test passano, e l’hash della password è lì nel sorgente della risposta.
La correzione è strutturale, non cosmetica: i Client Component devono dichiarare props minime, e i dati devono essere ridotti prima di arrivare al render.
// profile-card.tsx
"use client";
type ProfileCardProps = { name: string; avatarUrl: string | null };
export default function ProfileCard({ name, avatarUrl }: ProfileCardProps) {
// ...
}
Un tipo stretto sulle props è già una difesa: TypeScript non ti impedisce di passare un oggetto più grande (lo structural typing lo accetta), ma ti obbliga a scrivere name={user.name} invece di user={user}, e quell’attrito è esattamente quello che serve.
Una Data Access Layer con DTO
Per progetti nuovi, la raccomandazione della documentazione di Next.js è centralizzare l’accesso ai dati in una Data Access Layer (DAL): un modulo che gira solo sul server, fa i controlli di autorizzazione e restituisce DTO — oggetti con i soli campi che la UI può vedere.
// data/users.ts
import "server-only";
import { cache } from "react";
import { db } from "@/lib/db";
import { getCurrentUser } from "./auth";
export type PublicProfileDTO = {
name: string;
avatarUrl: string | null;
email: string | null; // visibile solo a sé stessi o agli admin
};
export const getPublicProfile = cache(async (slug: string): Promise<PublicProfileDTO | null> => {
const [user, viewer] = await Promise.all([
db.user.findUnique({ where: { slug } }),
getCurrentUser(),
]);
if (!user) return null;
const canSeeEmail = viewer?.id === user.id || viewer?.role === "admin";
return {
name: user.name,
avatarUrl: user.avatarUrl,
email: canSeeEmail ? user.email : null,
};
});
Tre dettagli che contano:
import "server-only"fa fallire la build se il modulo viene importato, anche indirettamente, da un file'use client'. È l’equivalente di unassertsul grafo degli import.- Il DTO è costruito campo per campo, non con uno spread o un
omit. Se domani qualcuno aggiunge una colonnatwoFactorSecretalla tabella, unomit(user, ["passwordHash"])la lascerebbe passare; una lista esplicita no. cache()deduplica le chiamate nello stesso render: puoi chiamaregetCurrentUser()in dieci componenti senza dieci query, e senza la tentazione di passare l’utente corrente come prop di componente in componente (che è il modo più rapido per farlo arrivare a un Client Component).
Variabili d’ambiente
Le variabili d’ambiente sono il caso più insidioso, perché il bundler le tratta in due modi diversi:
- In Next.js, quelle con prefisso
NEXT_PUBLIC_vengono inlinate nel bundle JavaScript al momento della build. Sono pubbliche per definizione, anche se le usi in un Server Component. UnaNEXT_PUBLIC_STRIPE_SECRET_KEYè un incidente, non una configurazione. - Le altre sono disponibili solo sul server — ma un Server Component può comunque leggerle e passarle come prop, e a quel punto viaggiano nel payload come qualsiasi altra stringa.
// ❌ il segreto attraversa il confine come normale prop
export default async function Page() {
return <MapWidget apiKey={process.env.MAPS_SERVER_KEY} />;
}
Due regole pratiche: solo la DAL legge process.env (così un audit si riduce a cercare process.env fuori da data/), e i segreti non vengono mai passati, vengono usati sul server — se il client deve chiamare un servizio esterno, il server gli fa da proxy o emette un token a scadenza breve con permessi limitati.
Le taint API: una rete di sicurezza
React offre due API per marcare valori che non devono mai arrivare a un Client Component. Se succede, il render lancia un errore invece di serializzarli:
// data/env.ts
import "server-only";
import { experimental_taintObjectReference, experimental_taintUniqueValue } from "react";
// l'oggetto process.env intero non può essere passato a un Client Component
experimental_taintObjectReference(
"Non passare l'intero process.env al client.",
process.env,
);
// un valore specifico: (messaggio, lifetime, valore)
experimental_taintUniqueValue(
"La chiave del servizio di pagamento non deve arrivare al client.",
process,
process.env.PAYMENT_SECRET_KEY!,
);
// data/users.ts
export async function getUserRecord(id: string) {
const user = await db.user.findUnique({ where: { id } });
if (user) {
experimental_taintObjectReference("Usa getPublicProfile() per i dati da mostrare.", user);
experimental_taintUniqueValue("Hash della password", user, user.passwordHash);
}
return user;
}
Il secondo argomento di taintUniqueValue è il lifetime: il valore resta “contaminato” finché quell’oggetto è vivo. Per i segreti di processo si usa process; per un campo di un record, il record stesso.
In Next.js si abilitano con experimental.taint in next.config:
// next.config.mjs
export default {
experimental: {
taint: true,
},
};
⚠ Cosa il tainting non fa
Le taint API sono ancora sperimentali (in React stabile non sono esportate) e proteggono solo
quel riferimento o quel valore esatto. Una copia dell’oggetto ({ ...user }) non è contaminata; un
valore derivato (secret.slice(0, 10), secret.toUpperCase()) nemmeno. Non sostituiscono DTO e
props minime: sono una cintura di sicurezza per quando qualcuno, tra sei mesi, scriverà
user={user} per fretta.
Le closure delle Server Actions inline
Una Server Action definita dentro un Server Component può catturare variabili dal suo scope:
export default async function Page() {
const internalPrice = await getInternalCost(); // dato riservato
async function applyDiscount() {
"use server";
// usa internalPrice...
}
return <form action={applyDiscount}>...</form>;
}
Per far funzionare la closure, le variabili catturate vengono inviate al client e rimandate al server a ogni invocazione. Next.js le cifra automaticamente con una chiave generata a ogni build, ma la stessa documentazione sconsiglia di affidarsi solo alla cifratura. Regola semplice: nelle closure delle action catturi identificatori (un id, una versione), mai dati riservati; il dato lo rileggi dentro l’action.
Se fai self-hosting su più istanze, imposta NEXT_SERVER_ACTIONS_ENCRYPTION_KEY (una chiave AES in base64, per esempio da openssl rand -base64 32) così tutte le istanze condividono la stessa chiave, e trattala come qualsiasi altro segreto: fuori dal repository, con rotazione.
Rischio 2: le Server Actions sono endpoint pubblici
Questa è la frase da attaccare sopra la scrivania: ogni funzione esportata da un file 'use server' e usata dall’app è un endpoint HTTP POST raggiungibile da chiunque. Non c’è un router.post() a ricordartelo, non c’è un file in api/, ma l’endpoint c’è.
Next.js mitiga qualcosa: gli ID delle action non sono deterministici e vengono rigenerati tra una build e l’altra, e le action non usate vengono eliminate dal bundle (niente ID, niente endpoint). Ma un’action usata da un qualsiasi Client Component ha il suo ID nel JavaScript scaricato dal browser, e da lì una richiesta costruita a mano è banale.
Autenticazione: dentro l’action, sempre
L’errore classico è pensare che la protezione della pagina protegga anche le action che contiene:
// app/admin/page.tsx
export default async function AdminPage() {
const session = await auth();
if (session?.user.role !== "admin") redirect("/login"); // protegge la UI...
return <DeleteAllButton />; // ...non l'action che il bottone chiama
}
// app/admin/actions.ts
"use server";
export async function deleteAllRecords() {
// ❌ nessun controllo: chiunque conosca l'ID dell'action può chiamarla
await db.record.deleteMany();
}
Il redirect decide quale UI renderizzare; l’action è un punto d’ingresso separato. Lo stesso vale per il middleware (in Next.js 16 rinominato proxy.ts): è un buon posto per redirect e header, non l’unico posto per l’autorizzazione. La vulnerabilità CVE-2025-29927 del 2025, che permetteva di saltare il middleware con un header costruito ad arte, è il promemoria più chiaro del perché il controllo deve stare accanto al dato.
Autorizzazione: autenticato non vuol dire autorizzato
Verificare che l’utente sia loggato non basta. Il problema più frequente nelle action è l’IDOR (Insecure Direct Object Reference): l’action riceve un ID e lo usa senza chiedersi se quell’utente può toccare quella risorsa.
"use server";
export async function deletePost(postId: string) {
const session = await auth();
if (!session) throw new Error("Unauthorized");
// ❌ qualsiasi utente loggato può cancellare qualsiasi post
await db.post.delete({ where: { id: postId } });
}
L’ID arriva dal client: un attaccante lo cambia con quello di un post altrui. Il controllo di proprietà va fatto sul server, idealmente nella stessa query:
// ✅ cancella solo se il post appartiene all'utente
const { count } = await db.post.deleteMany({
where: { id: postId, authorId: session.user.id },
});
if (count === 0) throw new Error("Forbidden");
Validazione: i tipi TypeScript non esistono a runtime
Questa firma dà un falso senso di sicurezza:
export async function updateQuantity(itemId: string, quantity: number) { ... }
Gli argomenti di una Server Action sono deserializzati da una richiesta HTTP. TypeScript non è presente a runtime: quantity può arrivare come -5, 1e308, una stringa, un oggetto annidato. Ogni action va trattata come un handler di API pubblica, con validazione esplicita. Con Zod:
// app/cart/actions.ts
"use server";
import { z } from "zod";
import { auth } from "@/lib/auth";
import { updateCartItem } from "@/data/cart";
const UpdateQuantitySchema = z.object({
itemId: z.uuid(),
quantity: z.number().int().min(1).max(99),
});
type ActionResult = { ok: true } | { ok: false; error: string };
export async function updateQuantity(input: unknown): Promise<ActionResult> {
const session = await auth();
if (!session) return { ok: false, error: "Devi effettuare l'accesso." };
const parsed = UpdateQuantitySchema.safeParse(input);
if (!parsed.success) return { ok: false, error: "Dati non validi." };
await updateCartItem(session.user.id, parsed.data.itemId, parsed.data.quantity);
return { ok: true };
}
Nota la firma input: unknown: è onesta. Dichiarare (itemId: string, quantity: number) descrive ciò che speri di ricevere; unknown ti costringe a dimostrarlo.
Con i form e useActionState il pattern è identico: si valida il FormData invece di un oggetto.
const ContactSchema = z.object({
email: z.email(),
message: z.string().trim().min(10).max(5000),
});
export async function sendMessage(_prev: ActionResult, formData: FormData): Promise<ActionResult> {
const parsed = ContactSchema.safeParse(Object.fromEntries(formData));
if (!parsed.success) return { ok: false, error: "Controlla i campi del modulo." };
// ...
}
💡 Mass assignment
Mai passare l’input validato direttamente all’ORM con uno spread (data: { ...input }): se lo
schema non è strict, campi in più come role: "admin" o credits: 99999 potrebbero arrivare
fino al database. Zod per default scarta le chiavi sconosciute con z.object, ma la difesa più
robusta è elencare esplicitamente i campi che l’action può scrivere.
Valori di ritorno ed errori
Anche il ritorno di un’action viene serializzato verso il client, quindi valgono le stesse regole dei DTO: restituisci { ok: true } o i soli campi aggiornati, non il record che l’ORM ti restituisce dopo un update.
Per gli errori, distingui tra errori attesi (validazione, permessi), da restituire come valori con un messaggio pensato per l’utente, ed eccezioni impreviste, da lasciar lanciare: in produzione React e Next.js non inviano al client il messaggio originale di un errore lanciato sul server, ma solo un identificatore (digest) da correlare con i log. Un catch che fa return { error: e.message } vanifica questa protezione e può rivelare dettagli di query, path o configurazione.
CSRF e origini
Le Server Actions accettano solo POST, e Next.js confronta l’header Origin con Host (o X-Forwarded-Host): se non coincidono, la richiesta viene rifiutata. Insieme ai cookie SameSite=Lax di default nei browser moderni, copre la maggior parte degli scenari CSRF.
Il punto delicato sono i reverse proxy: se l’app è servita dietro un dominio diverso da quello che vede il server, la soluzione corretta è dichiarare le origini ammesse, non disattivare il controllo:
// next.config.mjs
export default {
experimental: {
serverActions: {
allowedOrigins: ["app.example.com", "*.example.com"],
},
},
};
Fuori da Next.js, verifica cosa fa il tuo framework: a gennaio 2026 React Router e Remix hanno corretto una vulnerabilità CSRF proprio nelle action lato server (CVE-2026-22030, risolta in react-router 7.12.0).
Rate limiting
Un’action che manda email, crea record o chiama un’API a pagamento è un bersaglio per l’abuso automatizzato esattamente come un endpoint REST. Il rate limiting va applicato per utente (o per IP, per le action pubbliche) dentro la DAL o in un helper comune, non affidato al fatto che “c’è un bottone solo”.
Il protocollo stesso: aggiornare è una misura di sicurezza
Tutto quello visto finora riguarda il tuo codice. Ma tra fine 2025 e il 2026 il protocollo RSC (il formato “Flight” con cui viaggiano payload e argomenti) è stato oggetto di una serie di vulnerabilità nei pacchetti react-server-dom-webpack, react-server-dom-parcel e react-server-dom-turbopack:
| CVE | Data | Tipo |
|---|---|---|
| CVE-2025-55182 | Dicembre 2025 | Remote code execution (CVSS 10.0) |
| CVE-2026-23864 | Gennaio 2026 | Denial of service |
| CVE-2026-23869 | Aprile 2026 | Denial of service (consumo di CPU) |
| CVE-2026-23870 | Maggio 2026 | Denial of service (memoria/CPU) |
La prima, ribattezzata “React2Shell”, permetteva a un attaccante non autenticato di eseguire codice sul server con una singola richiesta verso un endpoint di Server Function — senza che l’applicazione definisse alcuna action vulnerabile: bastava che il framework esponesse il protocollo. Le successive sono DoS, ma con un dettaglio importante: ogni patch ha corretto la precedente, quindi chi ha aggiornato una volta e poi ha smesso di seguire gli advisory è rimasto esposto. Le correzioni per CVE-2026-23870 sono arrivate con 19.0.6, 19.1.7 e 19.2.6 (6 maggio 2026): sono il minimo indispensabile, non il punto d’arrivo. Al momento in cui scrivo le release più recenti di ogni linea sono 19.0.8, 19.1.9 e 19.2.8 (21 luglio 2026), che includono anche correzioni alla gestione di FormData nelle Server Actions, e 19.3.0 (9 settembre 2026). Conviene stare sull’ultima patch della propria linea e controllare la pagina delle release e gli advisory a ogni aggiornamento del framework.
⚠ Controlla l'albero delle dipendenze, non solo package.json
I pacchetti react-server-dom-* sono spesso dipendenze transitive o vendorizzate dal framework
(Next.js ne include una copia). Aggiornare react in package.json non basta: aggiorna il
framework e verifica la versione effettivamente risolta con npm ls react-server-dom-webpack (o
l’equivalente per il tuo bundler). Le tecniche di hardening della supply chain — lockfile, audit automatici, Dependabot/Renovate — qui non sono un extra.
Checklist di audit
Se devi revisionare un progetto RSC, questi sono i punti da controllare file per file:
- File
'use client': le props sono minime e tipizzate? Qualcuno riceve un record intero (user,order,session)? - File
'use server': ogni action esportata autentica l’utente, verifica l’autorizzazione sulla risorsa, valida gli argomenti con uno schema e restituisce solo ciò che serve? - Accesso ai dati: database e
process.envsono usati solo dentro una DAL marcata conserver-only? - Variabili
NEXT_PUBLIC_*: contengono solo valori che pubblicheresti su GitHub? - Closure: le action inline catturano solo identificatori, non dati riservati?
- Errori: nessun
catchrestituisceerror.messageo stack trace al client? - Segmenti dinamici (
[id],[slug]): i parametri sono validati come qualsiasi input? - Middleware/
proxy.ts: è usato come unico controllo di accesso da qualche parte? - Dipendenze: le versioni risolte di
react-server-dom-*e del framework includono le patch degli advisory recenti? - Rete di sicurezza:
experimental.taintè attivo e i record sensibili sono contaminati nella DAL?
FAQ
❓ Le Server Actions sono sicure di default?
No, sono comode di default. Il framework gestisce il trasporto, la cifratura delle closure e una protezione CSRF di base, ma ogni action resta un endpoint pubblico: autenticazione, autorizzazione, validazione e filtro dei dati restituiti sono responsabilità tua, come per qualsiasi API.
❓ Se una funzione non è importata da nessun Client Component, è raggiungibile?
In Next.js le action non usate vengono eliminate in fase di build e non ricevono un ID, quindi non
sono raggiungibili. Ma è un dettaglio di implementazione del framework, non una garanzia del
modello: scrivi ogni funzione in un file 'use server' come se fosse pubblica, e tieni la logica
riservata in moduli server-only senza direttiva.
❓ Posso usare le taint API in produzione?
Sono disponibili nelle build sperimentali di React; Next.js le espone tramite experimental.taint.
Valuta il rischio come per qualsiasi flag sperimentale. Anche quando diventeranno stabili, restano
una seconda linea di difesa: una copia o una trasformazione del valore le aggira.
❓ Basta TypeScript per validare gli argomenti di un'action?
No. I tipi spariscono dopo la compilazione e gli argomenti arrivano da una richiesta HTTP che chiunque può modificare. Serve una validazione a runtime (Zod, Valibot, ArkType o controlli manuali) all’inizio di ogni action.
❓ Il middleware di Next.js è sufficiente per proteggere le action?
No. Il middleware (proxy.ts da Next.js 16) è utile per redirect e controlli rapidi, ma non deve
essere l’unico punto di autorizzazione: le verifiche vanno ripetute dentro l’action o nella DAL,
accanto all’accesso ai dati.
Conclusioni
React Server Components e Server Actions non introducono categorie di vulnerabilità nuove: data leak, IDOR, input non validato, CSRF sono problemi vecchi quanto il web. Quello che cambia è che il confine dove di solito li cerchiamo — il controller, la route API — è diventato implicito. La strategia che funziona è renderlo di nuovo esplicito: una DAL server-only che è l’unica a toccare dati e segreti, DTO costruiti campo per campo, action sottili che autenticano, autorizzano e validano come qualsiasi handler pubblico, e un framework tenuto aggiornato con la stessa attenzione che dedichi al tuo codice.
Per le basi su direttive e componenti, parti dall’articolo sui React Server Components; per la parte browser, la Content Security Policy completa il quadro.
Riferimenti
-
How to think about data security in Next.js
-
experimental_taintObjectReference – documentazione React
-
experimental_taintUniqueValue – documentazione React
-
Critical Security Vulnerability in React Server Components – React Blog
-
GHSA-rv78-f8rc-xrxh – Denial of Service in React Server Components
-
OWASP – Insecure Direct Object Reference Prevention Cheat Sheet

Co-Fondatore & CTO presso PAPION. Senior full-stack engineer specializzato in React, TypeScript, Node.js e sicurezza applicativa.