Saltar al contenido
🐍 Curso de Python
/

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.

⏱ 18 min de lectura

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#

Salida en pantalla
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.XFrameOptionsMiddleware

Tres 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:

Salida en pantalla
X-Frame-Options: DENY
X-Content-Type-Options: nosniff
Referrer-Policy: same-origin
Cross-Origin-Opener-Policy: same-origin

Cuatro 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#

python
c = Client(enforce_csrf_checks=True)
c.post('/contactos/contacta/', {...})
Salida en pantalla
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:

python
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:

Salida en pantalla
{{ x }}       ->  &lt;script&gt;alert(&quot;robado&quot;)&lt;/script&gt;
{{ 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:

html
<a href={{ x }}>
Salida en pantalla
<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:

html
<a href="{{ x }}">enlace</a>
Salida en pantalla
<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:

Salida en pantalla
<script>var u = "&lt;/script&gt;&lt;script&gt;alert(1)&lt;/script&gt;";</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:

html
{{ datos|json_script:"datos" }}
Salida en pantalla
<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#

python
Contacto.objects.filter(nombre="x' OR '1'='1")
Salida en pantalla
filas: 0 de 17

El 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:

Salida en pantalla
parametrizado:  0 filas
concatenado:   17 filas   <-- todas

Diecisiete 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í:

python
cursor.execute('select ... where nombre = %s', [valor])     # bien
cursor.execute(f'select ... where nombre = {valor!r}')      # mal

Ese %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:

python
from django.utils.http import url_has_allowed_host_and_scheme
Salida en pantalla
'/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: False

Las 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:

AjustePor defectoProducción
SECURE_SSL_REDIRECTFalseTrue
SESSION_COOKIE_SECUREFalseTrue
CSRF_COOKIE_SECUREFalseTrue
SECURE_HSTS_SECONDS031536000
SECURE_HSTS_INCLUDE_SUBDOMAINSFalseTrue
Salida en pantalla
http -> 301 https://testserver/contactos/
Strict-Transport-Security: max-age=31536000; includeSubDomains; preload

Las 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#

Salida en pantalla
404 con DEBUG=True:   2972 bytes, con la lista de rutas registradas
404 con DEBUG=False:    179 bytes

Y 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:

Salida en pantalla
patrón: API|AUTH|TOKEN|KEY|SECRET|PASS|SIGNATURE|HTTP_COOKIE
Salida en pantalla
SECRET_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:

python
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.