Módulo 4 · Lección 42 de 45
Seguridad
Lo que Django protege solo, los tres huecos que deja el escape automático, y el ajuste con nombre en español que aparece en claro en la página de error.
Django trae bastante seguridad de fábrica, y eso tiene una cara mala: es fácil confiarse sin saber qué está protegido y qué no. Esta lección recorre las protecciones una a una, midiendo qué hacen, y después los sitios donde hay que poner algo de tu parte.
Y recoge las dos deudas que quedaron apuntadas: la cookie de sesión de la lección 35 y la dirección del panel de la lección 30.
Lo que ya está puesto#
1. django.middleware.security.SecurityMiddleware <-- seguridad
2. django.contrib.sessions.middleware.SessionMiddleware
3. django.middleware.common.CommonMiddleware
4. django.middleware.csrf.CsrfViewMiddleware <-- seguridad
5. django.contrib.auth.middleware.AuthenticationMiddleware
6. django.contrib.messages.middleware.MessageMiddleware
7. django.middleware.clickjacking.XFrameOptionsMiddlewareTres de los siete son de seguridad, y vienen activados. Quitar uno para que algo "funcione" es casi siempre el error, y es la causa de la mitad de los agujeros que se ven por ahí.
Y las cabeceras que salen sin configurar nada:
X-Frame-Options: DENY
X-Content-Type-Options: nosniff
Referrer-Policy: same-origin
Cross-Origin-Opener-Policy: same-originCuatro cabeceras de protección, por defecto. Esto es nuevo y conviene saberlo, porque los tutoriales de hace unos años te dicen que las actives a mano: en Django 6 ya están, y X-Frame-Options vale DENY y no SAMEORIGIN, que es más estricto.
Lo que hacen: nadie puede meter tu sitio en un marco (que es como se roban las pulsaciones), el navegador no adivina el tipo de los ficheros, no se filtra a otros sitios de dónde vienen tus visitas, y las pestañas que abras no pueden manipular la tuya.
CSRF#
c = Client(enforce_csrf_checks=True)
c.post('/contactos/contacta/', {...})403 Forbidden (CSRF cookie not set.)Eso es lo que impide que otra página ponga un formulario que envíe datos a la tuya aprovechando que estás identificado. La protección es el {% csrf_token %} que lleva cada formulario desde la lección 25, y el 403 que sale sin él es lo que a veces se intenta "arreglar" con un @csrf_exempt. Ese decorador quita la protección de esa vista entera.
Cuándo hace falta tocar algo de verdad:
Con peticiones desde JavaScript, que tienen que mandar la cabecera X-CSRFToken. La documentación trae la función para leer la cookie.
Detrás de un proxy o con varios dominios, con CSRF_TRUSTED_ORIGINS, que viene vacío y donde van los orígenes con su esquema:
CSRF_TRUSTED_ORIGINS = ['https://ejemplo.es', 'https://www.ejemplo.es']XSS: lo que el escape hace y lo que no#
La lección 17 ya midió lo principal:
{{ x }} -> <script>alert("robado")</script>
{{ x|safe }} -> <script>alert("robado")</script>El escape automático cubre el caso normal, que es texto dentro de HTML. Lo que hay que saber son los tres sitios donde no basta, porque ahí el escape es correcto y aun así hay un agujero.
Un atributo sin comillas:
<a href={{ x }}><a href=x onmouseover=alert(1)>Ahí no hay ningún carácter que escapar: el valor es texto normal. Pero como el atributo no lleva comillas, el espacio acaba el atributo y empieza otro. Arreglo: comillas siempre, sin excepción.
Una dirección que viene de fuera:
<a href="{{ x }}">enlace</a><a href="javascript:alert(1)">enlace</a>Lo mismo: javascript:alert(1) no tiene nada que escapar. El escape protege el HTML, no el significado de una URL. Arreglo: comprobar el esquema antes de pintar un enlace que venga de un usuario.
Datos dentro de una etiqueta de JavaScript. Aquí el escape sí actúa, y produce algo que no es un agujero pero está roto:
<script>var u = "</script><script>alert(1)</script>";</script>Ha bloqueado el ataque, pero el valor en JavaScript ya no es el que era, porque dentro de una etiqueta de guion el navegador no descodifica las entidades. Para esto hay una herramienta hecha:
{{ datos|json_script:"datos" }}<script id="datos" type="application/json">"\u003C/script\u003E\u003Cscript\u003Ealert(1)\u003C/script\u003E"</script>Escapa con secuencias de JavaScript, que es lo correcto en ese contexto, y se lee con JSON.parse(document.getElementById('datos').textContent).
Inyección de SQL#
Contacto.objects.filter(nombre="x' OR '1'='1")filas: 0 de 17El ORM manda el SQL y los datos por separado, así que no hay nada que inyectar. Lo mismo con un cursor, si se hace bien:
parametrizado: 0 filas
concatenado: 17 filas <-- todasDiecisiete de diecisiete. Esa es la diferencia entre pasar los valores como segundo argumento y meterlos en la cadena. Y es la única regla que hay que recordar aquí:
cursor.execute('select ... where nombre = %s', [valor]) # bien
cursor.execute(f'select ... where nombre = {valor!r}') # malEse %s no es el formato de Python: es un hueco que rellena el conector. No se pone entre comillas.
Y una advertencia sobre depurar: str(consulta.query) enseña los valores metidos en el SQL, y eso no es lo que se manda. Es una representación para mirar. Ver ahí el valor sin escapar no significa que haya un problema.
Redirección abierta#
Una vista que redirige a donde diga un parámetro se puede usar para llevar gente a otro sitio desde una dirección que parece tuya. Es el ?next= de la lección 32, y Django trae la comprobación:
from django.utils.http import url_has_allowed_host_and_scheme'/contactos/' permitido: True
'http://ejemplo.es/x' permitido: True
'https://malo.es/' permitido: False
'//malo.es/' permitido: False
'javascript:alert(1)' permitido: False
'/\\malo.es' permitido: FalseLas tres últimas son las que uno no se le ocurren solo, y por eso esto no se escribe a mano: //malo.es es una dirección absoluta sin esquema, y la barra con contrabarra la interpretan algunos navegadores como dos barras.
Los ajustes de producción#
Todo esto va en produccion.py, que es la lección 40:
| Ajuste | Por defecto | Producción |
|---|---|---|
SECURE_SSL_REDIRECT | False | True |
SESSION_COOKIE_SECURE | False | True |
CSRF_COOKIE_SECURE | False | True |
SECURE_HSTS_SECONDS | 0 | 31536000 |
SECURE_HSTS_INCLUDE_SUBDOMAINS | False | True |
http -> 301 https://testserver/contactos/
Strict-Transport-Security: max-age=31536000; includeSubDomains; preloadLas dos cookies seguras son la deuda de la lección 35, y hacen que la sesión y el testigo CSRF no viajen nunca por HTTP. Sin eso, basta con estar en la misma red que el usuario para quedarse con su sesión, y eso pasa incluso en un sitio que redirige a HTTPS, porque la primera petición va sin cifrar.
Las que ya vienen bien y no hay que tocar: SESSION_COOKIE_HTTPONLY está en True (JavaScript no puede leer la sesión) y las dos cookies son SameSite=Lax, que es la defensa moderna contra el CSRF.
Lo que enseña la página de error#
404 con DEBUG=True: 2972 bytes, con la lista de rutas registradas
404 con DEBUG=False: 179 bytesY la de error quinientos enseña mucho más: el código fuente alrededor del fallo, las variables locales de cada marco de la pila, la petición entera con sus cookies, y todos los ajustes.
Django tapa los ajustes que parecen secretos, y merece la pena mirar cómo decide:
patrón: API|AUTH|TOKEN|KEY|SECRET|PASS|SIGNATURE|HTTP_COOKIESECRET_KEY -> '********************'
API_TOKEN -> '********************'
DATABASE_PASSWORD -> '********************'
AWS_ACCESS_KEY_ID -> '********************'
MI_SECRETO_RARO -> '********************'
CLAVE_BD -> 'x'
SMTP_HOST -> 'h'Mira la penúltima. CLAVE_BD sale en claro, porque el patrón está en inglés y "clave" no aparece en él. MI_SECRETO_RARO sí se tapa, pero por casualidad: contiene "SECRET".
O sea que en un proyecto en español, la costumbre razonable de nombrar las cosas en tu idioma desactiva sin avisar el tapado de secretos de Django. Y no se ve nunca, porque solo aparece en una página de error con DEBUG=True.
Hay dos salidas: nombrar en inglés los ajustes que sean secretos, o cambiar el filtro:
DEFAULT_EXCEPTION_REPORTER_FILTER = 'cfg.filtros.FiltroEnEspanol'Aunque lo primero es que DEBUG sea False en producción. Y para las vistas que manejan contraseñas, @sensitive_variables y @sensitive_post_parameters, que tapan también en los correos de error.