Módulo 22: Seguridad
Objetivos del módulo
- Hacer el inventario de lo que una aplicación Next.js expone a Internet: páginas, payload RSC, Server Actions, Route Handlers y el propio
proxy.ts - Detectar las fugas de datos por la frontera entre Server y Client Components, y cerrarlas con una capa de acceso a datos, DTO y
server-only - Saber qué variables de entorno acaban en el navegador, por qué
NEXT_PUBLIC_se congela en el build y cómo un Client Component prerenderizado puede filtrar un secreto aunque no lleve el prefijo - Usar las APIs de taint de React (
experimental_taintObjectReference,experimental_taintUniqueValue) y conocer sus límites reales, comprobados en el laboratorio - Entender el ataque CSRF contra una Server Action, reproducirlo con un navegador de verdad y configurar
serverActions.allowedOriginssin abrir agujeros - Añadir límites de tamaño y de frecuencia a las acciones
- Configurar las cabeceras de seguridad habituales y quitar las que sobran
- Escribir una Content Security Policy con nonce en
proxy.ts, y entender por qué choca con el prerender y con Cache Components - Conocer las vulnerabilidades de React Server Components de diciembre de 2025 (React2Shell y siguientes) y el programa de parches de seguridad de Next.js
- Blindar la zona de gestión de EMPRESA aplicando todas las capas a la vez
💡 Consejo: Este módulo parte del módulo 10, cuyo apartado 13 ya demostró que una Server Action se puede llamar con
curl, que Next rechaza unOriginajeno y un identificador inventado, y que una acción que nadie importa no recibe identificador. No lo repetimos: aquí vamos más hondo. La autenticación (sesiones, cookies firmadas, roles) se estudia en el módulo 17, yproxy.tsen el módulo 13.
1. La superficie de ataque de una aplicación Next.js
Cuando programas con Next.js es fácil olvidar dónde está el servidor. Escribes un componente, importas una función de la base de datos, llamas a una acción desde un botón… y todo parece un único programa. No lo es. Hay un servidor en Internet que recibe peticiones HTTP, y cualquiera puede enviarle peticiones: tu interfaz, pero también curl, un script o la página de un atacante que tu usuario tiene abierta en otra pestaña.
Figura 1. Las puertas de entrada de una aplicación Next.js y los riesgos de cada una.
Repasemos cada puerta:
| Puerta | Qué recibe | Qué devuelve | Riesgo principal |
|---|---|---|---|
proxy.ts | Todas las peticiones que coinciden con su matcher | Lo que decida (seguir, redirigir, reescribir) | Que se crea la única barrera; un fallo aquí lo abre todo |
GET de una página | La URL, cookies y cabeceras | HTML y el payload RSC (también con la cabecera RSC: 1 o ?_rsc=) | Fugas: todo lo que pasas a un Client Component viaja al navegador |
POST con Next-Action | El identificador de la acción y sus argumentos | El valor devuelto y el árbol actualizado | Acciones sin control: son endpoints públicos |
| Route Handlers | Cualquier método HTTP | Lo que programes | Lo mismo que cualquier API REST (módulo 12) |
/_next/image y estáticos | URLs de imágenes y ficheros | Ficheros | SSRF y DoS si abres remotePatterns sin cuidado |
Y por debajo de todas ellas está el propio framework: el código de Next.js y de React que interpreta esas peticiones. En diciembre de 2025 se descubrió que un fallo en ese código permitía ejecutar código remoto en el servidor con una sola petición (lo vemos en el apartado 8). Contra eso no protege ninguna buena práctica tuya; solo el parche.
📘 Concepto: La frontera servidor-cliente (la directiva
'use client') no es solo una frontera de ejecución: es una frontera de seguridad. Lo que queda del lado del servidor (código, variables de entorno, consultas, datos) no lo ve nadie. Lo que cruza la frontera (props de Client Components, valores devueltos por acciones, variablesNEXT_PUBLIC_) lo ve cualquiera que abra las herramientas del navegador. Casi todos los errores de seguridad de una aplicación Next.js consisten en pasar algo al otro lado sin darse cuenta.
El resto del módulo recorre esas puertas de dentro hacia fuera: primero lo que se te puede escapar en los datos (apartados 2 a 4), después lo que te pueden hacer las peticiones (5), lo que configura el navegador (6 y 7) y, por último, lo que depende del framework (8 y 9).
1.1. El laboratorio de este módulo
Todos los ejemplos se han ejecutado en un proyecto de Next.js 16.4 con la base de datos SQLite de EMPRESA del módulo 16 (esquema de Drizzle y script de carga), compilado con next build y servido con next start. Las rutas son:
| Ruta | Para qué |
|---|---|
/fuga/mal, /fuga/bien | Fuga de datos por las props y su corrección con un DTO |
/taint/... | Seis casos de taint |
/entorno | Variables de entorno en un Client Component |
/csrf | Acciones con sesión, CSRF y límite de intentos |
/csp/estatica, /csp/dinamica, /csp/completa | CSP con nonce y prerender |
next build las clasifica así (salida real):
Route (app)
┌ ○ /
├ ○ /_not-found
├ ◐ /csp/completa
├ ◐ /csp/dinamica
├ ○ /csp/estatica
├ ◐ /csrf
├ ○ /entorno
├ ◐ /fuga/bien
├ ◐ /fuga/mal
├ ◐ /taint/cache
├ ◐ /taint/campos
├ ◐ /taint/copia
├ ◐ /taint/directo
├ ◐ /taint/env
└ ◐ /taint/valor
ƒ Proxy (Middleware)
Guarda esta tabla: en el apartado 7 veremos que los símbolos ○ y ◐ deciden si la CSP con nonce funciona o rompe la página.
2. Fugas por la frontera servidor-cliente
2.1. Lo que no pintas también viaja
Empezamos con el error más común y más silencioso. Tenemos una ficha de empleado, un Client Component que solo muestra nombre, apellidos y oficio:
// app/taint/FichaEmpleado.tsx
'use client'
type Props = {
empleado: { nombre: string; apellidos: string; oficio: string }
}
// Solo pinta tres campos... pero recibe lo que le pasen
export function FichaEmpleado({ empleado }: Props) {
return (
<div className="my-4 rounded border border-slate-300 bg-white p-4">
<p className="font-semibold">
{empleado.nombre} {empleado.apellidos}
</p>
<p className="text-sm text-slate-600">{empleado.oficio}</p>
</div>
)
}
Y una página que lee el empleado de la base de datos y se lo pasa:
// lib/dal/empleados.ts (fragmento)
import 'server-only'
import { eq } from 'drizzle-orm'
import { db } from '@/lib/db'
import { empleados } from '@/db/schema'
// Fila completa de la tabla: incluye salario, comisión, email y teléfono
export async function leerEmpleadoCrudo(empId: number) {
const [fila] = await db.select().from(empleados).where(eq(empleados.empId, empId)).limit(1)
return fila ?? null
}
// app/fuga/mal/page.tsx
import { Suspense } from 'react'
import { connection } from 'next/server'
import { leerEmpleadoCrudo } from '@/lib/dal/empleados'
import { FichaEmpleado } from '@/app/taint/FichaEmpleado'
async function Contenido() {
await connection()
const empleado = await leerEmpleadoCrudo(101)
if (!empleado) return <p>No existe</p>
// La ficha solo pinta nombre, apellidos y oficio... pero recibe la fila entera
return <FichaEmpleado empleado={empleado} />
}
export default function Pagina() {
return (
<main>
<h1 className="text-2xl font-bold">Ficha de empleado (con fuga)</h1>
<Suspense fallback={<p>Cargando…</p>}>
<Contenido />
</Suspense>
</main>
)
}
TypeScript no protesta: la fila tiene nombre, apellidos y oficio, y que tenga más propiedades no le importa (es tipado estructural). La página se ve perfecta. Pero busquemos en el HTML que recibe el navegador:
curl -s http://127.0.0.1:5522/fuga/mal | grep -o '\\"salario\\":[0-9]*\|\\"email\\":\\"[^\\]*'
\"email\":\"luis.martinez@empresa.es
\"salario\":4800

Figura 2. La interfaz muestra tres campos; el código fuente de la página lleva la fila entera, salario incluido.
¿Por qué? Porque para hidratar FichaEmpleado en el navegador, React necesita sus props. El servidor las serializa en el payload RSC, que va dentro del HTML en las llamadas self.__next_f.push(...) (y en las navegaciones posteriores, en la respuesta text/x-component). React no sabe qué propiedades vas a usar: manda todas.
⚠️ Importante: “No lo muestro” no significa “no lo envío”. Cualquier valor que pases como prop a un Client Component, a cualquier profundidad, lo puede leer cualquiera con “Ver código fuente”. Esto vale también para el valor que devuelve una Server Action.
2.2. La solución: una capa de acceso a datos con DTO
La documentación de Next.js recomienda una capa de acceso a datos (Data Access Layer, DAL): un único sitio, solo de servidor, donde se hacen las consultas, se comprueban los permisos y se decide qué forma tienen los datos que salen. A esos objetos recortados se les llama DTO (Data Transfer Object).
// lib/dal/empleados.ts (continuación)
// DTO: lo único que la ficha pública necesita
export type EmpleadoPublico = { empId: number; nombre: string; apellidos: string; oficio: string }
export async function leerEmpleadoPublico(empId: number): Promise<EmpleadoPublico | null> {
const [fila] = await db
.select({
empId: empleados.empId,
nombre: empleados.nombre,
apellidos: empleados.apellidos,
oficio: empleados.oficio,
})
.from(empleados)
.where(eq(empleados.empId, empId))
.limit(1)
return fila ?? null
}
La página /fuga/bien es idéntica a la anterior, pero llama a leerEmpleadoPublico. Resultado:
curl -s http://127.0.0.1:5522/fuga/bien | grep -c salario
0
Fíjate en que el recorte se hace en la consulta (select({ ... })): el salario ni siquiera sale de la base de datos. Es mejor que leer la fila entera y copiar campos después, porque no depende de que alguien se acuerde de copiar bien.
💡 Consejo: Pon nombre a los DTO según quién los ve, no según la tabla:
EmpleadoPublico,EmpleadoParaRRHH,EmpleadoParaSuJefe. Así, cuando alguien añade una columnaibana la tabla, no aparece sola en ninguna pantalla.
2.3. server-only: que el módulo no pueda cruzar
La DAL tiene otro peligro: que un Client Component la importe. Si eso ocurriera, el código de las consultas (y lo que lleve dentro) acabaría en el paquete JavaScript del navegador. Para impedirlo, la primera línea de lib/dal/empleados.ts y de lib/db/index.ts es:
import 'server-only'
Lo comprobamos con un componente que importa la DAL por error:
// app/malo/ClienteMalo.tsx
'use client'
import { leerEmpleadoPublico } from '@/lib/dal/empleados'
export function ClienteMalo() {
return <button onClick={() => leerEmpleadoPublico(101)}>Leer</button>
}
next build falla (salida real, recortada):
./lib/dal/empleados.ts:1:1
Error: 'server-only' cannot be imported from a Client Component module
> 1 | import 'server-only'
| ^^^^^^^^^^^^^^^^^^^^
2 | import { experimental_taintObjectReference, experimental_taintUniqueValue } from 'react'
3 | import { eq } from 'drizzle-orm'
4 | import { cacheLife } from 'next/cache'
It should only be used from a Server Component.
Import traces:
Server Component:
./lib/dal/empleados.ts
./app/taint/valor/page.tsx
Client Component Browser:
./lib/dal/empleados.ts [Client Component Browser]
./app/malo/ClienteMalo.tsx [Client Component Browser]
./app/malo/ClienteMalo.tsx [Server Component]
./app/malo/page.tsx [Server Component]
El error es útil por dos razones: falla en el build, antes de que nada llegue a producción, y la traza de importación te dice exactamente qué Client Component ha tirado del hilo. En el mismo build aparecieron otros errores relacionados (que cacheLife y 'use cache' no se pueden usar en un Client Component), pero el de server-only es el que explica la causa.
📘 Concepto: Next.js trata
server-onlyyclient-onlyde forma interna: no hace falta que el paquete esté instalado para que funcione (el laboratorio lo tiene enpackage.jsonporque alguna regla de ESLint se queja de dependencias no declaradas). El módulo 05 explica la parejaserver-only/client-onlycon más detalle.
¿Qué módulos deben llevar import 'server-only'? Como mínimo:
- El cliente de la base de datos (
lib/db/index.ts). - Toda la DAL.
- Los módulos que leen secretos (
process.env.CLAVE_...) o hablan con APIs internas. - Las utilidades de sesión:
lib/sesion.tsylib/dal/auth.tsdel módulo 17, que ya lo llevan.
⚠️ Importante: Un fichero con
'use server'no necesitaserver-onlypara que su código se quede en el servidor: el compilador ya lo sustituye por referencias. Pero cuidado con lo contrario: un fichero'use server'convierte cada función exportada en un endpoint público. No pongas'use server'en tu DAL “para que sea de servidor”: lo que conseguirías es que cualquiera pudiera llamar aleerEmpleadoCrudodesde fuera. La DAL llevaserver-only; las acciones,'use server'.
3. Secretos y variables de entorno
3.1. Dónde vive cada variable
Next.js carga las variables de .env, .env.local, .env.production, etc. al arrancar next dev y next build (verás - Environments: .env.local en la salida). La regla es sencilla:
| Variable | Dónde está disponible | Cuándo se lee |
|---|---|---|
CLAVE_API_PAGOS (sin prefijo) | Solo en el servidor | En tiempo de ejecución, cada vez que el código hace process.env.CLAVE_API_PAGOS |
NEXT_PUBLIC_VERSION_APP | Servidor y navegador | En el build: el compilador sustituye el texto process.env.NEXT_PUBLIC_VERSION_APP por su valor |
Lo comprobamos con este .env.local:
CLAVE_API_PAGOS=sk_prueba_7f3a9c21
NEXT_PUBLIC_VERSION_APP=2.1.0
Después de next build, buscamos los dos valores en el JavaScript que se envía al navegador (.next/static):
grep -rl "2.1.0" .next/static | head -3
grep -rl "sk_prueba_7f3a9c21" .next/static | wc -l
.next/static/chunks/1d_whc88dmqd_.js
.next/static/chunks/1_gb6zsgtb0sm.js
.next/static/chunks/2y7v2ornklfk6.js
0
La versión pública está escrita dentro de los ficheros JavaScript; la clave no aparece en ninguno. Hasta aquí, lo esperado.
⚠️ Importante: Como
NEXT_PUBLIC_se sustituye en el build, cambiar la variable en el servidor de producción no cambia nada hasta que vuelvas a compilar. Y por la misma razón, un valorNEXT_PUBLIC_es público para siempre: está en ficheros que las CDN guardan en caché y que cualquiera puede descargar. Jamás pongas el prefijo a una clave, aunque sea “de pruebas”.
3.2. La sorpresa: un Client Component prerenderizado
Ahora un Client Component que intenta leer las dos variables:
// app/entorno/VerEntorno.tsx
'use client'
export function VerEntorno() {
return (
<ul className="my-4 list-disc pl-6">
<li>
NEXT_PUBLIC_VERSION_APP: <code>{String(process.env.NEXT_PUBLIC_VERSION_APP)}</code>
</li>
<li>
CLAVE_API_PAGOS: <code>{String(process.env.CLAVE_API_PAGOS)}</code>
</li>
</ul>
)
}
// app/entorno/page.tsx
import { VerEntorno } from './VerEntorno'
export default function Entorno() {
return (
<main>
<h1 className="text-2xl font-bold">Variables de entorno vistas desde el navegador</h1>
<VerEntorno />
</main>
)
}
Lo esperable sería ver undefined en la clave. Pero mira el HTML:
curl -s http://127.0.0.1:5522/entorno | grep -o 'CLAVE_API_PAGOS: <code>[^<]*'
grep -rl "sk_prueba_7f3a9c21" .next/server | head -3
CLAVE_API_PAGOS: <code>sk_prueba_7f3a9c21
.next/server/app/entorno.html
.next/server/route-cache/APP_PAGE/338ea420.../$/entorno.html
La clave está en el HTML. Y en un fichero estático que se sirve igual a todos los visitantes. Con un navegador, este es el resultado (script de Playwright del laboratorio):
HTML recibido contiene la clave: true
Texto tras hidratar: NEXT_PUBLIC_VERSION_APP: 2.1.0 | CLAVE_API_PAGOS: undefined
¿Qué ha pasado? Los Client Components también se ejecutan en el servidor para generar el HTML inicial (el módulo 02 lo explicó con el prerender). La página /entorno es estática (○), así que next build ejecutó VerEntorno en Node.js, donde process.env.CLAVE_API_PAGOS sí existe, y escribió el resultado en entorno.html. Luego, en el navegador, el paquete JavaScript no tiene la variable, React hidrata y el texto pasa a undefined. Pero el daño ya está hecho: el secreto viajó en el HTML.
📘 Concepto: La documentación lo dice así: los Client Components “se ejecutan en el servidor durante el prerender, pero deben seguir las mismas suposiciones de seguridad que el código que corre en el navegador”. Traducción práctica: un Client Component no debe leer jamás una variable sin
NEXT_PUBLIC_. Que en el navegador valgaundefinedno te protege, porque el HTML se genera en el servidor.
La solución es no leer secretos fuera de módulos server-only. Si un Client Component necesita algo derivado de un secreto (por ejemplo, una URL firmada), un Server Component o una acción lo calcula en el servidor y le pasa solo el resultado.
3.3. Secretos escritos en el código
Otra mala costumbre es escribir el secreto directamente en el fichero:
// ❌ app/pagos/acciones.ts
'use server'
const CLAVE = 'sk_live_51H...' // "solo está en el servidor, no pasa nada"
export async function cobrar(importe: number) {
// ...
}
Hay tres motivos para no hacerlo:
- El código va al repositorio, y el repositorio lo leen más personas (y más herramientas) que el servidor.
- Rotar la clave obliga a cambiar código y desplegar, en lugar de cambiar una variable.
- Una de las vulnerabilidades de diciembre de 2025 (CVE-2025-55183) permitía obtener el código compilado de las Server Functions con una petición manipulada. El aviso oficial lo explica: los secretos podían quedar expuestos “si están definidos directamente en tu código (en lugar de leerse de variables de entorno en tiempo de ejecución) y se usan dentro de una Server Function”, porque el compilador puede incrustarlos en el código generado. Con
process.env.CLAVEleído en tiempo de ejecución, el código filtrado solo habría mostrado el nombre de la variable.
3.4. Buenas prácticas con secretos
| Práctica | Por qué |
|---|---|
.env.local en .gitignore (la plantilla ya lo hace) | Los secretos no van al repositorio |
Un .env.example con los nombres y valores falsos | El equipo sabe qué variables hacen falta |
| Secretos distintos en desarrollo y producción | Si se filtra el de desarrollo, producción sigue a salvo |
Leer los secretos solo en módulos server-only | Ningún Client Component los toca |
| Validar las variables al arrancar (con Zod, por ejemplo) | Un secreto que falta da un error claro, no un undefined silencioso |
| Rotar los secretos tras un incidente | Tras React2Shell, Vercel recomendó rotarlos todos (apartado 8) |
⚠️ Importante:
serverRuntimeConfigypublicRuntimeConfigdesaparecieron en Next.js 16. Si encuentras código antiguo que los use para secretos, sustitúyelo porprocess.envleído en módulosserver-only. El módulo 13 explica las variables de entorno con todas sus variantes.
4. Taint: una red de seguridad para los datos
El DTO y server-only evitan las fugas por diseño. Pero en un equipo grande alguien acabará pasando un objeto completo a un Client Component. React ofrece una red de seguridad para ese caso: marcar (taint, “contaminar”) un objeto o un valor para que React se niegue a enviarlo al cliente.
4.1. Activarlo
Las APIs son experimentales y hay que activarlas en next.config.ts:
// next.config.ts (fragmento)
const nextConfig: NextConfig = {
experimental: {
taint: true,
},
}
Al compilar, Next lo anuncia:
- Experiments (use with caution):
· serverActions
✓ taint
La documentación avisa de que esta opción activa el canal experimental de React para el directorio app (React trae las funciones experimental_* solo en ese canal), y de que además marca process.env para que no se pueda pasar entero a un Client Component.
Los tipos de TypeScript tampoco están en el paquete normal: hay que pedir los experimentales. Basta un fichero en la raíz del proyecto:
// react-experimental.d.ts
// Activa los tipos de las APIs experimentales de React (taint)
/// <reference types="react/experimental" />
Sin él, import { experimental_taintObjectReference } from 'react' no compila.
4.2. experimental_taintObjectReference: marcar un objeto
// lib/dal/empleados.ts (fragmento)
import { experimental_taintObjectReference } from 'react'
// La misma fila, marcada para que React se niegue a enviarla al cliente
export async function leerEmpleadoProtegido(empId: number) {
const fila = await leerEmpleadoCrudo(empId)
if (fila) {
experimental_taintObjectReference(
'No pases la fila completa del empleado a un Client Component: elige los campos o usa leerEmpleadoPublico().',
fila,
)
}
return fila
}
El primer argumento es el mensaje del error que se lanzará; el segundo, el objeto. Ahora repetimos el error del apartado 2.1:
// app/taint/directo/page.tsx (fragmento)
async function Contenido() {
await connection()
const empleado = await leerEmpleadoProtegido(101)
if (!empleado) return <p>No existe</p>
// ❌ La fila marcada entera hacia un Client Component
return <FichaEmpleado empleado={empleado} />
}
En el registro de next start:
⨯ Error: No pases la fila completa del empleado a un Client Component: elige los campos o usa leerEmpleadoPublico().
at ignore-listed frames {
digest: '2662292918'
}
Y el navegador recibe la página de error genérica de producción, con el digest para buscarlo en los registros (como vimos en el módulo 14):

Figura 3. Con taint, el intento de fuga se convierte en un error visible en lugar de un dato filtrado. El navegador no recibe ni el salario ni el mensaje.
Es mucho mejor un error que una fuga silenciosa: el error salta en desarrollo o en las pruebas, y alguien lo corrige.
Usar campos sueltos sí está permitido, porque los valores primitivos ('Luis', 'DIRECTOR') no están marcados:
// app/taint/campos/page.tsx (fragmento)
const empleado = await leerEmpleadoProtegido(101)
if (!empleado) return <p>No existe</p>
// ✅ Un objeto nuevo con los campos elegidos
const { nombre, apellidos, oficio } = empleado
return <FichaEmpleado empleado={{ nombre, apellidos, oficio }} />
Esta página responde 200 y su HTML no contiene salario.
4.3. Lo que taint no ve
La marca va unida a la referencia del objeto, no a su contenido. Una copia es un objeto nuevo, sin marca:
// app/taint/copia/page.tsx (fragmento)
const empleado = await leerEmpleadoProtegido(101)
if (!empleado) return <p>No existe</p>
// ⚠️ Una copia: taint vigila la referencia, no el contenido
return <FichaEmpleado empleado={{ ...empleado }} />
Resultado real de las seis páginas del laboratorio (si el HTML contiene salario y si hubo error):
| Ruta | Qué pasa al Client Component | ¿salario en el HTML? | ¿Error? |
|---|---|---|---|
/taint/directo | La fila marcada | No | Sí |
/taint/campos | Objeto nuevo con tres campos | No | No |
/taint/copia | { ...empleado } | Sí | No |
/taint/cache | La fila marcada dentro de 'use cache' | No | Sí |
/taint/valor | Una cadena marcada con taintUniqueValue | No | Sí |
/taint/env | process.env entero | No | Sí |
La fila de /taint/copia es la lección: el spread, structuredClone, JSON.parse(JSON.stringify(...)), un .map() que crea objetos nuevos… cualquier transformación produce un objeto sin marca. Lo mismo con los valores derivados: si marcas un objeto y alguien calcula fila.salario * 14, ese número no está marcado.
4.4. experimental_taintUniqueValue: marcar un valor
Para secretos sueltos (tokens, claves) existe la segunda API, que marca un valor concreto, no un objeto:
// lib/dal/empleados.ts (fragmento)
import { experimental_taintUniqueValue } from 'react'
// Configuración con un secreto: marcamos el valor, no el objeto
export function leerConfiguracionPagos() {
const config = {
urlPasarela: 'https://pagos.example/api',
clave: process.env.CLAVE_API_PAGOS ?? 'sin-clave',
}
experimental_taintUniqueValue(
'La clave de la pasarela de pagos no puede salir del servidor.',
config,
config.clave,
)
return config
}
Recibe tres argumentos: el mensaje, un objeto de vida (mientras ese objeto exista en memoria, la marca sigue activa) y el valor. Pasar config.urlPasarela a un Client Component funciona; pasar config.clave lanza Error: La clave de la pasarela de pagos no puede salir del servidor.
El laboratorio reveló un detalle que conviene entender. Como las cadenas se comparan por valor, la marca no se aplica a “la propiedad clave”, sino a la cadena 'sk_prueba_7f3a9c21' en cualquier parte del servidor. La ruta /taint/env?caso=variable pasa process.env.CLAVE_API_PAGOS directamente a un Client Component. Con el servidor recién arrancado:
curl /taint/env?caso=variable → el HTML contiene sk_prueba_7f3a9c21 (no hay marca todavía)
curl /taint/valor → error (se ejecuta leerConfiguracionPagos y se marca el valor)
curl /taint/env?caso=variable → error: "La clave de la pasarela de pagos no puede salir del servidor."
La misma petición fuga o no fuga según lo que se haya ejecutado antes en el proceso, y según cuándo el recolector de basura libere el objeto de vida. Por eso taint es una red de seguridad, no un mecanismo en el que confiar: no puedes probar que una fuga “no ocurre” con una sola petición.
💡 Consejo: Si quieres que un secreto quede marcado siempre, márcalo una vez, al cargar el módulo, con un objeto de vida que no muera nunca (por ejemplo, el propio objeto del módulo de configuración). Así la marca existe desde la primera petición.
4.5. process.env entero
Con experimental.taint activado, Next marca el objeto process.env. Pasarlo a un Client Component lanza un error con mensaje propio de Next:
⨯ Error: Do not pass process.env to Client Components since it will leak sensitive data
La documentación precisa los límites: la marca protege la referencia al objeto. Pasar process.env.MI_VARIABLE (una cadena suelta) o una copia { ...process.env } no se detecta, salvo que esa cadena esté marcada por otro lado, como acabamos de ver.
4.6. Taint y 'use cache' 🆕 16.0
🆕 Desde Next.js 16.0. No existe en Next.js 15 (allí
'use cache'solo existe tras la opción experimentaluseCache). Si usas Next.js 15: conunstable_cache, aplica el taint fuera de la función cacheada, sobre el resultado que devuelve, dentro de la función de la DAL que la llama; esa forma funciona igual en cualquier versión.
¿Sobrevive la marca a una función con 'use cache'? Los resultados cacheados se serializan para guardarlos, así que cabía esperar que el objeto que sale de la caché fuera una copia sin marca:
// Igual, pero dentro de una función con 'use cache'
export async function leerEmpleadoProtegidoCacheado(empId: number) {
'use cache'
cacheLife('minutes')
const fila = await leerEmpleadoCrudo(empId)
if (fila) {
experimental_taintObjectReference('Fila de empleado (versión en caché)', fila)
}
return fila
}
En el laboratorio (con la caché en memoria por defecto de next start), /taint/cache dio el error Fila de empleado (versión en caché) en la primera petición y en las siguientes, cuando el dato ya salía de la caché. La marca se mantuvo. Aun así, no lo convertiría en una regla: con otro manejador de caché (Redis, una plataforma de hosting) el objeto se reconstruye a partir de bytes, y lo prudente es aplicar el taint después de leer de la caché, en una función normal de la DAL que envuelva a la cacheada.
4.7. Cuándo usar taint
La documentación de Next.js lo deja claro: no confíes en taint como único mecanismo. Úsalo así:
| Situación | Herramienta principal | Taint como refuerzo |
|---|---|---|
| Tus propias consultas | DTO en la DAL (select con los campos justos) | Marca las funciones que devuelven filas completas, por si acaso |
| Datos de una biblioteca o API que no controlas | Copia los campos que necesitas | Marca el objeto que devuelve la biblioteca |
| Tokens y claves | Leerlos solo en módulos server-only | taintUniqueValue al cargar el módulo |
process.env | No pasarlo nunca | Next lo marca solo con experimental.taint |
5. Server Actions a fondo
En el módulo 10 vimos la lista de lo que Next hace por ti (solo POST, comprobación de origen, límite de tamaño, identificadores difíciles de adivinar, eliminación de acciones sin usar, cifrado de cierres) y la de lo que te toca a ti. Ahora nos metemos en tres de esas piezas con ataques reales: CSRF, tamaño y frecuencia.
5.1. Los identificadores no son un secreto
Los identificadores de acción ($ACTION_ID_402c878e...) aparecen en el HTML de cualquier página que tenga un formulario con una acción. Cualquiera puede leerlos:
curl -s http://127.0.0.1:5522/csrf | grep -o 'name="\$ACTION_ID_[0-9a-f]*"'
name="$ACTION_ID_402c878e7a1961227c4124e9d8a6fb2ef44bcbb5ce"
name="$ACTION_ID_409ef08fbe0b1f40b169c7cecebe541d9a44dc5dd6"
Además, la publicación de seguridad de julio de 2026 corrigió un fallo (CVE-2026-64643) por el que los identificadores de Server Actions y de funciones use cache se podían obtener de forma global “para reconocimiento y como parte de una cadena de ataque más amplia”. Que el identificador sea largo y cambie en cada despliegue dificulta adivinarlo, pero la seguridad de una acción no puede depender de que nadie conozca su identificador. Cada acción comprueba sesión, permisos y datos como si la llamara un desconocido, porque puede ser así.
5.2. Qué es un ataque CSRF
CSRF (Cross-Site Request Forgery, falsificación de peticiones entre sitios) funciona así:
- Ana entra en la gestión de EMPRESA. Su navegador guarda la cookie de sesión.
- Sin cerrar la pestaña, visita una web maliciosa (“¡Has ganado un premio!”).
- Esa web contiene un formulario oculto que se envía solo, con
method="post", hacia la aplicación de EMPRESA. - Si el navegador adjunta la cookie de Ana a esa petición, el servidor ve una petición autenticada y cancela el pedido. Ana no ha hecho nada.
Lo interesante es que un formulario HTML corriente puede invocar una Server Action: es lo que hace la mejora progresiva cuando el JavaScript no ha cargado. El formulario sin JavaScript lleva un campo oculto con el identificador de la acción. El atacante solo tiene que copiarlo:
<!-- Página del atacante en http://atacante.example/ -->
<form id="f" method="post" action="http://127.0.0.1:5522/csrf" enctype="multipart/form-data">
<input type="hidden" name="$ACTION_ID_409ef08fbe0b1f40b169c7cecebe541d9a44dc5dd6" value="">
<input type="hidden" name="pedidoId" value="5023">
</form>
<script>document.getElementById('f').submit()</script>
5.3. La acción que vamos a atacar
// app/csrf/acciones.ts
'use server'
import { cookies, headers } from 'next/headers'
import { redirect } from 'next/navigation'
import { registrar, superaLimite } from '@/lib/registro'
export async function cancelarPedido(formData: FormData) {
const sesion = (await cookies()).get('sesion')?.value
const origen = (await headers()).get('origin') ?? '(sin origin)'
const pedidoId = Number(formData.get('pedidoId'))
if (sesion !== 'demo') {
registrar(`RECHAZADA: sin sesión (origin ${origen})`)
redirect('/csrf?error=sesion')
}
if (!Number.isInteger(pedidoId) || pedidoId < 5001) {
registrar(`RECHAZADA: pedido no válido (origin ${origen})`)
redirect('/csrf?error=datos')
}
registrar(`EJECUTADA: pedido ${pedidoId} cancelado (origin ${origen})`)
redirect('/csrf')
}
export async function entrar(formData: FormData) {
const cabeceras = await headers()
const ip = cabeceras.get('x-forwarded-for')?.split(',')[0].trim() ?? 'local'
if (superaLimite(`entrar:${ip}`)) {
registrar(`LÍMITE: demasiados intentos desde ${ip}`)
redirect('/csrf?error=limite')
}
if (formData.get('clave') !== 'demo') {
redirect('/csrf?error=clave')
}
;(await cookies()).set('sesion', 'demo', {
httpOnly: true,
sameSite: 'lax',
secure: process.env.NODE_ENV === 'production' && process.env.HTTPS === '1',
path: '/',
maxAge: 60 * 60,
})
registrar(`Sesión iniciada desde ${ip}`)
redirect('/csrf')
}
La “sesión” de este apartado es una cookie fija, sesion=demo, sin firmar: la usamos solo para aislar el experimento de CSRF. En la aplicación real, la sesión es el JWT firmado con jose del módulo 17 (crearSesion, obtenerUsuario), y es la que usa el caso práctico del apartado 10. Los atributos que importan aquí son los mismos en los dos casos: httpOnly y sameSite: 'lax'. La diferencia es secure: el módulo 17 lo activa siempre en producción, y comprobó que con next start funciona en 127.0.0.1 porque los navegadores tratan ese origen como seguro; esta demo lo deja desactivado salvo que se indique HTTPS=1, pero no cambia nada del ataque. lib/registro.ts guarda en memoria las últimas líneas para poder ver qué acciones se ejecutaron:
// lib/registro.ts
import 'server-only'
// Registro en memoria de las acciones ejecutadas (solo para el laboratorio)
const entradas: string[] = []
export function registrar(texto: string) {
entradas.unshift(`${new Date().toISOString().slice(11, 19)} ${texto}`)
console.log(`[registro] ${texto}`)
}
export function leerRegistro() {
return entradas.slice(0, 10)
}
// Límite de intentos por clave (IP) en una ventana de tiempo
const intentos = new Map<string, number[]>()
export function superaLimite(clave: string, maximo = 5, ventanaMs = 60_000) {
const ahora = Date.now()
const recientes = (intentos.get(clave) ?? []).filter((t) => ahora - t < ventanaMs)
recientes.push(ahora)
intentos.set(clave, recientes)
return recientes.length > maximo
}
Y la página, con los dos formularios y el registro:
// app/csrf/page.tsx
import { Suspense } from 'react'
import { connection } from 'next/server'
import { leerRegistro } from '@/lib/registro'
import { cancelarPedido, entrar } from './acciones'
async function Registro({ error }: { error: Promise<string | undefined> }) {
await connection()
const e = await error
return (
<>
{e && <p className="text-red-700">Error: {e}</p>}
<ol id="registro" className="mt-4 list-decimal pl-6 font-mono text-sm">
{leerRegistro().map((linea, i) => (
<li key={i}>{linea}</li>
))}
</ol>
</>
)
}
export default function Csrf({ searchParams }: PageProps<'/csrf'>) {
const error = searchParams.then((s) => (typeof s.error === 'string' ? s.error : undefined))
return (
<main>
<h1 className="text-2xl font-bold">Pedidos (demo CSRF)</h1>
<form action={entrar} className="my-4 flex gap-2">
<input name="clave" type="password" placeholder="clave" className="border px-2" />
<button className="rounded bg-slate-700 px-3 py-1 text-white">Entrar</button>
</form>
<form action={cancelarPedido} className="my-4 flex gap-2">
<input name="pedidoId" defaultValue="5024" className="w-24 border px-2" />
<button className="rounded bg-red-700 px-3 py-1 text-white">Cancelar pedido</button>
</form>
<Suspense fallback={<p>Cargando registro…</p>}>
<Registro error={error} />
</Suspense>
</main>
)
}
Fíjate en que searchParams no se espera arriba de la página: se pasa la promesa al componente que está dentro de <Suspense> (con cacheComponents, esperar arriba rompe el build, como vimos en el módulo 10).
5.4. El ataque, con un navegador de verdad
Para reproducirlo sin montar otro servidor, el script pruebas/csrf.mjs usa Playwright: contexto.route('http://atacante.example/**', ...) hace que el navegador “visite” esa dirección y reciba la página trampa, como si existiera de verdad. El guion es:
- El usuario entra en
http://127.0.0.1:5522/csrfy escribe la clave: recibe la cookie. - Visita
http://atacante.example/, que envía el formulario oculto. - Visita
http://admin.empresa.test/, con la misma trampa (enseguida verás por qué). - Vuelve a la aplicación y cancela un pedido con el botón, de forma legítima.
El script imprime cada POST que llega a la aplicación, con su cabecera Origin y la cookie que adjuntó el navegador. Salida real:
-> 200
POST /csrf origin=http://127.0.0.1:5522 cookie=(ninguna)
Cookie de sesión: sesion sameSite=Lax httpOnly=true
== Visita a http://atacante.example/
POST /csrf origin=http://atacante.example cookie=(ninguna)
-> 500
== Visita a http://admin.empresa.test/
POST /csrf origin=http://admin.empresa.test cookie=(ninguna)
-> 303
== Clic legítimo en "Cancelar pedido"
POST /csrf origin=http://127.0.0.1:5522 cookie=sesion=demo
-> 200
Registro del servidor:
13:00:23 EJECUTADA: pedido 5024 cancelado (origin http://127.0.0.1:5522)
13:00:21 RECHAZADA: sin sesión (origin http://admin.empresa.test)
13:00:18 Sesión iniciada desde 127.0.0.1
Y en el registro de next start, para la visita a atacante.example:
`x-forwarded-host` header with value `127.0.0.1:5522` does not match `origin` header with value
`atacante.example` from a forwarded Server Actions request. Aborting the action.
⨯ Error: Invalid Server Actions request.
Hay dos defensas independientes funcionando a la vez:
| Defensa | Quién la pone | Qué hizo en la prueba |
|---|---|---|
| Comprobación de origen | Next.js | Con Origin: atacante.example, abortó antes de ejecutar la acción (500, y nada en el registro) |
Cookie SameSite=Lax | Tú, al crear la cookie | El navegador no adjuntó la cookie en ninguno de los dos POST desde otro sitio (cookie=(ninguna)) |
Y una tercera, la tuya: la acción comprueba la sesión. En la visita a admin.empresa.test la comprobación de origen dejó pasar la petición (enseguida veremos por qué), la acción se ejecutó, pero no había cookie y la rechazó: RECHAZADA: sin sesión.
📘 Concepto:
SameSite=Lax(el valor por defecto de los navegadores modernos cuando la cookie no dice nada, y el que debes poner tú explícitamente) significa: “envía esta cookie en peticiones de otro sitio solo si son navegaciones de nivel superior con métodos seguros (un enlace, unGET)”. UnPOSTdesde un formulario de otro sitio no la lleva.SameSite=Strictes aún más estricto (ni siquiera la manda al seguir un enlace desde otro sitio) yNonela manda siempre (y exigeSecure). Repasa las cookies en el Curso de JavaScript.
5.5. serverActions.allowedOrigins
¿Por qué pasó admin.empresa.test? Porque el next.config.ts del laboratorio lo autoriza:
// next.config.ts (fragmento)
experimental: {
serverActions: {
allowedOrigins: ['admin.empresa.test'],
bodySizeLimit: '1mb',
},
},
La comprobación de Next compara el host de la cabecera Origin con el de la aplicación, tomado de X-Forwarded-Host o, si no está, de Host. Si no coinciden, solo deja pasar los que estén en allowedOrigins.
¿Para qué sirve, entonces? Para los casos legítimos en los que el navegador está en un dominio y el servidor ve otro:
- Un proxy inverso o una CDN que no reenvía el host público en
X-Forwarded-Host: el navegador mandaOrigin: empresa.comy el servidor cree que eslocalhost:3000. Hay que añadir el host que ve el navegador, no el interno. - Un túnel de desarrollo remoto (
*.tunnel.example.com). En ese caso la documentación indica que hay que añadirlo también aallowedDevOrigins. - Varias aplicaciones (multi-zones) que se llaman entre sí.
Las reglas de los patrones, según la documentación de la versión instalada:
| Entrada | Coincide con | No coincide con |
|---|---|---|
empresa.com | empresa.com | empresa.com:8443, admin.empresa.com |
*.empresa.com | admin.empresa.com | empresa.com, admin.empresa.com:8443 |
**.empresa.com | admin.empresa.com, a.b.empresa.com | empresa.com |
empresa.com:8443 | empresa.com:8443 | empresa.com |
Solo se compara el host (nombre más puerto si lo hay), no el esquema ni la ruta. No se admiten comodines parciales (app-*.empresa.com), ** solo puede ir al principio, y el puerto no admite comodín. El código de Next.js 16.4 y 15.5 rechaza además los patrones que lo abrirían todo, como * o ** a secas.
⚠️ Importante: Cada entrada de
allowedOriginses un dominio desde el que cualquier página puede invocar tus acciones con la cookie del usuario, si la cookie lo permite. Nunca pongas dominios que no controlas, ni comodines amplios “para que funcione”. Si en producción ves el errorx-forwarded-host ... does not match origin, la solución correcta casi siempre es que tu proxy reenvíeX-Forwarded-Host, no ampliar la lista.
Recuerda lo que vimos en el módulo 10: una petición sin cabecera Origin se deja pasar con un aviso. Los navegadores siempre la envían en un POST, así que esto no abre un agujero CSRF; pero confirma que la comprobación de origen protege a tus usuarios frente a otras webs, no a tu acción frente a un programa. Contra un programa, solo valen la sesión y los permisos.
5.6. Límite de tamaño
Por defecto, el cuerpo de una petición a una acción no puede pasar de 1 MB. Lo probamos enviando un campo de 1,2 MB:
head -c 1200000 /dev/zero | tr '\0' 'a' > grande.txt
curl -s -o /dev/null -w "%{http_code}\n" -X POST http://127.0.0.1:5522/csrf \
-H "Origin: http://127.0.0.1:5522" -F '$ACTION_ID_409ef08f...=' -F "pedidoId=<grande.txt"
500
⨯ Error: Body exceeded 1mb limit.
To configure the body size limit for Server Actions, see: https://nextjs.org/docs/app/api-reference/next-config-js/serverActions#bodysizelimit
statusCode: 413
}
Para subir ficheros grandes, sube el límite solo lo necesario con bodySizeLimit ('5mb', por ejemplo). La documentación recuerda que el límite cuenta el cuerpo en bruto, incluidos los separadores de multipart/form-data, así que deja un margen de 10 a 20 KB. Si necesitas ficheros mucho mayores, súbelos directamente a un almacenamiento de objetos con una URL firmada y que la acción reciba solo la referencia.
5.7. Límite de frecuencia
Las acciones que cuestan algo (enviar correos, comprobar contraseñas, escribir en la base de datos) necesitan un límite de peticiones por usuario o por IP, o alguien las usará para probar contraseñas o para tumbar el servidor. La acción entrar del apartado 5.3 usa superaLimite: cinco intentos por minuto y por IP. Siete intentos con curl (con el formulario sin JavaScript):
for i in 1 2 3 4 5 6 7; do
curl -s -o /dev/null -D - -X POST http://127.0.0.1:5522/csrf \
-H "Origin: http://127.0.0.1:5522" -H "X-Forwarded-For: 203.0.113.7" \
-F '$ACTION_ID_402c878e...=' -F "clave=intento$i" | grep -i '^location\|^HTTP'
done
intento 1: HTTP/1.1 303 See Other Location: /csrf?error=clave
intento 2: HTTP/1.1 303 See Other Location: /csrf?error=clave
intento 3: HTTP/1.1 303 See Other Location: /csrf?error=clave
intento 4: HTTP/1.1 303 See Other Location: /csrf?error=clave
intento 5: HTTP/1.1 303 See Other Location: /csrf?error=clave
intento 6: HTTP/1.1 303 See Other Location: /csrf?error=limite
intento 7: HTTP/1.1 303 See Other Location: /csrf?error=limite
Dos advertencias sobre este limitador de juguete:
- Vive en la memoria de un proceso. Con dos instancias del servidor, cada una cuenta por su lado, y se pierde al reiniciar. En producción se usa un almacén compartido (Redis, la base de datos) o el límite del proxy o la plataforma.
X-Forwarded-Forlo puede escribir el cliente. Fíjate en que elcurlpuso la IP203.0.113.7a mano. Solo es fiable si delante hay un proxy tuyo que sobrescribe esa cabecera. Sinext startestá expuesto directamente a Internet, un atacante cambia de “IP” en cada intento. Además del límite por IP, limita por cuenta (cinco fallos para el usuarioana@..., venga de donde venga).
5.8. Lista de comprobación de cada acción
| Comprobación | Cómo | Dónde lo vimos |
|---|---|---|
| ¿Hay sesión? | Leer y verificar la cookie dentro de la acción | Módulo 17 |
| ¿Puede este usuario hacer esto con este recurso? | Comprobar rol y propiedad (el pedido es de su cartera, el empleado es de su departamento) | Módulo 17 |
| ¿Los argumentos son válidos? | Zod (safeParse), nunca confiar en los tipos de TypeScript | Módulo 11 |
| ¿Recibe identificadores, no objetos? | pedidoId, y releer el pedido en el servidor | Módulo 10 |
| ¿Devuelve solo lo necesario? | Un mensaje o un DTO, nunca la fila | Apartado 2 |
| ¿Tiene límite de frecuencia? | Si es costosa o comprueba secretos | Apartado 5.7 |
¿Usa la DAL server-only? | Nada de consultas sueltas en la acción | Apartado 2.3 |
6. Cabeceras de seguridad
Las cabeceras HTTP de la respuesta le dicen al navegador cómo tratar tu página: si se puede meter en un <iframe>, si debe adivinar el tipo de un fichero, a qué otras webs puede contar de dónde viene el usuario… Configurarlas cuesta unas líneas y cierra ataques enteros.
6.1. Con headers() en next.config.ts
// next.config.ts
import type { NextConfig } from 'next'
// Cabeceras de seguridad para todas las rutas.
// La CSP no va aquí: la pone proxy.ts, porque lleva un nonce distinto en cada petición.
const cabecerasSeguridad = [
{ key: 'X-Content-Type-Options', value: 'nosniff' },
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
{ key: 'X-Frame-Options', value: 'DENY' },
{ key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' },
{ key: 'Strict-Transport-Security', value: 'max-age=63072000; includeSubDomains; preload' },
{ key: 'Cross-Origin-Opener-Policy', value: 'same-origin' },
]
const nextConfig: NextConfig = {
cacheComponents: true,
partialPrefetching: true,
poweredByHeader: false,
experimental: {
taint: true,
serverActions: {
allowedOrigins: ['admin.empresa.test'],
bodySizeLimit: '1mb',
},
},
async headers() {
return [{ source: '/:path*', headers: cabecerasSeguridad }]
},
turbopack: {
rules: {
'*.css': {
loaders: ['@tailwindcss/turbopack'],
as: '*.css',
},
},
},
}
export default nextConfig
Respuesta real de /csp/estatica:
curl -s -D - -o /dev/null http://127.0.0.1:5522/csp/estatica
HTTP/1.1 200 OK
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
X-Frame-Options: DENY
Permissions-Policy: camera=(), microphone=(), geolocation=()
Strict-Transport-Security: max-age=63072000; includeSubDomains; preload
Cross-Origin-Opener-Policy: same-origin
content-security-policy: default-src 'self'; script-src 'self' 'nonce-N2I4YWZi...' 'strict-dynamic'; style-src 'self' 'nonce-N2I4YWZi...'; img-src 'self' blob: data:; font-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; upgrade-insecure-requests;
Vary: rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch, Accept-Encoding
x-nextjs-cache: HIT
x-nextjs-prerender: 1
Cache-Control: s-maxage=31536000
Content-Type: text/html; charset=utf-8
6.2. Qué hace cada una
| Cabecera | Valor recomendado | Qué evita |
|---|---|---|
X-Content-Type-Options | nosniff | Que el navegador “adivine” que un fichero subido como imagen es JavaScript y lo ejecute |
Referrer-Policy | strict-origin-when-cross-origin | Que la URL completa (con identificadores o tokens en la ruta) se envíe a otras webs al seguir un enlace; solo se manda el origen |
X-Frame-Options | DENY | Clickjacking: meter tu página en un <iframe> invisible para que el usuario pulse sin saberlo. Es la versión antigua de frame-ancestors 'none' de la CSP; se ponen las dos para los navegadores viejos |
Permissions-Policy | camera=(), microphone=(), geolocation=() | Que un script (tuyo o de terceros) pida cámara, micrófono o ubicación |
Strict-Transport-Security | max-age=63072000; includeSubDomains; preload | Que el usuario llegue por HTTP sin cifrar; el navegador recordará usar siempre HTTPS durante dos años |
Cross-Origin-Opener-Policy | same-origin | Que una ventana abierta desde otra web conserve una referencia a la tuya (ataques tipo tabnabbing y de canal lateral) |
Content-Security-Policy | Apartado 7 | Inyección de scripts (XSS) y mucho más |
⚠️ Importante:
Strict-Transport-SecurityconincludeSubDomainsypreloades una decisión difícil de deshacer: si un subdominio tuyo aún no tiene HTTPS, dejará de funcionar para los navegadores que hayan visto la cabecera. Empieza con unmax-agecorto (300) y sinpreload, y súbelo cuando todo el dominio sirva HTTPS. Enhttp://127.0.0.1el navegador la ignora, por eso no molesta en desarrollo.
6.3. Quitar lo que sobra: X-Powered-By
Por defecto, Next.js añade a cada respuesta la cabecera X-Powered-By: Next.js. La comprobamos en el laboratorio de Next.js 15, que no la desactiva:
X-Powered-By: Next.js
No es una vulnerabilidad, pero regala información a quien busca servidores de una tecnología concreta (por ejemplo, para probar un fallo recién publicado). Con poweredByHeader: false desaparece, como se ve en la salida del apartado 6.1.
6.4. ¿headers() o proxy.ts?
headers() en next.config.ts | proxy.ts | |
|---|---|---|
| Valor | Fijo, decidido al compilar | Puede cambiar en cada petición |
| Se aplica a | Todas las respuestas que coinciden con source, también los estáticos | Las rutas de su matcher |
| Ideal para | Cabeceras fijas (las de la tabla) | La CSP con nonce, cabeceras que dependen del usuario |
En el laboratorio usamos los dos: las fijas en next.config.ts y la CSP en proxy.ts.
7. Content Security Policy con nonce
7.1. Qué problema resuelve
Un ataque XSS (cross-site scripting) consiste en conseguir que tu página ejecute JavaScript del atacante. React escapa el texto que pintas, así que en una aplicación Next.js normal es difícil, pero no imposible: dangerouslySetInnerHTML con HTML de un usuario, un enlace javascript:, una biblioteca de terceros comprometida, un script de analítica que alguien cambia…
La CSP es una cabecera que le dice al navegador qué scripts (y estilos, imágenes, conexiones…) puede ejecutar. Aunque el atacante consiga meter un <script> en el HTML, el navegador se negará a ejecutarlo si no cumple la política.
La forma más robusta de decir “estos scripts sí, los demás no” es el nonce: un valor aleatorio, distinto en cada respuesta, que va en la cabecera (script-src 'nonce-ABC...') y como atributo en cada <script nonce="ABC..."> legítimo. El atacante no puede adivinarlo, porque cambia en cada petición.
7.2. El nonce en proxy.ts 🔄 16.0
🔄 Cambia en Next.js 16.0. En Next.js 15 el fichero se llama
middleware.tsy la función exportadamiddleware. Si usas Next.js 15: copia el mismo código enmiddleware.tsy renombra la función; en el laboratorio de Next.js 15.5 la CSP se aplicó igual, la página hidrató y el script inyectado quedó bloqueado.
// proxy.ts
import { NextResponse, type NextRequest } from 'next/server'
export function proxy(request: NextRequest) {
// Un nonce nuevo e impredecible en cada petición
const nonce = Buffer.from(crypto.randomUUID()).toString('base64')
const desarrollo = process.env.NODE_ENV === 'development'
const csp = `
default-src 'self';
script-src 'self' 'nonce-${nonce}' 'strict-dynamic'${desarrollo ? " 'unsafe-eval'" : ''};
style-src 'self' ${desarrollo ? "'unsafe-inline'" : `'nonce-${nonce}'`};
img-src 'self' blob: data:;
font-src 'self';
connect-src 'self';
object-src 'none';
base-uri 'self';
form-action 'self';
frame-ancestors 'none';
upgrade-insecure-requests;
`
.replace(/\s{2,}/g, ' ')
.trim()
// 1. Hacia dentro: Next lee la CSP de la petición para poner el nonce a sus scripts
const cabecerasPeticion = new Headers(request.headers)
cabecerasPeticion.set('x-nonce', nonce)
cabecerasPeticion.set('Content-Security-Policy', csp)
const respuesta = NextResponse.next({ request: { headers: cabecerasPeticion } })
// 2. Hacia fuera: el navegador aplica la política
respuesta.headers.set('Content-Security-Policy', csp)
return respuesta
}
export const config = {
matcher: [
{
source: '/((?!api|_next/static|_next/image|favicon.ico).*)',
missing: [
{ type: 'header', key: 'next-router-prefetch' },
{ type: 'header', key: 'purpose', value: 'prefetch' },
],
},
],
}
La parte delicada es que la CSP se pone dos veces:
- En la petición que sigue hacia Next (
NextResponse.next({ request: { headers } })). Next.js busca el patrón'nonce-...'en la cabeceraContent-Security-Policyde la petición y lo pone automáticamente en sus propios<script>: el runtime de React y Next, los paquetes de la página y los scripts en línea que genera. También deja el nonce enx-noncepara que tú lo leas. - En la respuesta, que es la que lee el navegador.
Las directivas, una a una:
| Directiva | Significado |
|---|---|
default-src 'self' | Por defecto, solo recursos de tu propio origen |
script-src 'self' 'nonce-...' 'strict-dynamic' | Solo scripts con el nonce. 'strict-dynamic' permite que esos scripts carguen otros (los trozos que Next carga bajo demanda) y hace que los navegadores modernos ignoren 'self' y las listas de dominios: manda el nonce |
'unsafe-eval' (solo en desarrollo) | React usa eval en desarrollo para reconstruir las trazas de error del servidor. En producción no hace falta |
style-src 'self' 'nonce-...' | Estilos de tu origen o con nonce. En desarrollo se usa 'unsafe-inline' porque la recarga en caliente inyecta estilos |
object-src 'none' | Nada de <object> ni <embed> (vía clásica de ataques) |
base-uri 'self' | Que nadie cambie la URL base con un <base> inyectado |
form-action 'self' | Los formularios solo se envían a tu origen |
frame-ancestors 'none' | Nadie puede meter tu página en un <iframe> |
upgrade-insecure-requests | Pide por HTTPS los recursos enlazados con http:// |
El matcher deja fuera los estáticos (no necesitan CSP) y las peticiones de precarga de <Link>, como recomienda la documentación.
Figura 4. Quién pone el nonce y cuándo. La pieza clave es que el nonce solo existe cuando hay una petición delante.
7.3. El choque con el prerender
Aquí está el problema que hace que muchos proyectos se rindan con la CSP. Mira la Figura 4 de nuevo: el nonce lo pone Next mientras renderiza la respuesta a una petición. Pero una página estática (○) se renderizó en next build, cuando no había petición ni nonce. Al servirla, el proxy genera un nonce nuevo en la cabecera, pero el HTML guardado no lo lleva.
Lo medimos en el laboratorio con las tres páginas de prueba. Las tres tienen el mismo contenido: un Client Component Contador y un <script> que simula un ataque XSS:
// app/csp/estatica/page.tsx
import { Contador } from '../Contador'
// Página sin datos de la petición: Next la prerenderiza en el build (○)
export default function CspEstatica() {
return (
<main>
<h1 className="text-2xl font-bold">CSP con nonce en una página estática</h1>
<Contador />
{/* Simula un script inyectado por un atacante (XSS) */}
<script dangerouslySetInnerHTML={{ __html: "document.body.dataset.xss = 'ejecutado'" }} />
</main>
)
}
// app/csp/Contador.tsx
'use client'
import { useState, useSyncExternalStore } from 'react'
const sinSuscripcion = () => () => {}
export function Contador() {
// false en el HTML del servidor, true en cuanto React hidrata en el navegador
const hidratado = useSyncExternalStore(sinSuscripcion, () => true, () => false)
const [clics, setClics] = useState(0)
return (
<div className="my-4 rounded border border-slate-300 bg-white p-4">
<p id="estado">React hidratado: {hidratado ? 'sí' : 'no'}</p>
<button
id="boton"
className="mt-2 rounded bg-indigo-700 px-3 py-1 text-white"
onClick={() => setClics((c) => c + 1)}
>
Clics: {clics}
</button>
</div>
)
}
/csp/dinamica añade una parte dinámica dentro de <Suspense> que lee el nonce con headers() y lo usa en un script propio:
// app/csp/dinamica/page.tsx
import { Suspense } from 'react'
import { headers } from 'next/headers'
import { Contador } from '../Contador'
async function ScriptPropio() {
// headers() hace dinámica esta parte: se renderiza en cada petición
const nonce = (await headers()).get('x-nonce') ?? undefined
return (
<>
<p>
Nonce de esta respuesta: <code id="nonce">{nonce}</code>
</p>
<script
nonce={nonce}
dangerouslySetInnerHTML={{ __html: "document.body.dataset.legitimo = 'ejecutado'" }}
/>
</>
)
}
export default function CspDinamica() {
return (
<main>
<h1 className="text-2xl font-bold">CSP con nonce en una página con partes dinámicas</h1>
<Contador />
<Suspense fallback={<p>Cargando…</p>}>
<ScriptPropio />
</Suspense>
<script dangerouslySetInnerHTML={{ __html: "document.body.dataset.xss = 'ejecutado'" }} />
</main>
)
}
Y /csp/completa intenta hacer dinámico todo su segmento con un layout que espera a connection() dentro de <Suspense>:
// app/csp/completa/layout.tsx
import { Suspense } from 'react'
import { connection } from 'next/server'
async function Dinamico({ children }: { children: React.ReactNode }) {
await connection()
return <>{children}</>
}
// Todo el segmento espera a la petición
export default function LayoutDinamico({ children }: LayoutProps<'/csp/completa'>) {
return (
<Suspense fallback={null}>
<Dinamico>{children}</Dinamico>
</Suspense>
)
}
Primero contamos los <script> del HTML y cuántos llevan el nonce de la cabecera (pruebas/nonces.sh):
== csp/estatica
cabecera: nonce-OGY0ODVmZDYtOTQ4Ny00YWY1LTk1MTQtOTY0ZGIzNzZiYjY4
scripts: 17 con nonce: 0
== csp/dinamica
cabecera: nonce-YmFkNGZiN2EtOWRjMi00ZDVmLWE5NzgtZmUwODc5YzY0NzRl
scripts: 17 con nonce: 6
== csp/completa
cabecera: nonce-OGQyZTYxMWUtMmRkMC00ZjZiLThkZmMtMjQ4Zjk2ZDBjYjEz
scripts: 16 con nonce: 5
Después, con Chromium (pruebas/csp.mjs), comprobamos si React hidrata, si el botón responde y qué scripts bloquea el navegador:
== /csp/estatica
React hidratado: no | tras un clic: Clics: 0 | dataset: {}
violaciones CSP: 17
- Loading the script 'http://127.0.0.1:5522/_next/static/chunks/080cwphh4dgt6.js' violates the following
Content Security Policy directive: "script-src 'self' 'nonce-OGVj...' 'strict-dynamic'" ...
== /csp/dinamica
React hidratado: no | tras un clic: Clics: 0 | dataset: {"legitimo":"ejecutado"}
violaciones CSP: 11
== /csp/completa
React hidratado: no | tras un clic: Clics: 0 | dataset: {}
violaciones CSP: 11

Figura 5. Página estática con CSP de nonce: el navegador bloquea los propios scripts de Next y la página queda muerta (el botón no responde).
Las tres páginas se rompen. El script del atacante no se ejecuta (dataset no tiene xss), pero tampoco los de Next: la página se ve, pero no hidrata y nada interactivo funciona. En /csp/dinamica la parte que se renderizó con la petición sí recibió el nonce (el script legítimo se ejecutó: "legitimo":"ejecutado"), pero los scripts principales de Next están en el armazón estático (◐), que se generó en el build. Y en /csp/completa, aunque todo el contenido del segmento espera a la petición, el armazón (el <html>, el <head> y los <script> de arranque, que pertenecen al layout raíz) sigue siendo estático.
📘 Concepto: La documentación lo resume en una frase: con nonces, todas las páginas deben renderizarse en cada petición, y el Partial Prerendering es incompatible con la CSP de nonce, porque los scripts del armazón estático no tienen acceso al nonce. Con Cache Components todo lo que no es dinámico se prerenderiza, así que activar la CSP de nonce sin más rompe la aplicación entera. Y fíjate en lo peor:
next buildtermina sin errores. Solo lo descubres al abrir la página.
7.4. Hacer dinámica toda la aplicación 🆕 16.3
🆕 Desde Next.js 16.3. No existe en Next.js 15 (no hay Cache Components ni la opción
instant). Si usas Next.js 15: basta con leerheaders()en el layout raíz, sininstant: todas las rutas pasan aƒ (Dynamic)y la CSP con nonce funciona; comprobado en el laboratorio de 15.5.
La única forma de que el nonce llegue a todos los scripts es que el layout raíz espere a la petición, para que no exista armazón estático. Con Cache Components, si lees headers() en el layout raíz fuera de <Suspense>, el build falla (salida real):
Error: Route "/_not-found": Next.js encountered uncached or runtime data during prerendering.
`fetch(...)`, `cookies()`, `headers()`, `params`, `searchParams`, or `connection()` accessed outside of `<Suspense>`
prevents the route from being prerendered, blocking the page load and leading to a slower user experience.
Ways to fix this:
- [stream] Provide a placeholder with `<Suspense fallback={...}>` around the data access
- [cache] For uncached data (`fetch`, database calls): cache the access with `"use cache"` (does not apply to `connection()`)
- [block] Set `export const instant = false` to allow a blocking route
La tercera vía, instant = false, es exactamente lo que queremos: declarar que esa ruta puede bloquear hasta tener la petición. Lo pusimos en el layout raíz de una carpeta aparte del laboratorio:
// app/layout.tsx
import { headers } from 'next/headers'
import './globals.css'
export const instant = false
// El layout raíz lee una cabecera fuera de <Suspense>: toda la aplicación
// se renderiza en cada petición y no hay armazón estático.
export default async function RootLayout({ children }: LayoutProps<'/'>) {
const nonce = (await headers()).get('x-nonce')
return (
<html lang="es" data-nonce-leido={nonce ? 'si' : 'no'}>
<body className="min-h-screen bg-slate-50 p-8 font-sans text-slate-800">{children}</body>
</html>
)
}
next build ya no prerenderiza nada:
Route (app)
┌ ƒ /_not-found
└ ƒ /csp
ƒ Proxy (Middleware)
ƒ (Dynamic) server-rendered on demand
Y la misma página con el Contador y el script inyectado, en Chromium:
== /csp
React hidratado: sí | tras un clic: Clics: 1 | dataset: {}
violaciones CSP: 1
- Executing inline script violates the following Content Security Policy directive 'script-src 'self'
'nonce-ZmRk...' 'strict-dynamic''. Either the 'unsafe-inline' keyword, a hash ('sha256-7m...
Ahora 11 de los 12 <script> llevan el nonce; el único que no lo lleva es el del atacante, que es la única violación. Dos peticiones seguidas devuelven nonces distintos en la cabecera y en el HTML, como debe ser.

Figura 6. Con el layout raíz dinámico, los scripts de Next llevan el nonce, la página funciona y solo se bloquea el script inyectado.
El precio es alto, y conviene que lo decidas sabiendo lo que pierdes:
- No hay HTML estático: cada visita renderiza la página en el servidor (más CPU, más latencia, más coste).
- No hay caché de CDN para el HTML: la respuesta lleva
Cache-Control: private, no-cache, no-store. - Pierdes el armazón instantáneo de Partial Prerendering y la navegación instantánea del módulo 15.
'use cache'sigue funcionando para los datos, así que la base de datos no sufre más; lo que se repite es el render.
💡 Consejo: No hace falta que toda la web pague ese precio. La CSP con nonce tiene más sentido en la zona privada (la gestión de EMPRESA, con datos sensibles y sesión), y menos en el catálogo público, que gana mucho siendo estático. Lo verás aplicado así en el caso práctico.
7.5. Leer el nonce en tus componentes
Next pone el nonce en sus scripts, pero no en los tuyos. Para un script de terceros con next/script, o un <script> en línea propio, léelo de x-nonce:
import { headers } from 'next/headers'
import Script from 'next/script'
export default async function Analitica() {
const nonce = (await headers()).get('x-nonce') ?? undefined
return <Script src="https://analitica.example/a.js" strategy="afterInteractive" nonce={nonce} />
}
Y añade el dominio a las directivas que use (connect-src https://analitica.example, img-src ...). Con 'strict-dynamic', el script-src no necesita el dominio, porque el script se carga con nonce.
7.6. Alternativas sin nonce
| Opción | Cómo | Ventaja | Inconveniente |
|---|---|---|---|
CSP sin nonce, con 'unsafe-inline' | headers() en next.config.ts | Las páginas siguen siendo estáticas | 'unsafe-inline' deja pasar justo los scripts en línea que usaría un XSS; protege mucho menos |
| Subresource Integrity (experimental) | experimental.sri | Hashes calculados en el build: compatible con páginas estáticas | Experimental; no cubre los scripts que se generan en cada petición |
Content-Security-Policy-Report-Only | Misma política con otro nombre de cabecera | El navegador avisa de las violaciones sin bloquear nada | No protege: sirve para probar una política antes de aplicarla |
7.7. Subresource Integrity con Turbopack 🔄 16.2
🔄 Cambia en Next.js 16.2. En Next.js 15 la opción
experimental.sriexiste, pero Turbopack no la aplica; según el blog de Next.js 16.2, Turbopack la admite desde esa versión. Si usas Next.js 15: para probarla, compila con webpack (next buildsin--turbopack).
// next.config.ts (fragmento)
const nextConfig: NextConfig = {
experimental: {
sri: { algorithm: 'sha256' }, // o 'sha384', 'sha512'
},
}
Con SRI, Next calcula en el build un hash de cada fichero JavaScript y lo pone en el atributo integrity de su <script>. El navegador solo ejecuta el fichero si su contenido coincide con el hash. El blog de 16.2 lo presenta precisamente como la alternativa a los nonces cuando quieres mantener páginas estáticas. Es experimental: en este curso no lo usamos en la aplicación, pero conviene que sepas que existe y que la documentación lo limita a App Router y a los scripts que se conocen en el build.
7.8. Errores frecuentes con la CSP
| Síntoma | Causa | Solución |
|---|---|---|
| La página se ve pero ningún botón funciona; la consola está llena de “violates the following Content Security Policy directive” | Página estática o armazón PPR sin nonce | Apartado 7.4, o una CSP sin nonce para esas rutas |
Funciona en next dev pero no en producción | En desarrollo todo se renderiza en cada petición; en producción hay prerender | Prueba siempre la CSP con next build y next start |
Falla solo en desarrollo, con errores de eval | Falta 'unsafe-eval' en desarrollo | Añádelo solo si NODE_ENV === 'development' |
| Un script de terceros no carga | No tiene el nonce, o su dominio falta en connect-src/img-src | Pásale nonce con next/script y añade sus dominios |
| El nonce es siempre el mismo | Lo generas fuera de la función proxy o lo guardas en caché | Créalo dentro de proxy en cada petición |
x-nonce llega vacío a un componente | El matcher excluye esa ruta, o solo pusiste la CSP en la respuesta | Ponla en la petición (NextResponse.next({ request: { headers } })) |
8. Las vulnerabilidades de React Server Components de diciembre de 2025
Todo lo anterior son errores tuyos que puedes evitar. Este apartado trata de lo contrario: fallos en el propio framework, que ninguna buena práctica evita. Los datos de este apartado y del siguiente proceden de las publicaciones oficiales del blog de Next.js.
8.1. React2Shell: CVE-2025-66478 (3 de diciembre de 2025)
El 3 de diciembre de 2025, Next.js publicó un aviso de seguridad por una vulnerabilidad crítica, con puntuación CVSS 10.0 (la máxima), en el protocolo de React Server Components. Según el aviso, permitía ejecución remota de código (RCE) al procesar peticiones controladas por un atacante en entornos sin parchear. Se la conoce como React2Shell.
- El fallo estaba en la implementación de React (CVE-2025-55182); CVE-2025-66478 registra su impacto en las aplicaciones Next.js con App Router.
- El aviso explica que el protocolo vulnerable “permitía que entradas no confiables influyeran en el comportamiento de ejecución del lado del servidor”. Recuerda el apartado 1 del módulo 10: el cuerpo de una petición a una acción es un formato de serialización que el servidor deserializa. Un fallo en ese deserializador es un fallo en la puerta de entrada de todas las peticiones.
- Afectaba a Next.js 15.x, 16.x y a las versiones canary desde 14.3.0-canary.77. No afectaba a Next.js 13.x, 14.x estable, a las aplicaciones solo con Pages Router ni al Edge Runtime.
- Versiones corregidas: 15.0.5, 15.1.9, 15.2.6, 15.3.6, 15.4.8, 15.5.7 y 16.0.7 (además de las canary 15.6.0-canary.58 y 16.1.0-canary.12).
- “No hay alternativa: es obligatorio actualizar a una versión parcheada.”
En los días siguientes el aviso se amplió dos veces:
- El 6 de diciembre se publicó una herramienta para actualizar automáticamente:
npx fix-react2shell-next, que comprueba las versiones y aplica la subida recomendada. - También el 6 de diciembre: si tu aplicación estuvo en línea y sin parchear el 4 de diciembre de 2025 a las 13:00 (hora del Pacífico), se recomienda rotar todos sus secretos, empezando por los más críticos. Una ejecución remota de código significa que el atacante pudo leer
process.env.
La lista de recursos del aviso incluye una publicación del blog de seguridad de AWS titulada “China-nexus cyber threat groups rapidly exploit React2Shell vulnerability”: el fallo se explotó de verdad, y rápido.
8.2. Las vulnerabilidades del 11 de diciembre de 2025
Al estudiar los parches de React2Shell, los investigadores encontraron dos fallos más en el protocolo RSC. El aviso subraya que ninguno permite ejecución remota de código y que el parche de React2Shell sigue siendo eficaz:
| CVE | Tipo | Gravedad | Qué permitía |
|---|---|---|---|
| CVE-2025-55184 | Denegación de servicio | Alta | Una petición manipulada a cualquier endpoint del App Router provocaba un bucle infinito que colgaba el proceso del servidor |
| CVE-2025-67779 | Denegación de servicio | La corrección completa de la anterior: el primer arreglo de CVE-2025-55184 fue incompleto, y quien ya había actualizado tuvo que volver a actualizar | |
| CVE-2025-55183 | Exposición de código fuente | Media | Una petición manipulada hacía que una Server Function devolviera el código compilado de otras Server Functions de la aplicación |
Versiones corregidas, entre otras: 15.5.9, 16.0.10 y 14.2.35 (esta vez sí se corrigió la 14, porque la denegación de servicio afectaba desde la 13.3). De nuevo, sin alternativa: había que actualizar.
8.3. Qué nos enseñan
- Las Server Actions y los RSC son una superficie de ataque nueva. No son “funciones que llamas”: son un protocolo de red con su propio formato, y su intérprete es código que puede tener fallos.
- Actualizar es la defensa principal. Ninguna validación tuya podía frenar React2Shell, porque el fallo ocurría antes de que tu código se ejecutara, al interpretar la petición.
- Un parche puede ser incompleto. CVE-2025-67779 obligó a actualizar dos veces en una semana. No basta con “actualicé en diciembre”: hay que seguir los avisos.
- Los secretos en el código se pueden filtrar (CVE-2025-55183, apartado 3.3).
- Rotar secretos forma parte de la respuesta a un incidente, no es un extra.
- Las capas ayudan a limitar el daño. Una CSP no habría frenado un RCE en el servidor, pero una base de datos con un usuario de pocos permisos, secretos distintos por entorno y registros de acceso sí reducen lo que un atacante puede hacer una vez dentro.
⚠️ Importante: Si mantienes un proyecto que en diciembre de 2025 usaba una versión afectada y no sabes si se actualizó a tiempo, compruébalo en el historial del
package-lock.jsony, ante la duda, rota los secretos. Las versiones del curso (16.4.x y 15.5.28) incluyen todas estas correcciones.
9. El programa de parches de seguridad de Next.js
9.1. De los parches por sorpresa a un calendario
El 13 de julio de 2026, el equipo de Next.js anunció un programa formal de publicaciones de seguridad. El motivo, según el anuncio: hasta entonces los parches salían “sin aviso previo”, lo que causaba trastornos a los equipos, y el volumen de investigación de vulnerabilidades está creciendo deprisa, impulsado por el descubrimiento asistido por modelos de lenguaje (el anuncio cita como ejemplo los 271 fallos que Mozilla publicó en una sola versión de Firefox).
Cómo funciona, según el anuncio:
- Más o menos una vez al mes, el blog de Next.js publica un aviso previo de la próxima publicación de seguridad, con la fecha prevista y la gravedad más alta de los fallos que incluye. Los detalles (CVE, versiones afectadas, instrucciones) no se publican hasta que el parche está disponible.
- Ese tiempo sirve para que los equipos planifiquen la actualización, y para que Vercel coordine con los proveedores de hosting mitigaciones temporales (como reglas de cortafuegos) para las aplicaciones que aún no se han actualizado.
- Los fallos urgentes, o los que ya se están explotando, se siguen corrigiendo fuera de calendario, y también se anuncian en el blog.
- Los investigadores pueden informar de fallos a través del programa de recompensas de Vercel en HackerOne, y las dudas se envían a
security@vercel.com.
Figura 7. De los parches por sorpresa al calendario mensual. En naranja, las actualizaciones fuera de calendario.
9.2. Lo que se ha publicado desde entonces
Cada publicación parchea a la vez la versión 16 (Active LTS) y la 15.5 (Maintenance LTS), lo que confirma la política de soporte que vimos en el módulo 00:
| Fecha | Tipo | Versiones | Contenido destacado |
|---|---|---|---|
| 20-7-2026 | Programada | 16.2.11 y 15.5.21 | 4 altas y 5 medias, entre ellas: DoS con Server Actions, salto del proxy con Turbopack e i18n de un solo idioma, SSRF en rewrites, exposición de los identificadores de Server Functions |
| 25-8-2026 | Programada (adelantada un día) | 16.3.3 y 15.5.24 | Dos RCE críticas: optimización de imágenes AVIF (librería libheif de sharp; se desactivó la optimización de AVIF) y servidores en Windows |
| 22-9-2026 | Urgente | 16.3.6 y 15.5.26 | Un fallo crítico en una dependencia |
| 30-9-2026 | Programada | 16.3.8 y 15.5.27 | SSRF en la optimización de imágenes, envenenamiento de caché en SSG/ISR, fugas entre parámetros raíz en 'use cache' anidados, contenido de Draft Mode filtrado por use cache, y el endpoint MCP de next dev legible desde otras webs |
| 14-10-2026 | Urgente | 16.4.1 y 15.5.28 | RCE crítica en funciones use cache que devuelven varios typed arrays, RCE crítica en la optimización de imágenes SVG (sharp), DoS con Server Actions |
| 3-11-2026 | Programada (anunciada el 27-10) | Pendiente | El aviso previo anuncia siete vulnerabilidades de gravedad media; los detalles se publicarán con el parche |
Fíjate en cuántas afectan a funciones que usas en este curso: Server Actions, 'use cache', next/image, el proxy. Y en una curiosa: el servidor MCP de next dev (el del módulo 01) era legible desde cualquier web que visitara el desarrollador. Los parches también importan en desarrollo.
💡 Consejo: En npm,
next@16.4.4se publicó el 2 de noviembre de 2026 y ya es la etiquetalatest. Recuerda la recomendación del curso: instala siempre el último parche de 16.4.x. Cuando salga la publicación del 3 de noviembre, lee su entrada del blog para saber qué versión la contiene.
9.3. Cómo enterarte y cómo actualizar
Consulta las versiones publicadas (salida real del 2 de noviembre de 2026, recortada):
npm view next dist-tags
{
"next-14": "14.2.35",
"backport": "15.5.28",
"latest": "16.4.4",
"canary": "16.5.0-canary.36",
...
}
latest es el último parche de la versión estable; backport, el de la 15.5. Para actualizar dentro de tu versión:
npm install next@16.4 eslint-config-next@16.4 # el último 16.4.x
npm install next@backport # si sigues en Next.js 15
Audita las dependencias. npm audit compara tu package-lock.json con la base de datos de avisos de GitHub. En el laboratorio (con next@16.4.3), el 2 de noviembre de 2026:
npm audit --omit=dev
# npm audit report
esbuild <=0.24.2
Severity: moderate
esbuild enables any website to send any requests to the development server and read the response - https://github.com/advisories/GHSA-67mh-4wv8-2f99
fix available via `npm audit fix --force`
Will install drizzle-kit@0.18.1, which is a breaking change
...
4 moderate severity vulnerabilities
Dos lecciones en esta salida:
- No hay ningún aviso de Next.js, aunque 16.4.3 no es el último parche. Los avisos de la publicación del 3 de noviembre aún no existen:
npm auditsolo conoce lo que ya se ha publicado. Por eso, además de auditar, hay que seguir el blog. npm audit fix --forcees peligroso: aquí “arreglaría” un aviso de desarrollo bajandodrizzle-kita la 0.18.1, una versión de hace años incompatible con el proyecto. Lee siempre qué propone antes de aplicarlo.
Automatiza. Dependabot o Renovate abren una pull request cuando sale un parche; con pruebas automáticas (el módulo 21), aceptarla es cuestión de minutos.
Lee el aviso antes de actualizar. Cada publicación indica a quién afecta (por ejemplo, “solo Pages Router con SSG en servidores propios” o “solo si configuras images.remotePatterns”). Eso te dice la urgencia, no si debes actualizar: debes actualizar siempre.
9.4. Una rutina mínima para tu proyecto
| Cuándo | Qué |
|---|---|
| Cada mes, tras el aviso previo | Planifica la actualización para el día de la publicación |
| El día de la publicación | Lee los avisos, actualiza al último parche, ejecuta las pruebas y despliega |
| Ante un aviso urgente | Lo mismo, el mismo día; si el aviso lo pide, rota los secretos |
| En cada pull request | npm audit en la integración continua |
| Al planificar el curso o el proyecto | Comprueba que tu versión mayor sigue con soporte (la 15 recibe parches hasta el 31-01-2027) |
10. Caso práctico: blindar la gestión de EMPRESA
Juntamos todas las capas en la aplicación EMPRESA. El catálogo público de la tienda sigue siendo estático y rápido; la zona privada de gestión (empleados, pedidos) recibe la protección completa.
Figura 8. Defensa en profundidad: cada capa frena lo que se le escapa a la anterior.
10.1. Estructura
app/
(tienda)/ catálogo público: estático, sin nonce
layout.tsx
productos/page.tsx
entrar/ inicio de sesión (módulo 17)
gestion/ zona privada
layout.tsx lee headers(): dinámica y con nonce
empleados/page.tsx
pedidos/
page.tsx
acciones.ts 'use server'
lib/
db/index.ts import 'server-only'
claves.ts scrypt (módulo 17)
sesion.ts JWT con jose, cookie de sesión (módulo 17)
dal/auth.ts obtenerUsuario, verificarSesion, comprobarCredenciales (módulo 17)
dal/empleados.ts import 'server-only' + DTO + taint
dal/pedidos.ts import 'server-only'
proxy.ts CSP con nonce solo en /gestion
next.config.ts cabeceras fijas, taint, allowedOrigins vacío
react-experimental.d.ts
10.2. La CSP solo donde hace falta
El proxy aplica la CSP estricta a /gestion y una política más suave, sin nonce, al catálogo:
// proxy.ts
import { NextResponse, type NextRequest } from 'next/server'
function politica(nonce: string | null) {
const desarrollo = process.env.NODE_ENV === 'development'
const scripts = nonce
? `'self' 'nonce-${nonce}' 'strict-dynamic'`
: `'self' 'unsafe-inline'` // catálogo estático: sin nonce posible
return `
default-src 'self';
script-src ${scripts}${desarrollo ? " 'unsafe-eval'" : ''};
style-src 'self' 'unsafe-inline';
img-src 'self' blob: data:;
font-src 'self';
connect-src 'self';
object-src 'none';
base-uri 'self';
form-action 'self';
frame-ancestors 'none';
`
.replace(/\s{2,}/g, ' ')
.trim()
}
export function proxy(request: NextRequest) {
const privada = request.nextUrl.pathname.startsWith('/gestion')
const nonce = privada ? Buffer.from(crypto.randomUUID()).toString('base64') : null
const csp = politica(nonce)
const cabeceras = new Headers(request.headers)
cabeceras.set('Content-Security-Policy', csp)
if (nonce) cabeceras.set('x-nonce', nonce)
const respuesta = NextResponse.next({ request: { headers: cabeceras } })
respuesta.headers.set('Content-Security-Policy', csp)
return respuesta
}
export const config = {
matcher: [
{
source: '/((?!api|_next/static|_next/image|favicon.ico).*)',
missing: [
{ type: 'header', key: 'next-router-prefetch' },
{ type: 'header', key: 'purpose', value: 'prefetch' },
],
},
],
}
⚠️ Importante: El layout de
/gestiondebe hacer que todo su contenido se renderice con la petición. Pero recuerda lo que medimos en el apartado 7.3: los<script>de arranque pertenecen al layout raíz, y si este es estático, quedan sin nonce. Por eso esta estructura usa dos layouts raíz con grupos de rutas:(tienda)/layout.tsx, estático, ygestion/layout.tsxcomo raíz propia coninstant = falseyawait headers()(cada uno con su<html>y<body>, como vimos en el módulo 04). No hayapp/layout.tsxcomún.
// app/gestion/layout.tsx (layout raíz de la zona privada)
import { headers } from 'next/headers'
import '../globals.css'
export const instant = false
export default async function LayoutGestion({ children }: LayoutProps<'/gestion'>) {
await headers() // espera a la petición: sin armazón estático, todos los scripts llevan nonce
return (
<html lang="es">
<body className="min-h-screen bg-slate-50 font-sans text-slate-800">
<nav className="bg-indigo-900 p-4 text-white">EMPRESA · Gestión</nav>
<main className="p-8">{children}</main>
</body>
</html>
)
}
10.3. La DAL de pedidos
// lib/dal/pedidos.ts
import 'server-only'
import { and, eq } from 'drizzle-orm'
import { db } from '@/lib/db'
import { pedidos } from '@/db/schema'
import { obtenerUsuario } from '@/lib/dal/auth' // módulo 17
export type ResultadoPedido = { ok: true; mensaje: string } | { ok: false; mensaje: string }
export async function cancelarPedidoComo(pedidoId: number): Promise<ResultadoPedido> {
// En una acción: obtenerUsuario (devuelve null) mejor que verificarSesion (redirige)
const usuario = await obtenerUsuario()
if (!usuario) return { ok: false, mensaje: 'Tu sesión ha caducado' }
// Autorización por recurso: un USER solo cancela los pedidos que gestiona
const [pedido] = await db.select().from(pedidos).where(eq(pedidos.pedidoId, pedidoId)).limit(1)
if (!pedido) return { ok: false, mensaje: 'El pedido no existe' }
if (usuario.rol !== 'ADMIN' && pedido.empleadoId !== usuario.empId) {
return { ok: false, mensaje: 'No puedes cancelar este pedido' }
}
if (pedido.estado !== 'PENDIENTE') return { ok: false, mensaje: 'Solo se cancelan pedidos pendientes' }
await db
.update(pedidos)
.set({ estado: 'CANCELADO' })
.where(and(eq(pedidos.pedidoId, pedidoId), eq(pedidos.estado, 'PENDIENTE')))
return { ok: true, mensaje: `Pedido ${pedidoId} cancelado` }
}
export async function listarPedidosPendientes() {
return db
.select({ pedidoId: pedidos.pedidoId, empleadoId: pedidos.empleadoId, estado: pedidos.estado })
.from(pedidos)
.where(eq(pedidos.estado, 'PENDIENTE'))
}
📘 Concepto: Fíjate en que la comprobación de permisos está en la DAL, no en la página ni en la acción. Así, cualquier camino que llegue a cancelar un pedido (una acción, un Route Handler, un trabajo programado) pasa por la misma puerta.
obtenerUsuarioes la del módulo 17: verifica la firma del JWT, busca al usuario en la base de datos, compruebaversionSesiony devuelve el DTOUsuarioActual(usuarioId,nombre,rol,empId), con el rol sacado de la base de datos y no del token. Las columnasempleadoId(el comercial que gestiona el pedido) yestadoson las del esquema del módulo 16.
La página comprueba la sesión por su cuenta, como pide el módulo 17 (el layout no protege):
// app/gestion/pedidos/page.tsx
import { verificarSesion } from '@/lib/dal/auth'
import { listarPedidosPendientes } from '@/lib/dal/pedidos'
import { FormCancelar } from './FormCancelar'
export default async function Pedidos() {
const usuario = await verificarSesion() // sin sesión: redirect('/entrar')
const pendientes = await listarPedidosPendientes()
return (
<>
<h1 className="text-2xl font-bold">
Pedidos pendientes: {pendientes.length} ({usuario.nombre}, {usuario.rol})
</h1>
<FormCancelar />
</>
)
}
Aquí verificarSesion puede leer las cookies arriba de la página sin <Suspense> porque el layout raíz de /gestion ya es bloqueante (instant = false). En una zona con armazón estático, el módulo 17 explica por qué eso rompería el build.
10.4. La acción
// app/gestion/pedidos/acciones.ts
'use server'
import { z } from 'zod'
import { headers } from 'next/headers'
import { updateTag } from 'next/cache'
import { cancelarPedidoComo, type ResultadoPedido } from '@/lib/dal/pedidos'
import { superaLimite } from '@/lib/registro'
const esquema = z.object({ pedidoId: z.coerce.number().int().min(5001) })
export async function cancelarPedido(_previo: ResultadoPedido | null, formData: FormData) {
const ip = (await headers()).get('x-forwarded-for')?.split(',')[0].trim() ?? 'local'
if (superaLimite(`cancelar:${ip}`, 20)) {
return { ok: false, mensaje: 'Demasiadas operaciones; espera un minuto' } satisfies ResultadoPedido
}
const datos = esquema.safeParse({ pedidoId: formData.get('pedidoId') })
if (!datos.success) return { ok: false, mensaje: 'Datos no válidos' } satisfies ResultadoPedido
const resultado = await cancelarPedidoComo(datos.data.pedidoId)
if (resultado.ok) updateTag('pedidos')
return resultado // un mensaje, nunca la fila
}
La página de pedidos usa la acción con useActionState (el módulo 10 lo explica):
// app/gestion/pedidos/FormCancelar.tsx
'use client'
import { useActionState } from 'react'
import { cancelarPedido } from './acciones'
export function FormCancelar() {
const [estado, accion, pendiente] = useActionState(cancelarPedido, null)
return (
<form action={accion} className="my-4 flex gap-2">
<input name="pedidoId" defaultValue="5024" className="w-24 border px-2" />
<button disabled={pendiente} className="rounded bg-red-700 px-3 py-1 text-white">
Cancelar pedido
</button>
{estado && <p id="resultado">{estado.mensaje}</p>}
</form>
)
}
Con los usuarios de prueba del módulo 17, un script de Playwright inicia sesión en /entrar como Luis (USER, empleado 101) y después como Ana (ADMIN), e intenta cancelar pedidos. Salida real:
login luis.martinez@empresa.es /gestion/pedidos [ 'sesion secure=true' ]
Pedidos pendientes: 3 (Luis Martínez, USER) | cancelar 5024 -> No puedes cancelar este pedido
Pedidos pendientes: 3 (Luis Martínez, USER) | cancelar 5022 -> No puedes cancelar este pedido
Pedidos pendientes: 3 (Luis Martínez, USER) | cancelar abc -> Datos no válidos
login ana.garcia@empresa.es /gestion/pedidos [ 'sesion secure=true' ]
Pedidos pendientes: 3 (Ana García, ADMIN) | cancelar 5022 -> Pedido 5022 cancelado
Pedidos pendientes: 2 (Ana García, ADMIN) | cancelar 5022 -> Solo se cancelan pedidos pendientes
Los pedidos pendientes los gestionan los comerciales 110 y 113, no Luis: autorización por recurso. Ana es ADMIN y puede cancelar cualquiera; repetirlo no hace nada, y abc no pasa Zod. La cookie de sesión lleva Secure también en 127.0.0.1, como se midió en el módulo 17.
Un detalle de esta estructura: /entrar está en (tienda) y /gestion tiene otro layout raíz, así que la redirección tras iniciar sesión no es una navegación de cliente sino una carga completa de página (en la prueba se ve un GET /gestion/pedidos de navegación). Es lo esperable al cruzar entre layouts raíz, y es justo lo que hace falta: la página nueva llega con su propio nonce.
10.5. La configuración
// next.config.ts (fragmento)
const nextConfig: NextConfig = {
cacheComponents: true,
partialPrefetching: true,
poweredByHeader: false,
experimental: {
taint: true,
serverActions: {
// Vacío a propósito: el proxy de producción reenvía X-Forwarded-Host.
// Solo se añadiría el dominio público si el proxy no lo hiciera.
allowedOrigins: [],
bodySizeLimit: '1mb',
},
},
async headers() {
return [{ source: '/:path*', headers: cabecerasSeguridad }]
},
}
10.6. Comprobación: dos zonas, dos políticas
next build del caso práctico (con las páginas de los ejercicios incluidas):
Route (app)
┌ ○ /_not-found
├ ƒ /gestion/clientes
├ ƒ /gestion/pedidos
├ ○ /productos
├ ◐ /taint/array
└ ◐ /taint/clon
ƒ Proxy (Middleware)
El catálogo (/productos) sigue estático y la gestión es ƒ. Con Chromium:
== /productos
React hidratado: sí | tras un clic: Clics: 1 | dataset: {}
violaciones CSP: 0
== /gestion/pedidos
React hidratado: sí | tras un clic: Clics: 1 | dataset: {}
violaciones CSP: 0
Y en el HTML de /gestion/pedidos, los 12 <script> llevan el nonce. Las dos zonas funcionan, cada una con su política.
10.7. Auditoría final
La documentación de Next.js propone una lista para auditar un proyecto. Aplicada a EMPRESA:
| Pregunta | En EMPRESA |
|---|---|
| ¿Hay una DAL aislada? ¿Los paquetes de base de datos y las variables de entorno solo se importan dentro? | lib/db y lib/dal, con server-only |
| ¿Los Client Components esperan datos privados en sus props? ¿Sus tipos son demasiado amplios? | Las props usan DTO (EmpleadoPublico), no tipos de tabla |
| ¿Las acciones validan argumentos, vuelven a autorizar al usuario, comprueban la propiedad del recurso, filtran lo que devuelven y delegan en la DAL? | Sí: Zod, cancelarPedidoComo y un mensaje como resultado |
¿Las carpetas [param] validan sus parámetros? | z.coerce.number() en las páginas de detalle |
¿proxy.ts y route.ts se han revisado con especial cuidado? | El proxy solo pone cabeceras; la autorización real está en la DAL |
| ¿Next.js está en el último parche? | npm view next dist-tags y npm audit en la integración continua |
11. Errores frecuentes
| Error | Consecuencia | Solución |
|---|---|---|
| Pasar una fila de la base de datos a un Client Component | Todos sus campos viajan en el HTML | DTO en la DAL (apartado 2.2) |
Leer process.env.SECRETO en un Client Component “porque en el navegador es undefined” | El prerender escribe el secreto en el HTML | Leer secretos solo en módulos server-only (apartado 3.2) |
NEXT_PUBLIC_ en una clave | La clave queda en el JavaScript público para siempre | Quitar el prefijo, rotar la clave y recompilar |
'use server' en la DAL | Cada función de la DAL se convierte en un endpoint público | import 'server-only' en la DAL |
| Confiar en taint como única protección | Una copia o un valor derivado se escapan sin aviso | DTO primero; taint como red |
| Comprobar la sesión en la página y no en la acción | curl llama a la acción sin pasar por la página | Comprobar dentro de la acción o de la DAL |
Añadir dominios a allowedOrigins para silenciar un error | Esos dominios pueden lanzar ataques CSRF | Arreglar X-Forwarded-Host en el proxy |
Fiarse de X-Forwarded-For sin un proxy propio delante | El atacante cambia de “IP” a voluntad | Limitar también por cuenta; usar la IP que da tu plataforma |
| CSP con nonce y páginas estáticas o PPR | La aplicación deja de funcionar en producción (el build no avisa) | Apartado 7.4 o CSP sin nonce en esas rutas |
Probar la CSP solo en next dev | En desarrollo todo es dinámico y funciona | Probar con next build y next start |
| Actualizar Next.js “cuando haya tiempo” | Expuesto a fallos públicos, algunos explotados en días | Rutina mensual del apartado 9.4 |
npm audit fix --force sin leer | Bajadas de versión incompatibles | Leer la propuesta y actualizar a mano |
12. Ejercicios
Ejercicio 1: Encuentra la fuga
Esta página muestra los clientes de un comercial. ¿Qué datos llegan al navegador que no se ven en pantalla? Corrígela.
// app/gestion/clientes/page.tsx
import { db } from '@/lib/db'
import { clientes } from '@/db/schema'
import { ListaClientes } from './ListaClientes' // 'use client', pinta nombre y ciudad
export default async function Clientes() {
const filas = await db.select().from(clientes)
return <ListaClientes clientes={filas} />
}
Ver solución
La tabla clientes del esquema del módulo 16 tiene nif, email, telefono, limiteCredito y comercialId, además de nombre y ciudad. Todas esas columnas viajan en el payload RSC para hidratar ListaClientes, aunque solo se pinten dos.
Hay un segundo problema: la página consulta la base de datos directamente, sin pasar por la DAL, así que tampoco comprueba quién la está viendo.
// lib/dal/clientes.ts
import 'server-only'
import { asc } from 'drizzle-orm'
import { db } from '@/lib/db'
import { clientes } from '@/db/schema'
import { verificarSesion } from '@/lib/dal/auth' // módulo 17
export type ClienteResumen = { clienteId: number; nombre: string; ciudad: string }
export async function listarClientesResumen(): Promise<ClienteResumen[]> {
await verificarSesion() // sin sesión: redirect('/entrar')
return db
.select({ clienteId: clientes.clienteId, nombre: clientes.nombre, ciudad: clientes.ciudad })
.from(clientes)
.orderBy(asc(clientes.nombre))
}
// app/gestion/clientes/page.tsx
import { listarClientesResumen } from '@/lib/dal/clientes'
import { ListaClientes } from './ListaClientes'
export default async function Clientes() {
return <ListaClientes clientes={await listarClientesResumen()} />
}
Y el tipo de la prop de ListaClientes pasa a ser ClienteResumen[]. Para comprobarlo: curl de la página y grep -c nif debe dar 0.
Ejercicio 2: El secreto del prerender
Un compañero escribe este componente para mostrar si el modo de mantenimiento está activo, y dice que es seguro porque “en el navegador process.env.TOKEN_ADMIN es undefined”. Explica qué pasa y propón una versión correcta.
'use client'
export function AvisoMantenimiento() {
const activo = process.env.MODO_MANTENIMIENTO === '1'
return activo ? <p>Mantenimiento en curso (token: {process.env.TOKEN_ADMIN})</p> : null
}
Ver solución
El componente se ejecuta también en el servidor para generar el HTML. Si la página es estática, next build lo ejecuta con todas las variables de entorno disponibles: si MODO_MANTENIMIENTO vale 1, el HTML guardado contendrá el valor de TOKEN_ADMIN. Es exactamente lo que medimos en /entorno (apartado 3.2): HTML recibido contiene la clave: true. En el navegador, tras hidratar, activo pasa a ser false (allí las variables no existen) y el párrafo desaparece, lo que encima hace más difícil notar el problema.
Versión correcta: la decisión se toma en el servidor y el componente solo recibe un booleano.
// app/AvisoMantenimiento.tsx (Server Component, sin 'use client')
import 'server-only'
export function AvisoMantenimiento() {
if (process.env.MODO_MANTENIMIENTO !== '1') return null
return <p>Mantenimiento en curso</p>
}
El token no aparece en ningún componente. Si hiciera falta en el navegador un dato público (el texto del aviso), se usaría una variable NEXT_PUBLIC_AVISO, sabiendo que es pública y que se fija en el build.
Ejercicio 3: Taint en la DAL de clientes
Marca con taint las filas completas de clientes y el nif de cada uno, para que ni el objeto ni el NIF puedan cruzar a un Client Component. ¿Qué API usas para cada caso? ¿Qué objeto de vida eliges para el NIF?
Ver solución
// lib/dal/clientes.ts (fragmento)
import 'server-only'
import { experimental_taintObjectReference, experimental_taintUniqueValue } from 'react'
import { eq } from 'drizzle-orm'
import { db } from '@/lib/db'
import { clientes } from '@/db/schema'
export async function leerClienteCompleto(clienteId: number) {
const [fila] = await db.select().from(clientes).where(eq(clientes.clienteId, clienteId)).limit(1)
if (!fila) return null
experimental_taintObjectReference('Fila completa de cliente: usa un DTO', fila)
experimental_taintUniqueValue('El NIF de un cliente no se envía al navegador', fila, fila.nif)
return fila
}
- Para el objeto,
experimental_taintObjectReference. - Para el NIF,
experimental_taintUniqueValue. El objeto de vida natural es la propia fila: mientras exista, el NIF está marcado. Recuerda lo que vimos en el apartado 4.4: la marca se aplica al valor (la cadena'B12345678') en todo el servidor mientras viva la fila, y deja de aplicarse cuando el recolector de basura la libera.
Necesitas experimental.taint: true en next.config.ts y el fichero react-experimental.d.ts con /// <reference types="react/experimental" />.
Un detalle que comprobamos en el laboratorio: { ...fila } crea una copia sin marca del objeto, pero el NIF de esa copia sigue siendo la misma cadena marcada, así que pasar la copia también falla (Error: El NIF de un cliente no se envía al navegador), al menos mientras viva la fila. Lo que no cubre es un NIF transformado (fila.nif.toLowerCase()): ya es otra cadena, sin marca.
Ejercicio 4: ¿Pasa o no pasa?
Con experimental.taint: true y la función leerEmpleadoProtegido del apartado 4.2, indica qué ocurre en cada caso al pasar el valor a un Client Component:
empleado{ nombre: empleado.nombre }[empleado]structuredClone(empleado)empleado.salarioprocess.env{ ...process.env }
Ver solución
Todos los casos están comprobados en el laboratorio (páginas /taint/...).
- Error: es la referencia marcada (
/taint/directo). - Pasa: objeto nuevo con un primitivo no marcado (
/taint/campos). - Error: React serializa el array y encuentra dentro la referencia marcada (
/taint/array). La marca se comprueba en cualquier objeto que React vaya a serializar, esté donde esté. - Pasa:
structuredClonecrea un objeto nuevo, igual que el spread de/taint/copia. El salario llega al navegador (/taint/clon). - Pasa: es un número, y nadie lo ha marcado. Además,
taintUniqueValuesolo admite cadenas,biginty typed arrays (así lo declaran sus tipos): está pensada para tokens y claves, no para cifras. - Error:
Do not pass process.env to Client Components since it will leak sensitive data(/taint/env). - Pasa (
/taint/clon?caso=env): es una copia. Y es justo uno de los peores errores posibles, porque manda todas las variables de entorno.
La conclusión es la del apartado 4.7: taint atrapa el error más directo, pero cualquier transformación lo esquiva.
Ejercicio 5: Diagnosticar un error de origen
Despliegas EMPRESA detrás de un proxy inverso propio. El navegador está en https://gestion.empresa.com, y al enviar cualquier formulario el registro del servidor dice:
`x-forwarded-host` header with value `localhost:3000` does not match `origin` header with value
`gestion.empresa.com` from a forwarded Server Actions request. Aborting the action.
Un compañero propone allowedOrigins: ['*']. ¿Qué pasa con esa propuesta y cuál es la solución buena? ¿Y la aceptable si no puedes tocar el proxy?
Ver solución
El proxy reenvía la petición con X-Forwarded-Host: localhost:3000 (su destino interno) en lugar del host público. Next compara Origin (gestion.empresa.com) con ese valor, no coinciden, y aborta.
allowedOrigins: ['*'] no funciona: el código de Next rechaza explícitamente los patrones que coincidirían con dominios enteros (*, **). Y aunque funcionara, desactivaría la protección CSRF: cualquier web podría invocar las acciones con la cookie del usuario.
- Solución buena: configurar el proxy para que reenvíe el host original (en nginx,
proxy_set_header X-Forwarded-Host $host;). EntoncesX-Forwarded-Hostvalegestion.empresa.comy la comprobación pasa sin tocar Next. - Aceptable si no controlas el proxy: añadir solo el host público exacto, el que ve el navegador:
experimental: {
serverActions: {
allowedOrigins: ['gestion.empresa.com'],
},
},
Nunca el interno (localhost:3000) ni un comodín amplio.
Ejercicio 6: Límite de intentos por cuenta
El limitador del apartado 5.7 cuenta por IP. Modifica la acción entrar del módulo 17 (la que usa comprobarCredenciales y crearSesion) para que limite los intentos por IP y, además, bloquee una cuenta tras cinco fallos en quince minutos, venga de la IP que venga. Es el ejercicio 8 propuesto del módulo 17.
Ver solución
superaLimite ya acepta cualquier clave, una ventana y un máximo. El detalle importante es cuándo se cuenta: el límite por IP cuenta todos los intentos (frena a quien prueba muchas cuentas), y el límite por cuenta debe contar solo los fallos (si no, un atacante bloquearía la cuenta de Ana mandando cinco peticiones cualesquiera… lo que de todas formas puede hacer con cinco fallos: es el precio de este tipo de bloqueo, y por eso suele durar poco). Las dos comprobaciones van antes de comprobarCredenciales, para no gastar scrypt en peticiones que ya se van a rechazar.
// app/entrar/acciones.ts (la del módulo 17, con los límites)
'use server'
import { headers } from 'next/headers'
import { redirect } from 'next/navigation'
import { z } from 'zod'
import { comprobarCredenciales } from '@/lib/dal/auth'
import { crearSesion } from '@/lib/sesion'
import { intentosFallidos, registrarFallo, superaLimite } from '@/lib/registro'
export type EstadoEntrar = { error?: string; email?: string }
const esquemaEntrar = z.object({
email: z.email('Escribe un email válido'),
clave: z.string().min(1, 'Escribe la contraseña'),
desde: z.string().optional(),
})
function destinoSeguro(desde: string | undefined) {
if (desde && desde.startsWith('/') && !desde.startsWith('//') && !desde.startsWith('/\\')) {
return desde
}
return '/gestion'
}
export async function entrar(_previo: EstadoEntrar, formData: FormData): Promise<EstadoEntrar> {
const datos = esquemaEntrar.safeParse(Object.fromEntries(formData))
if (!datos.success) {
return { error: datos.error.issues[0].message, email: String(formData.get('email') ?? '') }
}
const email = datos.data.email.trim().toLowerCase()
// 1. Límite por IP: cuenta todos los intentos (frena a quien prueba muchas cuentas)
const ip = (await headers()).get('x-forwarded-for')?.split(',')[0].trim() ?? 'local'
if (superaLimite(`entrar:ip:${ip}`, 20)) {
return { error: 'Demasiados intentos; espera un minuto', email }
}
// 2. Límite por cuenta: solo cuenta los fallos, exista o no el email
if (intentosFallidos(email) >= 5) {
return { error: 'Demasiados intentos fallidos con esta cuenta; espera 15 minutos', email }
}
const usuario = await comprobarCredenciales(email, datos.data.clave)
if (!usuario) {
registrarFallo(email)
return { error: 'Email o contraseña incorrectos', email }
}
await crearSesion(usuario)
redirect(destinoSeguro(datos.data.desde)) // fuera de cualquier try/catch
}
// lib/registro.ts (añadido)
const fallos = new Map<string, number[]>()
const QUINCE_MINUTOS = 15 * 60_000
export function registrarFallo(cuenta: string) {
const ahora = Date.now()
const recientes = (fallos.get(cuenta) ?? []).filter((t) => ahora - t < QUINCE_MINUTOS)
recientes.push(ahora)
fallos.set(cuenta, recientes)
}
export function intentosFallidos(cuenta: string) {
const ahora = Date.now()
return (fallos.get(cuenta) ?? []).filter((t) => ahora - t < QUINCE_MINUTOS).length
}
Prueba con Playwright sobre el formulario de /entrar: seis contraseñas malas para Marta, seis para un email que no existe y, al final, la contraseña buena de Marta. Salida real:
marta.sanchez@empresa.es intento 1: Email o contraseña incorrectos
marta.sanchez@empresa.es intento 2: Email o contraseña incorrectos
marta.sanchez@empresa.es intento 3: Email o contraseña incorrectos
marta.sanchez@empresa.es intento 4: Email o contraseña incorrectos
marta.sanchez@empresa.es intento 5: Email o contraseña incorrectos
marta.sanchez@empresa.es intento 6: Demasiados intentos fallidos con esta cuenta; espera 15 minutos
nadie@empresa.es intento 1: Email o contraseña incorrectos
...
nadie@empresa.es intento 6: Demasiados intentos fallidos con esta cuenta; espera 15 minutos
marta con la clave buena: Demasiados intentos fallidos con esta cuenta; espera 15 minutos
Fíjate en dos cosas. El email inexistente se bloquea igual que el real: si solo se bloquearan las cuentas que existen, el mensaje de bloqueo delataría qué emails tienen cuenta, y se perdería lo que consiguió el módulo 17 con el mensaje único y el hash ficticio. Y Marta no puede entrar ni con la contraseña buena hasta que pase la ventana: el bloqueo funciona, pero también puede usarse para molestar a un usuario, por eso se combina con ventanas cortas y avisos al administrador. En producción, estos contadores van en un almacén compartido, no en un Map.
💡 Consejo: Si escribes tu propia prueba con Playwright, busca el mensaje con
p[role=alert]y no con[role=alert]a secas: Next.js añade a cada página un anunciador de rutas (next-route-announcer) que también tienerole="alert", y el localizador encontraría dos elementos.
Ejercicio 7: CSP para una página con analítica
La zona de gestión necesita cargar https://analitica.example/a.js, que a su vez envía datos a https://recogida.analitica.example. Escribe la parte de la CSP que cambia y el componente que carga el script.
Ver solución
Con 'strict-dynamic', el script-src no necesita el dominio: basta con que el <script> lleve el nonce. Lo que sí hay que abrir es la conexión de datos:
script-src 'self' 'nonce-${nonce}' 'strict-dynamic';
connect-src 'self' https://recogida.analitica.example;
img-src 'self' blob: data: https://recogida.analitica.example;
(El img-src solo si la biblioteca usa píxeles de imagen; compruébalo con la consola.)
// app/gestion/Analitica.tsx
import { headers } from 'next/headers'
import Script from 'next/script'
export async function Analitica() {
const nonce = (await headers()).get('x-nonce') ?? undefined
return <Script src="https://analitica.example/a.js" strategy="afterInteractive" nonce={nonce} />
}
Se coloca en app/gestion/layout.tsx, que ya es dinámico. Para comprobarlo: next build, next start y la consola del navegador sin violaciones (o el script pruebas/csp.mjs del laboratorio).
Ejercicio 8: Responder a un aviso
Es 3 de noviembre de 2026 y el blog de Next.js publica la entrada de la publicación de seguridad de noviembre. Tu proyecto usa next@16.4.3. Escribe, en orden, los pasos que darías desde que lees la entrada hasta que lo das por cerrado.
Ver solución
- Leer la entrada completa: qué versiones corrigen los fallos y a quién afecta cada uno (por ejemplo, “solo con
images.remotePatterns” o “solo con Cache Components”). Anotar cuáles afectan a EMPRESA. - Comprobar la versión publicada:
npm view next dist-tagsy la versión que indica la entrada. - Actualizar en una rama:
npm install next@16.4 eslint-config-next@16.4(el último 16.4.x). Comprobar enpackage-lock.jsonque la versión instalada es la del aviso o posterior. - Pasar las pruebas:
eslint,next build, las pruebas de Vitest y Playwright del módulo 21. - Desplegar el mismo día, aunque ningún fallo parezca afectarte: los avisos describen lo conocido, no todo lo posible.
- Comprobar en producción la versión real (la cabecera no la dice, por eso quitamos
X-Powered-By: mírala en el registro de arranque o en el artefacto desplegado). - Si el aviso lo indica (fallos de ejecución remota o exposición de datos ya explotados), revisar los registros de los días anteriores y rotar los secretos.
- Documentar el cambio (versión, fecha, avisos cubiertos) para la próxima auditoría.
Fíjate en que no hace falta conocer el contenido del aviso para tener el plan: es justo lo que pretende el programa de publicaciones con aviso previo.
Ejercicio 9 (propuesto): Política en modo informe
Cambia el proxy.ts del laboratorio para enviar la política como Content-Security-Policy-Report-Only en las rutas del catálogo, y comprueba con Chromium que la página estática funciona y que la consola muestra los avisos sin bloquear nada. ¿Qué ventaja tiene desplegar una política así durante una semana antes de aplicarla?
Ejercicio 10 (propuesto): Auditoría de tu proyecto
Aplica la tabla del apartado 10.6 a tu versión de EMPRESA de los módulos anteriores. Para cada fila, anota el fichero que la cumple o el cambio que falta. Añade import 'server-only' donde corresponda y comprueba con next build que ningún Client Component lo rompe.
Resumen
| Concepto | API | Idea clave |
|---|---|---|
| Frontera servidor-cliente | 'use client' | Todo lo que la cruza (props, retornos de acciones) lo puede leer cualquiera |
| Fuga por props | Payload RSC | Lo que no pintas también viaja en el HTML |
| DAL y DTO | select({ ... }) | Recorta los datos en la consulta, con tipos según quién los ve |
| Módulos de servidor | import 'server-only' | Si un Client Component lo importa, el build falla con la traza |
| Variables públicas | NEXT_PUBLIC_ | Se escriben en el JavaScript al compilar; públicas para siempre |
| Secretos y prerender | process.env | Un Client Component prerenderizado puede escribir un secreto en el HTML |
| Taint de objetos | experimental_taintObjectReference | Error si la referencia cruza; las copias no se detectan |
| Taint de valores | experimental_taintUniqueValue | Marca el valor en todo el servidor mientras viva el objeto de vida |
| Activar taint | experimental.taint, react/experimental | Activa el canal experimental de React y marca process.env |
| CSRF | Comprobación de Origin | Next aborta si Origin no coincide con Host/X-Forwarded-Host |
| Cookies | SameSite=Lax, HttpOnly | La cookie no viaja en un POST desde otro sitio |
| Orígenes extra | serverActions.allowedOrigins | Solo el host público exacto; nunca comodines amplios |
| Tamaño | serverActions.bodySizeLimit | 1 MB por defecto; error 413 si se supera |
| Frecuencia | Limitador propio | Por IP y por cuenta, en un almacén compartido |
| Cabeceras | headers(), poweredByHeader | nosniff, Referrer-Policy, frame-ancestors, HSTS con cuidado |
| CSP con nonce | proxy.ts 🔄 16.0, x-nonce | Next pone el nonce en sus scripts si la CSP va en la petición |
| CSP y prerender | ○, ◐ | Sin petición no hay nonce: la página no hidrata y el build no avisa |
| Ruta bloqueante | instant = false 🆕 16.3 | Layout raíz con headers(): todo ƒ, nonce en todos los scripts |
| SRI | experimental.sri 🔄 16.2 | Alternativa experimental con hashes compatible con páginas estáticas |
| React2Shell | CVE-2025-66478 / CVE-2025-55182 | RCE con CVSS 10.0 en el protocolo RSC; solo lo arregla el parche |
| Fallos del 11-12-2025 | CVE-2025-55184, 67779, 55183 | DoS y exposición del código de las acciones; parche incompleto y segundo parche |
| Programa de parches | Blog de Next.js | Aviso previo mensual, parches para 16 y 15.5, urgencias aparte |
| Mantenerse al día | npm view next dist-tags, npm audit | El último 16.4.x; audit no sabe lo que aún no se ha publicado |