# 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.

- Autore / Author: Diego Betto (https://diegobetto.com/about/)
- Pubblicato / Published: 2026-10-09
- URL: https://diegobetto.com/sicurezza-server-actions-react-server-components/
- Tag: react, sicurezza, server components, nextjs, typescript

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](https://diegobetto.com/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

```tsx
// 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} />;
}
```

```tsx
// 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.

```tsx
// 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.

```ts
// 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 un `assert` sul grafo degli import.
- **Il DTO è costruito campo per campo**, non con uno spread o un `omit`. Se domani qualcuno aggiunge una colonna `twoFactorSecret` alla tabella, un `omit(user, ["passwordHash"])` la lascerebbe passare; una lista esplicita no.
- **`cache()`** deduplica le chiamate nello stesso render: puoi chiamare `getCurrentUser()` 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. Una `NEXT_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.

```tsx
// ❌ 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:

```ts
// 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!,
);
```

```ts
// 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`:

```js
// 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:

```tsx
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:

```tsx
// 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
}
```

```ts
// 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.

```ts
"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:

```ts
// ✅ 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:

```ts
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:

```ts
// 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`](https://diegobetto.com/react-useactionstate-useformstatus/) il pattern è identico: si valida il `FormData` invece di un oggetto.

```ts
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:

```js
// 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](https://github.com/react/react/releases) 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](https://diegobetto.com/sicurezza-supply-chain-nodejs/) — 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:

1. **File `'use client'`**: le props sono minime e tipizzate? Qualcuno riceve un record intero (`user`, `order`, `session`)?
2. **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?
3. **Accesso ai dati**: database e `process.env` sono usati solo dentro una DAL marcata con `server-only`?
4. **Variabili `NEXT_PUBLIC_*`**: contengono solo valori che pubblicheresti su GitHub?
5. **Closure**: le action inline catturano solo identificatori, non dati riservati?
6. **Errori**: nessun `catch` restituisce `error.message` o stack trace al client?
7. **Segmenti dinamici** (`[id]`, `[slug]`): i parametri sono validati come qualsiasi input?
8. **Middleware/`proxy.ts`**: è usato come _unico_ controllo di accesso da qualche parte?
9. **Dipendenze**: le versioni risolte di `react-server-dom-*` e del framework includono le patch degli advisory recenti?
10. **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](https://diegobetto.com/react-server-components/); per la parte browser, la [Content Security Policy](https://diegobetto.com/csp-content-security-policy/) completa il quadro.

**Riferimenti**

- [How to think about data security in Next.js](https://nextjs.org/docs/app/guides/data-security)
- [experimental_taintObjectReference – documentazione React](https://react.dev/reference/react/experimental_taintObjectReference)
- [experimental_taintUniqueValue – documentazione React](https://react.dev/reference/react/experimental_taintUniqueValue)
- [Critical Security Vulnerability in React Server Components – React Blog](https://react.dev/blog/2025/12/03/critical-security-vulnerability-in-react-server-components)
- [GHSA-rv78-f8rc-xrxh – Denial of Service in React Server Components](https://github.com/react/react/security/advisories/GHSA-rv78-f8rc-xrxh)
- [OWASP – Insecure Direct Object Reference Prevention Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Insecure_Direct_Object_Reference_Prevention_Cheat_Sheet.html)
