V3 · Seguridad de Frontend Web#

¿De qué trata esta sección?#

V3 se enfoca en los riesgos que existen dentro del navegador: cómo el browser interpreta, renderiza y comparte recursos entre distintos orígenes. Un atacante que no puede penetrar el servidor puede aún atacar al usuario desde el cliente — mediante scripts maliciosos, cookies mal configuradas, o abusando de la forma en que los navegadores comparten recursos entre pestañas y dominios.

Los ataques de frontend no siempre explotan código del servidor. A menudo explotan decisiones de configuración que el desarrollador tomó (o no tomó) sobre cabeceras HTTP, atributos de cookies, y cómo se cargan recursos externos.

**Diferencia clave con otras secciones:** - **V1** pregunta: *¿Este dato es técnicamente seguro?* - **V2** pregunta: *¿Este dato tiene sentido para mi aplicación?* - **V3** pregunta: *¿Está el navegador configurado para proteger al usuario incluso si el atacante ya está en la página?*

V3.1 · Documentación de Seguridad de Frontend#

Antes de configurar controles, hay que documentar qué características de seguridad del navegador se requieren y qué pasa si no están disponibles.

#RequisitoNivel
3.1.1Documentar las características de seguridad del navegador que la aplicación requiere (HTTPS, HSTS, CSP, etc.), y cómo debe comportarse si alguna no está disponible (advertir al usuario o bloquear acceso).3

Ejemplo de documentación de características requeridas#

# browser-security-requirements.yaml

caracteristicas_requeridas:
  - nombre: HTTPS
    nivel: obligatorio
    si_no_disponible: "bloquear acceso, redirigir a página de error"

  - nombre: HSTS
    nivel: obligatorio
    si_no_disponible: "mostrar advertencia, registrar en logs"

  - nombre: Content Security Policy (CSP)
    nivel: obligatorio (L2+)
    si_no_disponible: "registrar violaciones, informar al equipo de seguridad"

  - nombre: SameSite cookies
    nivel: obligatorio
    si_no_disponible: "considerar al usuario en riesgo de CSRF"

comportamiento_navegador_inseguro:
  accion: "mostrar banner de advertencia al usuario"
  mensaje: "Tu navegador no cumple los requisitos mínimos de seguridad."
  bloquear_acceso_en_nivel: L3

V3.2 · Interpretación No Intencional de Contenido#

Renderizar contenido en un contexto incorrecto puede llevar a que scripts maliciosos se ejecuten o que archivos del usuario se interpreten como HTML.

#RequisitoNivel
3.2.1Verificar que existen controles para evitar que el navegador renderice contenido en un contexto incorrecto. Usar cabeceras Sec-Fetch-*, Content-Disposition: attachment o la directiva sandbox de CSP.1
3.2.2Verificar que el contenido destinado a mostrarse como texto usa funciones seguras como createTextNode o textContent, en lugar de innerHTML.1
3.2.3Verificar que la aplicación evita DOM clobbering mediante declaraciones explícitas de variables, type-checking estricto, y aislamiento de namespaces.3

Ejemplo: innerHTML vs textContent#

// ❌ MAL: innerHTML interpreta el string como HTML, ejecuta scripts
function mostrarComentario(comentario) {
    document.getElementById('comentarios').innerHTML = comentario;
    // Si comentario = "<img src=x onerror=alert(1)>", se ejecuta el JS
}

// ✅ BIEN: textContent trata el string como texto plano, nunca ejecuta nada
function mostrarComentario(comentario) {
    document.getElementById('comentarios').textContent = comentario;
    // El mismo string se muestra literalmente, sin ejecutarse
}

// ✅ BIEN: createTextNode, equivalente seguro
function mostrarComentario(comentario) {
    const nodo = document.createTextNode(comentario);
    document.getElementById('comentarios').appendChild(nodo);
}

Ejemplo: Evitar que un archivo subido se renderice como HTML#

# Flask: forzar descarga en lugar de renderizado del navegador

from flask import send_from_directory, abort
import os

EXTENSIONES_PERMITIDAS = {'pdf', 'png', 'jpg', 'csv', 'docx'}

@app.route('/descargar/<nombre_archivo>')
def descargar_archivo(nombre_archivo):
    extension = nombre_archivo.rsplit('.', 1)[-1].lower()

    if extension not in EXTENSIONES_PERMITIDAS:
        abort(403)

    # Content-Disposition: attachment fuerza la descarga
    # El navegador NO intentará renderizar el archivo
    return send_from_directory(
        '/ruta/uploads',
        nombre_archivo,
        as_attachment=True  # ← Esto genera el header Content-Disposition: attachment
    )

V3.3 · Configuración de Cookies#

Las cookies de sesión son un objetivo de alto valor. Su configuración incorrecta puede permitir que sean robadas por JavaScript malicioso, enviadas a sitios cruzados, o modificadas por el atacante.

#RequisitoNivel
3.3.1Verificar que las cookies tienen el atributo Secure, y que el nombre usa el prefijo __Host- o como mínimo __Secure-.1
3.3.2Verificar que el atributo SameSite está configurado correctamente según el propósito de la cookie, para limitar ataques CSRF.2
3.3.3Verificar que las cookies usan el prefijo __Host- a menos que estén diseñadas explícitamente para compartirse entre subdominios.2
3.3.4Verificar que si el valor de una cookie no debe ser accesible desde JavaScript (como un token de sesión), tiene el atributo HttpOnly y solo se transfiere via Set-Cookie.2
3.3.5Verificar que el nombre y valor de la cookie combinados no superan los 4096 bytes, ya que el navegador descartaría una cookie mayor.3

Ejemplo: Atributos de cookies seguros en Flask#

from flask import Flask, session
from datetime import timedelta

app = Flask(__name__)
app.secret_key = "clave_muy_segura_y_aleatoria"

# Configuración global de la cookie de sesión
app.config.update(
    SESSION_COOKIE_SECURE=True,       # Solo enviada por HTTPS
    SESSION_COOKIE_HTTPONLY=True,     # Inaccesible desde JavaScript
    SESSION_COOKIE_SAMESITE='Lax',    # Protección CSRF: no se envía en requests cross-site
    SESSION_COOKIE_NAME='__Host-session',  # Prefijo __Host-: máxima protección
    PERMANENT_SESSION_LIFETIME=timedelta(hours=2)
)

# ¿Cuándo usar qué valor de SameSite?
# 'Strict' → La cookie NUNCA se envía en requests cross-site (máxima protección CSRF)
#            Problema: si el usuario llega a tu app desde un link externo, no tiene sesión
# 'Lax'    → La cookie se envía en navegación top-level (clicks en links) pero no en
#            requests de fondo como imágenes o iframes. Balance recomendado.
# 'None'   → La cookie se envía siempre. SOLO para casos de terceros (ej: OAuth).
#            REQUIERE también Secure=True.
# El nombre de la cookie actúa como una "firma" del servidor

# ❌ MAL: cookie sin prefijo, puede ser sobreescrita por subdominios
response.set_cookie('session', value=token)

# ✅ BIEN: prefijo __Secure-
# Garantiza: la cookie solo fue establecida via HTTPS
# Permite: cualquier Path y Domain
response.set_cookie('__Secure-session', value=token, secure=True)

# ✅ MEJOR: prefijo __Host-
# Garantiza: establecida via HTTPS, desde el host exacto (no subdominio),
#            con Path=/, sin Domain explícito
# Es la opción más segura para cookies de sesión en apps sin subdominios.
response.set_cookie(
    '__Host-session',
    value=token,
    secure=True,
    httponly=True,
    samesite='Lax',
    path='/'
    # NO incluir domain= cuando se usa __Host-
)

V3.4 · Cabeceras de Seguridad del Navegador#

Las cabeceras HTTP de seguridad le indican al navegador qué restricciones debe aplicar al procesar las respuestas. Son la primera línea de defensa contra XSS, clickjacking, y robo de información.

#RequisitoNivel
3.4.1Incluir Strict-Transport-Security en todas las respuestas. Edad mínima: 1 año. En L2+, debe incluir includeSubDomains.1
3.4.2El header Access-Control-Allow-Origin debe ser un valor fijo o validado contra una allowlist. Si se usa *, la respuesta no debe contener información sensible.1
3.4.3Incluir Content-Security-Policy con al menos object-src 'none' y base-uri 'none', usando allowlist, nonces o hashes. En L3, la política debe ser por-respuesta con nonces/hashes.2
3.4.4Incluir X-Content-Type-Options: nosniff en todas las respuestas HTTP para evitar MIME-sniffing.2
3.4.5Configurar Referrer-Policy para evitar que datos técnicamente sensibles (rutas, queries) sean enviados a terceros via la cabecera Referer.2
3.4.6Usar la directiva frame-ancestors de CSP en cada respuesta para controlar quién puede embeber la aplicación. El header X-Frame-Options está obsoleto.2
3.4.7El CSP debe especificar una URL de reporte de violaciones.3
3.4.8Las respuestas que inicien renderizado de documento deben incluir Cross-Origin-Opener-Policy: same-origin o same-origin-allow-popups para prevenir ataques como tabnabbing.3

Ejemplo: Middleware de cabeceras de seguridad en Flask#

from flask import Flask

app = Flask(__name__)

@app.after_request
def agregar_cabeceras_seguridad(response):

    # Forzar HTTPS por al menos 1 año, incluyendo subdominios
    response.headers['Strict-Transport-Security'] = (
        'max-age=31536000; includeSubDomains'
    )

    # Evitar que el navegador "adivine" el tipo de contenido
    response.headers['X-Content-Type-Options'] = 'nosniff'

    # No enviar el Referer completo a sitios externos
    response.headers['Referrer-Policy'] = 'strict-origin-when-cross-origin'

    # Prohibir embeber la app en iframes de otros dominios
    # (reemplaza al obsoleto X-Frame-Options)
    response.headers['Content-Security-Policy'] = (
        "default-src 'self'; "
        "object-src 'none'; "          # Bloquea Flash, plugins
        "base-uri 'none'; "            # Bloquea inyección de base URL
        "frame-ancestors 'none'; "     # Bloquea embebido en iframes
        "report-uri /csp-reporte"      # Enviar violaciones a este endpoint
    )

    # Proteger contra tabnabbing y frame counting
    response.headers['Cross-Origin-Opener-Policy'] = 'same-origin'

    return response

@app.route('/csp-reporte', methods=['POST'])
def recibir_reporte_csp():
    # Registrar violaciones de CSP para análisis
    import logging
    logging.warning(f"CSP Violation: {request.get_json()}")
    return '', 204

Ejemplo: CSP con nonces (L3 — política por respuesta)#

import secrets
from flask import g

@app.before_request
def generar_nonce():
    # Generar un nonce único por request
    g.csp_nonce = secrets.token_urlsafe(16)

@app.after_request
def csp_con_nonce(response):
    nonce = getattr(g, 'csp_nonce', '')
    response.headers['Content-Security-Policy'] = (
        f"default-src 'self'; "
        f"script-src 'nonce-{nonce}' 'strict-dynamic'; "
        f"object-src 'none'; "
        f"base-uri 'none';"
    )
    return response

# En la plantilla HTML: usar el nonce en cada <script>
# <script nonce="{{ g.csp_nonce }}">
#     // Solo este script es ejecutado — cualquier script inyectado no tiene el nonce
# </script>

V3.5 · Separación de Orígenes en el Navegador#

Cuando una solicitud llega al servidor a funcionalidad sensible, la aplicación debe verificar que fue iniciada por ella misma y no fue forjada por un atacante desde otro origen.

#RequisitoNivel
3.5.1Si no se usa el mecanismo de preflight CORS para proteger funcionalidad sensible, verificar que las solicitudes usan anti-forgery tokens o cabeceras HTTP no estandarizadas por CORS (protección CSRF).1
3.5.2Si se usa CORS preflight como protección, verificar que no es posible llamar a la funcionalidad con una solicitud que no active el preflight.1
3.5.3Las solicitudes a funcionalidad sensible deben usar métodos HTTP apropiados (POST, PUT, PATCH, DELETE) y no métodos “seguros” (GET, HEAD, OPTIONS).1
3.5.4Aplicaciones separadas deben estar en hostnames distintos para respetar la same-origin policy y el aislamiento de cookies.2
3.5.5Los mensajes recibidos por postMessage deben descartarse si el origen no es confiable o la sintaxis del mensaje es inválida.2
3.5.6Verificar que JSONP está deshabilitado en toda la aplicación para evitar ataques XSSI.3
3.5.7Datos que requieran autorización no deben incluirse en archivos de script (JS) para prevenir XSSI.3
3.5.8Recursos autenticados (imágenes, videos, scripts) solo deben poder cargarse cuando sea intencional, usando validación estricta de Sec-Fetch-* o Cross-Origin-Resource-Policy.3

Ejemplo: Protección CSRF con token anti-forgery#

# Flask-WTF maneja CSRF automáticamente con tokens, pero aquí la lógica manual:

import secrets
from flask import session, request, jsonify, abort

def generar_csrf_token():
    if 'csrf_token' not in session:
        session['csrf_token'] = secrets.token_hex(32)
    return session['csrf_token']

def validar_csrf_token():
    """Validar en todas las rutas que cambian estado (POST, PUT, DELETE)"""
    if request.method in ['POST', 'PUT', 'PATCH', 'DELETE']:
        token_enviado = request.headers.get('X-CSRF-Token') or \
                        request.form.get('csrf_token')
        token_esperado = session.get('csrf_token')

        if not token_enviado or not secrets.compare_digest(
            token_enviado, token_esperado or ''
        ):
            abort(403)  # Forbidden: posible ataque CSRF

@app.before_request
def csrf_protection():
    validar_csrf_token()

# En el HTML, incluir el token en formularios:
# <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
# O en peticiones AJAX: headers: {'X-CSRF-Token': csrfToken}

Ejemplo: postMessage con validación de origen#

// ❌ MAL: aceptar mensajes de cualquier origen
window.addEventListener('message', function(event) {
    ejecutarAccion(event.data);  // Cualquier sitio puede enviar comandos arbitrarios
});

// ✅ BIEN: validar que el origen es uno de los esperados
const ORIGENES_PERMITIDOS = [
    'https://app.midominio.com',
    'https://panel.midominio.com'
];

window.addEventListener('message', function(event) {
    // Validar origen
    if (!ORIGENES_PERMITIDOS.includes(event.origin)) {
        console.warn(`postMessage rechazado de origen: ${event.origin}`);
        return;
    }

    // Validar estructura del mensaje
    const { tipo, payload } = event.data || {};
    if (typeof tipo !== 'string' || !payload) {
        console.warn('Mensaje con formato inválido');
        return;
    }

    // Solo entonces procesar
    ejecutarAccion(tipo, payload);
});

V3.6 · Integridad de Recursos Externos#

Cargar scripts o estilos desde CDNs externos sin verificación es un riesgo: si el CDN es comprometido, el script malicioso corre en todos tus usuarios.

#RequisitoNivel
3.6.1Los recursos estáticos cargados desde CDNs externos (JS, CSS, fuentes) deben usar Subresource Integrity (SRI) para verificar su integridad. Si no es posible, debe haber una decisión documentada de seguridad para cada recurso.3

Ejemplo: Subresource Integrity (SRI)#

<!-- ❌ MAL: cargar desde CDN sin verificar integridad -->
<script src="https://cdn.ejemplo.com/libreria.min.js"></script>
<!-- Si el CDN es comprometido, este script malicioso corre en tus usuarios -->

<!-- ✅ BIEN: SRI con hash SHA-384 -->
<script
  src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"
  integrity="sha384-YvpcrYf0tY3lHB60NNkmXc4s9bIOgUxi8T/jzmFnxhJt8EVjMgCn6pWxHfHqsHT6"
  crossorigin="anonymous">
</script>
<!-- Si el archivo no coincide con el hash, el navegador lo rechaza -->

<!--
  ¿Cómo generar el hash?
  $ curl -s https://cdn.example.com/lib.js | openssl dgst -sha384 -binary | openssl base64 -A

  O usar: https://www.srihash.org
-->

V3.7 · Otras Consideraciones de Seguridad del Navegador#

#RequisitoNivel
3.7.1La aplicación debe usar solo tecnologías de cliente soportadas y seguras. Tecnologías obsoletas como Flash, ActiveX, Silverlight, NACL o applets de Java no deben usarse.2
3.7.2La aplicación solo debe redirigir automáticamente al usuario a hostnames externos si el destino aparece en una allowlist.2
3.7.3Mostrar una notificación al usuario cuando sea redirigido fuera del control de la aplicación, con opción de cancelar.3
3.7.4El dominio principal de la aplicación debe estar en la lista de preload de HSTS para que los navegadores usen TLS directamente, sin depender solo del header.3
3.7.5La aplicación debe comportarse como está documentado (advertir o bloquear acceso) si el navegador no soporta las características de seguridad esperadas.3

Ejemplo: Validar redirecciones con allowlist#

from urllib.parse import urlparse

DOMINIOS_PERMITIDOS = {
    'app.midominio.com',
    'panel.midominio.com',
    'docs.midominio.com'
}

def es_redireccion_segura(url: str) -> bool:
    """Verificar que una URL de redirección es a un destino permitido."""
    try:
        parsed = urlparse(url)
        # Debe tener esquema y hostname válidos
        if not parsed.scheme in ('http', 'https'):
            return False
        if parsed.hostname not in DOMINIOS_PERMITIDOS:
            return False
        return True
    except Exception:
        return False

@app.route('/redirigir')
def redirigir():
    destino = request.args.get('url', '')

    if not es_redireccion_segura(destino):
        # Redirigir a página de error o a inicio
        return redirect(url_for('inicio'))

    return redirect(destino)

# ❌ Nunca hacer esto:
# return redirect(request.args.get('url'))  # Open redirect — cualquiera puede redirigir a phishing

Resumen: ¿Qué cubre cada subsección?#

SecciónPregunta que respondeTécnica principal
V3.1¿Están documentados los requisitos del navegador?Documentación de características de seguridad esperadas
V3.2¿Se renderiza el contenido en el contexto correcto?textContent, Content-Disposition, CSP sandbox
V3.3¿Las cookies son resistentes a robo y CSRF?Atributos Secure, HttpOnly, SameSite, prefijo __Host-
V3.4¿Le dice la app al navegador qué restricciones aplicar?Cabeceras: HSTS, CSP, COOP, nosniff, Referrer-Policy
V3.5¿Se verifica que el request vino del origen correcto?CSRF tokens, validación de Origin, postMessage seguro
V3.6¿Son confiables los recursos de terceros?Subresource Integrity (SRI)
V3.7¿Hay protecciones adicionales del lado del cliente?Allowlists de redirección, tecnologías modernas, HSTS preload
**Regla de oro de V3:** El navegador es un aliado, no solo un cliente. Configúralo explícitamente — si no le dices qué puede hacer, lo intentará todo.