Módulo 5: Security Headers y Sessions — Introducción

Cápsula 02: Security headers

Descripción

Los security headers son instrucciones que tu API envía al cliente diciéndole "trátame de esta forma específica para protegerme contra estos ataques". El browser los respeta automáticamente. Apps con headers correctos previenen XSS, clickjacking, downgrade attacks, MIME sniffing.

En esta cápsula vas a implementar middleware FastAPI que aplica los headers correctos a TODAS las responses, vas a entender qué ataque previene cada header, y vas a aprender por qué algunos (como CSP) son distintos para APIs vs web apps.


Los headers críticos

HeaderPara qué
Strict-Transport-Security (HSTS)Forzar HTTPS, prevenir downgrade attacks
X-Content-Type-OptionsPrevenir MIME sniffing
X-Frame-OptionsPrevenir clickjacking
Content-Security-Policy (CSP)Controlar qué recursos puede cargar el browser
Referrer-PolicyControlar qué info de referer se manda
Permissions-PolicyControlar acceso a APIs del browser (camera, geolocation)

Vamos uno por uno.


1. Strict-Transport-Security (HSTS)

Header:

Strict-Transport-Security: max-age=31536000; includeSubDomains; preload

Qué hace: le dice al browser "siempre usa HTTPS para este dominio durante el próximo año, para todos los subdominios".

Qué previene: downgrade attacks. Sin HSTS, un atacante en la red puede interceptar la primera request HTTP del usuario y hacerle creer que el sitio no soporta HTTPS. Con HSTS, el browser nunca acepta HTTP después de la primera vez.

Componentes:

  • max-age=31536000 — 1 año en segundos (recomendación OWASP)
  • includeSubDomains — aplica a *.tudominio.com también
  • preload — opcional; permite que el browser cargue HSTS antes de la primera visita (requiere submit a hstspreload.org)

SOLO en HTTPS. En HTTP el header se ignora. NO sirve si tu API está en HTTP.


2. X-Content-Type-Options

Header:

X-Content-Type-Options: nosniff

Qué hace: previene que el browser intente "adivinar" el MIME type del response (lo respeta tal cual lo declaras).

Qué previene: un atacante sube un archivo .txt que en realidad contiene JavaScript. Sin nosniff, algunos browsers lo ejecutan como JS. Con nosniff, lo tratan estrictamente como texto.

Para APIs JSON: importante porque previene que un response JSON sea interpretado como HTML/JS si tu Content-Type tiene un typo.

Configuración: un solo valor nosniff. Aplica siempre, sin trade-offs.


3. X-Frame-Options

Header:

X-Frame-Options: DENY

Qué hace: previene que tu sitio sea cargado en un <iframe>.

Qué previene: clickjacking. Un atacante carga tu app en un iframe invisible, superpone elementos para que el usuario haga click sin saber.

Valores:

  • DENY — nunca puede ser embebido en iframe
  • SAMEORIGIN — solo del mismo dominio
  • ALLOW-FROM uri — DEPRECATED, no usar

Para APIs: las APIs no deberían cargarse en iframes nunca. DENY es la opción correcta.

Note: modernamente reemplazado por Content-Security-Policy: frame-ancestors. Mantener X-Frame-Options para compat con browsers viejos.


4. Content-Security-Policy (CSP)

Header (para una API JSON):

Content-Security-Policy: default-src 'none'; frame-ancestors 'none'

Qué hace: controla qué recursos (scripts, imágenes, frames, etc.) el browser puede cargar al renderizar tu response.

Qué previene: XSS (cross-site scripting). Si un atacante logra inyectar <script> en tu response, CSP lo bloquea antes de ejecutarse.

Para APIs JSON específicamente:

default-src 'none'           ← no permitir cargar NADA por default
frame-ancestors 'none'       ← no permitir embedding en iframes

Como tu API solo retorna JSON, no necesitas permitir scripts/images/styles. default-src 'none' es lo más restrictivo y correcto.

Para APIs que también sirven HTML (ej. /docs de Swagger):

Content-Security-Policy: default-src 'self'; script-src 'self' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net

(/docs requiere CDN de Swagger UI, lo permites explícitamente)


5. Referrer-Policy

Header:

Referrer-Policy: strict-origin-when-cross-origin

Qué hace: controla qué información del Referer header se manda en requests subsecuentes.

Por qué importa: sin esto, si tu API está en api.myapp.com/users/42, el browser puede mandar esa URL completa como Referer a sitios externos. La URL puede contener info sensible (IDs, tokens query string, etc.).

Valores comunes:

  • no-referrer — nunca mandar Referer
  • strict-origin-when-cross-origin — solo el origin (no el path), y solo cuando va al mismo origin
  • same-origin — solo a recursos del mismo origin

Recomendado: strict-origin-when-cross-origin para APIs.


6. Permissions-Policy (opcional pero recomendado)

Header:

Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=()

Qué hace: restringe qué APIs del browser pueden ser usadas.

Para APIs JSON: las APIs del browser no aplican a tu response. Lo dejas restrictivo (todas vacías) para defensa en profundidad por si tu response alguna vez sirve HTML.


Implementación con FastAPI middleware

# app/middleware/security_headers.py
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import Response


SECURITY_HEADERS = {
    "Strict-Transport-Security": "max-age=31536000; includeSubDomains",
    "X-Content-Type-Options": "nosniff",
    "X-Frame-Options": "DENY",
    "Content-Security-Policy": "default-src 'none'; frame-ancestors 'none'",
    "Referrer-Policy": "strict-origin-when-cross-origin",
    "Permissions-Policy": "camera=(), microphone=(), geolocation=(), payment=()",
}


class SecurityHeadersMiddleware(BaseHTTPMiddleware):
    """Agrega security headers a cada response."""
    
    async def dispatch(self, request: Request, call_next) -> Response:
        response = await call_next(request)
        for header, value in SECURITY_HEADERS.items():
            response.headers[header] = value
        return response

Registrar en main.py:

# app/main.py
from app.middleware.security_headers import SecurityHeadersMiddleware

app = FastAPI(...)
app.add_middleware(SecurityHeadersMiddleware)

Excepción: /docs y /redoc necesitan CSP relajado

Swagger UI carga assets de CDN. Si aplicas default-src 'none' a /docs, no carga.

Solución: detectar la ruta y aplicar CSP distinto:

class SecurityHeadersMiddleware(BaseHTTPMiddleware):
    async def dispatch(self, request: Request, call_next) -> Response:
        response = await call_next(request)
        
        for header, value in SECURITY_HEADERS.items():
            if header == "Content-Security-Policy" and request.url.path in ("/docs", "/redoc", "/openapi.json"):
                # CSP relajado para Swagger/ReDoc UI
                response.headers[header] = (
                    "default-src 'self' https://cdn.jsdelivr.net; "
                    "script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; "
                    "style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; "
                    "img-src 'self' data: https://cdn.jsdelivr.net; "
                    "font-src 'self' https://cdn.jsdelivr.net"
                )
            else:
                response.headers[header] = value
        
        return response

Verificar headers funcionan

curl -I http://localhost:8000/api/v1/posts

HTTP/1.1 200 OK
date: ...
strict-transport-security: max-age=31536000; includeSubDomains
x-content-type-options: nosniff
x-frame-options: DENY
content-security-policy: default-src 'none'; frame-ancestors 'none'
referrer-policy: strict-origin-when-cross-origin
permissions-policy: camera=(), microphone=(), geolocation=(), payment=()
content-type: application/json

Y con tools:

# securityheaders.com (online)
# Cargar tu URL pública

# Mozilla Observatory
# observatory.mozilla.org → analyze

# Localmente con curl
curl -I -s https://api.myapp.com | grep -i 'strict\|frame\|content-security'

Test del middleware

# tests/test_security_headers.py
import pytest


@pytest.mark.asyncio
@pytest.mark.parametrize("header", [
    "strict-transport-security",
    "x-content-type-options",
    "x-frame-options",
    "content-security-policy",
    "referrer-policy",
    "permissions-policy",
])
async def test_security_headers_present(client, header):
    response = await client.get("/api/v1/posts")
    assert header in response.headers


@pytest.mark.asyncio
async def test_x_frame_options_deny(client):
    response = await client.get("/api/v1/posts")
    assert response.headers["x-frame-options"] == "DENY"


@pytest.mark.asyncio
async def test_x_content_type_options_nosniff(client):
    response = await client.get("/api/v1/posts")
    assert response.headers["x-content-type-options"] == "nosniff"


@pytest.mark.asyncio
async def test_hsts_at_least_one_year(client):
    response = await client.get("/api/v1/posts")
    hsts = response.headers["strict-transport-security"]
    # max-age debe ser al menos 1 año (31536000)
    assert "max-age=" in hsts
    max_age = int(hsts.split("max-age=")[1].split(";")[0])
    assert max_age >= 31536000

Outputs esperados

# Antes (sin middleware)
$ curl -I http://localhost:8000/api/v1/posts
HTTP/1.1 200 OK
content-type: application/json
content-length: 2

# Después (con middleware)
$ curl -I http://localhost:8000/api/v1/posts
HTTP/1.1 200 OK
content-type: application/json
strict-transport-security: max-age=31536000; includeSubDomains
x-content-type-options: nosniff
x-frame-options: DENY
content-security-policy: default-src 'none'; frame-ancestors 'none'
referrer-policy: strict-origin-when-cross-origin
permissions-policy: camera=(), microphone=(), geolocation=(), payment=()

Audit con securityheaders.com

Si tu API es pública:

  1. Visita https://securityheaders.com
  2. Pega tu URL
  3. Recibes grade A+ a F

Con los headers que configuramos: A o A+.


Troubleshooting

Problema 1: HSTS no aparece en navegador

HSTS solo es respetado en HTTPS. En desarrollo local (HTTP) el header se manda pero el browser lo ignora.

Problema 2: CSP rompe Swagger UI

Aplicar CSP relajado para /docs y /redoc. Código mostrado arriba.

Problema 3: X-Frame-Options DENY rompe embedding intencional

Si tu API debería ser embebible (raro), cambia a SAMEORIGIN o usa Content-Security-Policy: frame-ancestors 'self'.

Problema 4: Headers no se aplican

Verifica que el middleware está registrado ANTES de las rutas:

app = FastAPI(...)
app.add_middleware(SecurityHeadersMiddleware)  # ← ANTES de include_router
app.include_router(...)

Problema 5: Necesito CSP que permite cierto dominio

Construir el header dinámicamente:

ALLOWED_CDN = "https://cdn.myapp.com"

csp = f"default-src 'self'; script-src 'self' {ALLOWED_CDN}"

Ejercicios

Ejercicio 1: Implementa el middleware

Crea app/middleware/security_headers.py y registra en main.py. Verifica con curl que los headers aparecen.

Ver solución

Código en la cápsula. Verificar:

curl -I http://localhost:8000/api/v1/posts | grep -i 'strict\|frame\|content-security'

Ejercicio 2: Tests de cada header

Implementa los tests parametrize de la cápsula. Confirma que todos pasan.

Ver solución

Código en la cápsula.

Ejercicio 3: CSP relajado para /docs

Implementa la lógica para aplicar CSP distinto en /docs. Verifica que /docs sigue funcionando con el browser.

Ver solución

Código en la cápsula. Visitar /docs en browser → debería renderizar correctamente. Sin la excepción, /docs aparece roto (sin estilos).

Ejercicio 4: Audit con tool externa

Si tu API tiene URL pública (ngrok, Render, Railway), corre:

  • securityheaders.com
  • observatory.mozilla.org

Reporta el grade obtenido.

Ver solución

Con los headers configurados, esperado: A+ en securityheaders.com, A+ en Mozilla Observatory.

Si recibes grado más bajo, los reportes te dicen qué falta.

Ejercicio 5: Detecta el bug

class BadMiddleware(BaseHTTPMiddleware):
    async def dispatch(self, request, call_next):
        response = await call_next(request)
        response.headers["X-Frame-Options"] = "ALLOW-ALL"
        return response

¿Qué problema tiene?

Ver solución

ALLOW-ALL no es un valor válido de X-Frame-Options. Browsers lo ignoran. Como resultado, NO hay protección contra clickjacking.

Valores válidos: DENY, SAMEORIGIN. Para "permitir todo", simplemente no setear el header (o usar CSP frame-ancestors *).


Resumen

  • Security headers son defensa de capa browser. El browser los respeta y previene ataques comunes.
  • HSTS previene downgrade attacks. Solo activo en HTTPS. max-age=31536000 (1 año).
  • X-Content-Type-Options: nosniff previene MIME sniffing.
  • X-Frame-Options: DENY previene clickjacking.
  • CSP controla qué recursos cargar. Para APIs JSON: default-src 'none'.
  • Referrer-Policy controla qué info de referer se filtra.
  • Permissions-Policy restringe APIs del browser.
  • Middleware FastAPI aplica headers a TODAS las responses.
  • /docs y /redoc necesitan CSP relajado para cargar Swagger UI.
  • Auditar con securityheaders.com y Mozilla Observatory.

Recursos adicionales

  1. OWASP — Secure Headers Project — Lista exhaustiva
  2. MDN — HTTP Security Headers — Doc oficial
  3. securityheaders.com — Tool de audit
  4. Mozilla Observatory — Audit comprehensivo
  5. HSTS Preload — Submit tu dominio para preload
  6. CSP Generator — Helper para construir CSP

Siguiente paso

Cápsula 03: API key authentication. Para integraciones service-to-service que NO usan OAuth/JWT. Vas a implementar API keys con hash, scopes, y validación.