Defensa del Lado del Cliente
La seguridad del lado del cliente cubre todas las medidas que protegen al navegador, las sesiones de usuario y los datos procesados en el front-end. XSS, CSRF, Clickjacking, cookies inseguras, CORS mal configurado y headers de seguridad ausentes son las vulnerabilidades más comunes y directamente explotables desde el navegador.
Áreas cubiertas en este manual
El navegador es el runtime de toda aplicación web. Entender qué controles implementa automáticamente — y cuáles dependen del servidor — es el punto de partida para defender aplicaciones client-side.
Modelo de seguridad del navegador
| Mecanismo | Qué protege | Quién lo configura |
|---|---|---|
| Same-Origin Policy | Aísla el contenido de distintos orígenes — un script de evil.com no puede leer respuestas de bank.com | El navegador automáticamente |
| Sandbox de iframes | Limita lo que puede hacer un iframe embebido: sin acceso al DOM padre, sin navegación | Atributo sandbox en el HTML |
| HTTPS / TLS | Cifra el tráfico entre el navegador y el servidor para evitar intercepciones | Servidor + navegador |
| Flags de cookies | HttpOnly evita acceso JS; Secure solo envía por HTTPS; SameSite limita envío cross-site | El servidor al emitir la cookie |
| CSP | Controla qué scripts, estilos y recursos puede cargar la página | El servidor vía cabecera HTTP |
| CORS | Permite al servidor autorizar peticiones cross-origin de forma controlada | El servidor vía cabeceras HTTP |
XSS es la vulnerabilidad más común en aplicaciones web. Ocurre cuando la aplicación incluye datos no confiables en una página sin el encoding adecuado, permitiendo a un atacante ejecutar JavaScript en el navegador de otros usuarios.
Los 3 tipos de XSS
| Tipo | Cómo funciona | Persistencia | Impacto |
|---|---|---|---|
| Reflected XSS | El payload viaja en la URL o formulario, el servidor lo refleja sin sanitizar. La víctima debe hacer clic en un enlace malicioso. | No persiste | Alto |
| Stored XSS | El payload se almacena en la BBDD (comentario, perfil) y se muestra a todos los usuarios que visitan esa página. | Persiste en BBDD | Crítico |
| DOM-based XSS | JavaScript de la página lee datos del DOM (URL, hash) y los inserta sin sanitizar. El servidor nunca ve el payload. | Solo en cliente | Alto |
¿Qué puede hacer un atacante con XSS?
Contextos de inyección XSS
## CONTEXTO HTML — en el cuerpo de la página <script>alert(document.cookie)</script> <img src=x onerror="alert(1)"> <svg onload="alert(1)"> ## CONTEXTO ATRIBUTO HTML " onmouseover="alert(1) ← cierra el atributo e inyecta evento ## CONTEXTO JAVASCRIPT — dentro de un bloque JS '-alert(1)-' ← cierra el string y ejecuta ## DOM XSS — sink peligroso en el código del cliente // Si el código hace esto: document.getElementById('out').innerHTML = location.hash.slice(1); // El atacante visita: https://sitio.com/#<img src=x onerror=alert(1)>
Content Security Policy es la defensa más potente contra XSS. Es una cabecera HTTP que instruye al navegador sobre qué fuentes de contenido son legítimas. Si un script no viene de una fuente autorizada, el navegador lo bloquea — aunque el HTML contenga código XSS.
Directivas CSP más importantes
| Directiva | Controla | Ejemplo seguro |
|---|---|---|
default-src | Fuente por defecto para todos los recursos no especificados | default-src 'self' |
script-src | Fuentes permitidas para JavaScript | script-src 'self' 'nonce-RANDOM' |
style-src | Fuentes permitidas para CSS | style-src 'self' |
img-src | Fuentes permitidas para imágenes | img-src 'self' data: https: |
connect-src | URLs a las que JS puede conectarse (fetch, XHR, WebSocket) | connect-src 'self' api.ejemplo.com |
frame-ancestors | Qué páginas pueden embeber esta en un iframe | frame-ancestors 'none' |
report-uri | URL donde el navegador reporta violaciones de CSP | report-uri /csp-report |
## CABECERA HTTP Content-Security-Policy: default-src 'self'; script-src 'self'; img-src 'self' data:; frame-ancestors 'none'; report-uri /csp-violations ## NGINX add_header Content-Security-Policy "default-src 'self'; script-src 'self'; frame-ancestors 'none';" always; ## MODO REPORT-ONLY — probar sin bloquear (ideal para despliegue progresivo) Content-Security-Policy-Report-Only: default-src 'self'; script-src 'self'; report-uri /csp-violations ## NONCES — forma más segura para scripts inline legítimos # El servidor genera un nonce aleatorio por petición: Content-Security-Policy: script-src 'self' 'nonce-RANDOM_BASE64' # El HTML incluye el nonce en cada script legítimo: <script nonce="RANDOM_BASE64">codigo_legitimo();</script> # Scripts sin nonce correcto son bloqueados — incluyendo los de XSS
'unsafe-inline' en script-src cancela completamente la protección contra XSS. Wildcards (*) en script-src permiten scripts de cualquier dominio. Usar siempre nonces o hashes en lugar de unsafe-inline.La defensa primaria contra XSS es el encoding correcto según el contexto de salida. CSP es la segunda línea. Juntos hacen que XSS sea extremadamente difícil de explotar.
## CONTEXTO HTML — nunca innerHTML con datos del usuario element.innerHTML = userInput; // MAL — interpreta HTML y ejecuta scripts element.textContent = userInput; // BIEN — trata siempre como texto plano ## EN EL SERVIDOR (encoding de salida) htmlspecialchars($input, ENT_QUOTES); // PHP HtmlUtils.htmlEscape(input); // Java/Spring {{ input | e }} // Jinja2 (autoescape activo por defecto) ## CONTEXTO JAVASCRIPT — serializar como JSON, nunca concatenar var x = "'" + userInput + "'"; // MAL — inyección trivial var x = {{ input | tojson }}; // BIEN — escaping automático ## SANITIZACIÓN — cuando necesitas aceptar HTML del usuario # Usar una librería especializada, nunca hacer una propia DOMPurify.sanitize(userHTML); // JS — la más recomendada bleach.clean(userHTML, tags=allowed); // Python
Checklist de defensa XSS
- Nunca usar
innerHTML,document.write()oeval()con datos del usuario - Usar
textContenten lugar deinnerHTMLpara texto plano - HTML entity encoding en todas las salidas del servidor en contexto HTML
- CSP con nonces o hashes — sin
unsafe-inline - DOMPurify para HTML del usuario en el cliente
Clickjacking engaña al usuario para que haga clic en un elemento invisible. El atacante superpone un iframe transparente de la página objetivo sobre contenido atractivo — el usuario cree que hace clic en algo inofensivo pero activa una acción en la página objetivo.
<!-- Página maliciosa controlada por el atacante --> <style> iframe { position: absolute; opacity: 0.0; /* invisible para la víctima */ width: 400px; height: 200px; top: 120px; left: 50px; } .senhuelo { position: absolute; top: 130px; left: 60px; } </style> <iframe src="https://banco.com/transferir?destino=atacante&cantidad=1000"></iframe> <button class="senhuelo">Haz clic para ganar un premio</button> <!-- La víctima hace clic en el "premio" pero activa la transferencia bancaria -->
Vectores comunes de clickjacking
| Escenario | Qué consigue el atacante |
|---|---|
| Botón "Me gusta" en RRSS superpuesto | La víctima da like a contenido sin saberlo |
| Botón de transferencia bancaria | Iniciar una transferencia en nombre de la víctima |
| Botón "Activar cámara/micrófono" | Conceder permisos de hardware sin consentimiento |
| Formulario de compra superpuesto | Completar una compra en nombre de la víctima |
La defensa contra clickjacking es controlar quién puede embeber tu página en un iframe. Hay dos mecanismos: la cabecera X-Frame-Options (legacy) y la directiva CSP frame-ancestors (moderna y más flexible).
## X-FRAME-OPTIONS (amplio soporte, legacy) X-Frame-Options: DENY # nadie puede embeber esta página en un iframe X-Frame-Options: SAMEORIGIN # solo el mismo origen puede embeber ## CSP frame-ancestors (moderno — más flexible y con más opciones) Content-Security-Policy: frame-ancestors 'none'; # equivale a DENY Content-Security-Policy: frame-ancestors 'self'; # equivale a SAMEORIGIN Content-Security-Policy: frame-ancestors https://socio.com; # solo ese dominio ## NGINX — añadir las dos para máxima compatibilidad add_header X-Frame-Options "DENY" always; add_header Content-Security-Policy "frame-ancestors 'none';" always; ## APACHE Header always set X-Frame-Options "DENY"
Las cookies de sesión son el activo más crítico de una aplicación web. Si un atacante roba la cookie de sesión puede suplantar la identidad del usuario completamente. Los flags de seguridad son la primera línea de defensa.
Los flags de seguridad de las cookies
| Flag | Qué hace | Sin este flag... |
|---|---|---|
HttpOnly | La cookie NO es accesible desde JavaScript (document.cookie) | Un XSS puede robar la cookie con document.cookie y enviarla al atacante |
Secure | La cookie SOLO se envía por conexiones HTTPS | La cookie viaja en claro por HTTP — interceptable en redes inseguras |
SameSite=Strict | La cookie NO se envía en ninguna petición cross-site | La cookie se envía en peticiones cross-site — vulnerable a CSRF |
SameSite=Lax | La cookie se envía solo en navegación top-level GET cross-site, no en POST | Protección parcial — bloquea POSTs cross-site pero no todos los GETs |
SameSite=None; Secure | La cookie se envía siempre (para APIs cross-origin legítimas) | Sin Secure los navegadores modernos la rechazan directamente |
## CABECERA HTTP directa Set-Cookie: sessionId=abc123; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=3600 ## NODE.JS — Express con express-session app.use(session({ secret: process.env.SESSION_SECRET, cookie: { httpOnly: true, // no accesible por JavaScript secure: true, // solo por HTTPS sameSite: 'strict', // no enviar en peticiones cross-site maxAge: 3600000 // 1 hora en milisegundos } })); ## PYTHON — Flask app.config.update( SESSION_COOKIE_HTTPONLY=True, SESSION_COOKIE_SECURE=True, SESSION_COOKIE_SAMESITE='Strict', ) ## PHP session_set_cookie_params([ 'secure' => true, 'httponly' => true, 'samesite' => 'Strict' ]); ## PREFIJO __Host- (opción más segura disponible) Set-Cookie: __Host-sessionId=abc123; HttpOnly; Secure; SameSite=Strict; Path=/ # Garantiza: Secure activado, sin atributo Domain, Path=/ # Impide que subdominios comprometidos sobreescriban la cookie
CSRF explota que el navegador envía automáticamente las cookies al hacer peticiones a un dominio, independientemente del origen de la petición. Un atacante puede hacer que el navegador de la víctima envíe peticiones autenticadas a otra aplicación sin que ella lo sepa.
## EL ATAQUE — página maliciosa visitada por la víctima autenticada en banco.com <!-- GET — cargar la imagen dispara la petición autenticada --> <img src="https://banco.com/transferir?a=atacante&cantidad=5000" width="0"> <!-- POST — formulario auto-enviado al cargar la página --> <form action="https://banco.com/transferir" method="POST" id="f"> <input type="hidden" name="destino" value="atacante"> <input type="hidden" name="cantidad" value="5000"> </form> <script>document.getElementById('f').submit();</script> ## DEFENSA 1: Token CSRF — el más robusto <form method="POST" action="/transferir"> <input type="hidden" name="_csrf" value="{{ csrfToken }}"> ... </form> // En el servidor: verificar que el token coincide con el de la sesión ## DEFENSA 2: SameSite=Strict en la cookie de sesión Set-Cookie: sessionId=abc; SameSite=Strict; Secure; HttpOnly // La cookie no se envía en peticiones cross-site — CSRF imposible sin la cookie ## DEFENSA 3: Verificar el header Origin en el servidor if (req.headers.origin !== 'https://mi-dominio.com') { return res.status(403).send('Forbidden'); } ## Django — protección CSRF automática en todos los formularios <form method="POST">{% csrf_token %}</form>
Los fallos en la gestión de sesiones son la categoría A07 del OWASP Top 10. Incluyen tokens predecibles, sesiones que no expiran, session fixation y logout que no invalida el token en el servidor.
Requisitos de un token de sesión seguro
| Propiedad | Requisito | Por qué importa |
|---|---|---|
| Longitud | Mínimo 128 bits (16 bytes) de entropía real | Tokens cortos son vulnerables a fuerza bruta |
| Aleatoriedad | Generado con CSPRNG (crypto.randomBytes, secrets) | Math.random() de JS NO es criptográficamente seguro |
| Regeneración | Generar nuevo token tras cada login exitoso | Previene session fixation |
| Expiración | Timeout absoluto y por inactividad | Limita la ventana si un token es comprometido |
| Invalidación en logout | El logout destruye el token en el servidor | Eliminar solo la cookie del cliente no invalida la sesión |
## GENERAR TOKEN SEGURO const crypto = require('crypto'); const token = crypto.randomBytes(32).toString('hex'); // 256 bits de entropía ## REGENERAR TRAS LOGIN — prevenir session fixation app.post('/login', async (req, res) => { const user = await verificarCredenciales(req.body); if (user) { req.session.regenerate((err) => { // crea un nuevo ID de sesión req.session.userId = user.id; res.redirect('/dashboard'); }); } }); ## LOGOUT SEGURO — destruir en el servidor app.post('/logout', (req, res) => { req.session.destroy((err) => { // elimina la sesión del servidor res.clearCookie('sessionId'); res.redirect('/login'); }); }); ## TIMEOUT DE INACTIVIDAD app.use(session({ rolling: true, // renueva el timeout en cada petición cookie: { maxAge: 30 * 60 * 1000 } // 30 minutos de inactividad }));
La Same-Origin Policy (SOP) es el mecanismo de seguridad más fundamental del navegador. Define qué puede acceder a qué entre distintos orígenes. Sin SOP, cualquier script podría leer el contenido de cualquier otra página abierta en el navegador.
¿Qué define un "origen"?
Dos URLs tienen el mismo origen si comparten exactamente: esquema + dominio + puerto. Cualquier diferencia en estos tres elementos significa orígenes distintos.
URL de referencia: https://ejemplo.com:443/pagina.html https://ejemplo.com/otra.html → MISMO ORIGEN (443 implícito) http://ejemplo.com/pagina.html → DIFERENTE (esquema: http vs https) https://sub.ejemplo.com/pagina.html → DIFERENTE (subdominio distinto) https://ejemplo.com:8080/pagina.html → DIFERENTE (puerto distinto) https://otraempresa.com/pagina.html → DIFERENTE (dominio distinto)
¿Qué bloquea y qué permite la SOP?
| Acción | ¿Bloqueada por SOP? | Notas |
|---|---|---|
| Leer respuesta de fetch/XHR cross-origin | Sí — bloqueado | A menos que el servidor responda con cabeceras CORS adecuadas |
| Acceder al DOM de un iframe cross-origin | Sí — bloqueado | No se puede leer ni modificar el DOM de otro origen |
| Enviar una petición cross-origin | No — permitido | La petición llega al servidor — lo que se bloquea es LEER la respuesta |
| Cargar imágenes, scripts y CSS cross-origin | No — permitido | Por eso funcionan los CDNs. SRI añade verificación de integridad. |
CORS permite a un servidor relajar la SOP de forma controlada: autorizar peticiones de orígenes específicos. Una configuración incorrecta puede anular la SOP completamente y exponer todos los datos a cualquier origen.
## CABECERAS CORS CORRECTAS en la respuesta del servidor Access-Control-Allow-Origin: https://app.ejemplo.com ← origen explícito Access-Control-Allow-Methods: GET, POST Access-Control-Allow-Headers: Content-Type, Authorization Access-Control-Allow-Credentials: true ← solo si necesitas cookies Access-Control-Max-Age: 86400 ## NODE.JS — Express con cors (configuración segura) const cors = require('cors'); app.use(cors({ origin: ['https://app.ejemplo.com', 'https://admin.ejemplo.com'], // lista blanca methods: ['GET', 'POST'], credentials: true, })); ## ERRORES COMUNES que crean vulnerabilidades CORS // MAL — refleja cualquier origen sin validar res.header('Access-Control-Allow-Origin', req.headers.origin); // MAL — evil-ejemplo.com pasa la validación if (origin.includes('ejemplo.com')) { ... } // BIEN — comparación exacta contra lista blanca const permitidos = ['https://app.ejemplo.com']; if (permitidos.includes(req.headers.origin)) { res.header('Access-Control-Allow-Origin', req.headers.origin); }
Las cabeceras de seguridad HTTP instruyen al navegador para activar mecanismos de protección. Son fáciles de implementar y de alto impacto. Su ausencia es un hallazgo estándar en cualquier auditoría web.
## NGINX — añadir en el bloque server add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always; add_header X-Frame-Options "DENY" always; add_header X-Content-Type-Options "nosniff" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always; add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always; add_header Content-Security-Policy "default-src 'self'; frame-ancestors 'none';" always; server_tokens off; # ocultar versión de Nginx ## NODE.JS — Helmet hace todo esto en una línea const helmet = require('helmet'); app.use(helmet()); // activa todas las cabeceras con valores seguros por defecto
Descripción de cada cabecera
| Cabecera | Qué hace | Valor recomendado |
|---|---|---|
X-Content-Type-Options | Evita MIME-type sniffing — el navegador respeta el Content-Type declarado sin intentar adivinarlo | nosniff |
Referrer-Policy | Controla cuánta info de la URL actual se incluye en el header Referer al navegar a otro sitio | strict-origin-when-cross-origin |
Permissions-Policy | Controla el acceso a APIs del navegador: cámara, micrófono, geolocalización, notificaciones | geolocation=(), microphone=(), camera=() |
HSTS instruye al navegador para que siempre use HTTPS con este dominio, incluso si el usuario escribe http:// o hace clic en un enlace http://. Elimina los ataques de downgrade y SSL stripping.
## CABECERA HSTS COMPLETA Strict-Transport-Security: max-age=31536000; includeSubDomains; preload max-age=31536000 → el navegador recuerda usar HTTPS durante 1 año includeSubDomains → aplica también a todos los subdominios preload → solicitar inclusión en la lista preload del navegador ## DESPLIEGUE PROGRESIVO — aumentar max-age gradualmente Strict-Transport-Security: max-age=300 # 5 min — testear que todo va bien Strict-Transport-Security: max-age=86400 # 1 día — confirmar Strict-Transport-Security: max-age=2592000 # 30 días Strict-Transport-Security: max-age=31536000; includeSubDomains; preload # definitivo ## Registrar en la lista preload: https://hstspreload.org
SRI permite al navegador verificar que los recursos cargados desde CDNs no han sido manipulados. Si el hash del archivo no coincide, el navegador no lo ejecuta — aunque el CDN haya sido comprometido.
## SRI EN HTML — añadir integrity y crossorigin <script src="https://cdn.jsdelivr.net/npm/jquery@3.7.1/dist/jquery.min.js" integrity="sha256-/JqT3SQfawRcv/BIHPThkBvs0OEvtFFmqPF/lYI/Cxo=" crossorigin="anonymous"> </script> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.3.0/css/bootstrap.min.css" integrity="sha384-9ndCyUaIbzAi2FUVXJi0CjmCapSmO7SnpJef0486qhLnuZ2cdeRhO02iuK6FUUVM" crossorigin="anonymous"> ## GENERAR HASH SRI desde la terminal curl -s https://cdn.ejemplo.com/libreria.js | openssl dgst -sha384 -binary | openssl base64 -A ## GENERADOR ONLINE: https://www.srihash.org ## Si el hash no coincide, el navegador bloquea el recurso: Subresource Integrity check failed for resource https://cdn.ejemplo.com/libreria.js — posible compromiso del CDN
El DOM expone muchas APIs potencialmente peligrosas. Usarlas con datos no confiables es la causa principal de DOM XSS y otras vulnerabilidades del lado del cliente.
APIs del DOM peligrosas (sinks)
| API peligrosa | Riesgo | Alternativa segura |
|---|---|---|
element.innerHTML = input | XSS — interpreta el string como HTML con scripts | element.textContent = input |
document.write(input) | XSS — escribe HTML directamente en el documento | Manipulación del DOM con createElement |
eval(input) | RCE en el cliente — ejecuta código JS arbitrario | Nunca usar eval con datos del usuario |
setTimeout(input, 0) | Como eval si el primer argumento es un string | Usar siempre función: setTimeout(() => fn(), 0) |
location.href = input | Open redirect o javascript: URI | Validar que la URL pertenece al dominio propio |
## QUÉ NO guardar en localStorage localStorage.setItem('sessionToken', token); // MAL — accesible por JS y por XSS ## Por qué es inseguro para tokens de autenticación: - Accesible por cualquier JS de la página (incluyendo código XSS) - No tiene flags como HttpOnly - Persiste aunque el usuario cierre el navegador ## Alternativa segura: cookies HttpOnly gestionadas por el servidor El servidor emite la cookie con HttpOnly — el JS nunca puede leerla. El navegador la envía automáticamente en cada petición. ## postMessage — validar SIEMPRE el origen del mensaje # ENVIAR: especificar el origen destino (nunca '*' con datos sensibles) window.parent.postMessage({ tipo: 'datos' }, 'https://destino.com'); # RECIBIR: verificar el origen antes de procesar window.addEventListener('message', (event) => { if (event.origin !== 'https://origen-confiable.com') return; // crítico // solo aquí procesar event.data });
Herramientas y técnicas para evaluar la seguridad del lado del cliente en tu propio proyecto o en auditorías autorizadas.
## VERIFICAR SECURITY HEADERS desde la terminal curl -I https://ejemplo.com | grep -iE "strict-transport|x-frame|x-content|referrer|permissions|content-security" ## HERRAMIENTAS ONLINE securityheaders.com → calificación A-F de todas las cabeceras observatory.mozilla.org → auditoría completa de Mozilla csp-evaluator.withgoogle.com → analizar política CSP y detectar bypasses ssllabs.com/ssltest → configuración TLS/SSL y HSTS ## NIKTO — escaneo de configuración del servidor nikto -h https://localhost:3000 -ssl ## OWASP ZAP — escaneo pasivo y activo zaproxy -cmd -quickurl http://localhost:3000 -quickout /tmp/zap_report.html ## VERIFICAR COOKIES desde las DevTools del navegador Chrome/Firefox → F12 → Application → Cookies Verificar que cada cookie de sesión tiene: HttpOnly ✓, Secure ✓, SameSite: Strict ## RETIRE.JS — detectar librerías JavaScript vulnerables npm install -g retire && retire --path ./public/js/
Configuración completa de seguridad client-side para un servidor Nginx con todas las cabeceras, TLS seguro y redirección HTTP a HTTPS.
# /etc/nginx/sites-available/mi-app-segura server { listen 443 ssl http2; server_name ejemplo.com; ssl_certificate /etc/letsencrypt/live/ejemplo.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/ejemplo.com/privkey.pem; # TLS seguro — solo TLS 1.2 y 1.3 ssl_protocols TLSv1.2 TLSv1.3; ssl_ciphers ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384; ssl_prefer_server_ciphers off; # Security Headers add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always; add_header X-Frame-Options "DENY" always; add_header X-Content-Type-Options "nosniff" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always; add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always; add_header Content-Security-Policy "default-src 'self'; script-src 'self'; img-src 'self' data:; frame-ancestors 'none'; report-uri /csp-violations" always; server_tokens off; # ocultar versión de Nginx location / { proxy_pass http://localhost:3000; proxy_set_header Host $host; proxy_hide_header X-Powered-By; # ocultar tecnología del backend } } # Redirigir HTTP → HTTPS server { listen 80; server_name ejemplo.com; return 301 https://$host$request_uri; }
Aplicación Express.js con todas las medidas implementadas: Helmet, cookies seguras, CSRF con tokens, CSP con nonces y logout correcto.
// npm install express helmet express-session crypto
const express = require('express');
const helmet = require('helmet');
const session = require('express-session');
const crypto = require('crypto');
const app = express();
// 1. HELMET — security headers automáticos
app.use(helmet({ contentSecurityPolicy: false })); // CSP manual con nonces
// 2. CSP CON NONCES — un nonce único y aleatorio por petición
app.use((req, res, next) => {
const nonce = crypto.randomBytes(16).toString('base64');
res.locals.nonce = nonce;
res.setHeader('Content-Security-Policy',
`default-src 'self'; ` +
`script-src 'self' 'nonce-${nonce}'; ` +
`frame-ancestors 'none'; ` +
`report-uri /csp-violations`
);
next();
});
// 3. SESIONES SEGURAS con prefijo __Host-
app.use(session({
secret: process.env.SESSION_SECRET || crypto.randomBytes(32).toString('hex'),
name: '__Host-sid', // prefijo __Host- — máxima seguridad de cookie
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 30 * 60 * 1000 // 30 minutos de inactividad
}
}));
// 4. CSRF — token por sesión verificado en cada POST/PUT/DELETE
app.use((req, res, next) => {
if (!req.session.csrfToken)
req.session.csrfToken = crypto.randomBytes(32).toString('hex');
res.locals.csrfToken = req.session.csrfToken;
next();
});
const verificarCSRF = (req, res, next) => {
if (['POST','PUT','DELETE','PATCH'].includes(req.method)) {
const token = req.body._csrf || req.headers['x-csrf-token'];
if (token !== req.session.csrfToken)
return res.status(403).json({ error: 'Token CSRF inválido' });
}
next();
};
app.use(express.urlencoded({ extended: true }));
app.use(verificarCSRF);
// 5. LOGOUT SEGURO — destruir sesión en el servidor
app.post('/logout', (req, res) => {
req.session.destroy(() => {
res.clearCookie('__Host-sid');
res.redirect('/login');
});
});
// 6. ENDPOINT para reportes de violaciones CSP
app.post('/csp-violations', express.json({ type: 'application/csp-report' }), (req, res) => {
console.log('[CSP Violation]', req.body);
res.status(204).send();
});
app.listen(3000, () => console.log('[+] Servidor en http://localhost:3000'));Verificación completa de todas las medidas — válida como checklist de auditoría o de implementación.
Cabeceras HTTP de Seguridad
- HSTS con max-age mínimo 1 año e includeSubDomains
- Content-Security-Policy sin unsafe-inline en script-src
- X-Frame-Options: DENY o CSP frame-ancestors 'none'
- X-Content-Type-Options: nosniff
- Referrer-Policy: strict-origin-when-cross-origin
- Permissions-Policy desactivando APIs no usadas
- Versión del servidor ocultada (server_tokens off)
Cookies
- Cookie de sesión con HttpOnly
- Cookie de sesión con Secure
- Cookie de sesión con SameSite=Strict
- Prefijo __Host- en cookies de sesión críticas
- Max-Age definido — no sesiones permanentes
XSS y CSP
- Ningún uso de innerHTML, document.write() o eval() con datos del usuario
- textContent en lugar de innerHTML para texto plano
- DOMPurify para sanitizar HTML del usuario
- CSP implementada con nonces o hashes — sin unsafe-inline
- report-uri configurado para detectar violaciones CSP
CSRF, Sesiones y Recursos Externos
- Tokens CSRF en todos los formularios de cambio de estado
- Token de sesión regenerado tras login (prevenir fixation)
- Logout destruye el token en el servidor — no solo la cookie
- Tokens generados con CSPRNG (crypto.randomBytes)
- SRI (integrity + crossorigin) en scripts y CSS de CDN
- CORS con lista blanca explícita — no wildcard con credenciales
- postMessage valida siempre el origen del mensaje
Herramientas de verificación
| Herramienta | URL | Qué verifica |
|---|---|---|
| Security Headers | securityheaders.com | Calificación A-F de todas las cabeceras |
| Mozilla Observatory | observatory.mozilla.org | Auditoría completa: headers, cookies, CSP, CORS |
| CSP Evaluator (Google) | csp-evaluator.withgoogle.com | Analiza la CSP y detecta bypasses |
| SSL Labs | ssllabs.com/ssltest | Configuración TLS y HSTS |
| HSTS Preload | hstspreload.org | Estado en la lista preload |
| SRI Hash Generator | srihash.org | Generar hashes SRI para CDN |