Backend Latam Radio #7

Publicado el 30/05/2026

Hardening I: secrets, env vars y cierre de la API

Después de la auditoría tocaba arreglar. Lo hice en nueve fases temáticas, una sesión por fase. Las primeras dos las cuento juntas porque atacan la superficie más expuesta: configuración (secrets fuera del repo) y API (cerrar lo que no debía estar abierto).

El antes: settings.py versión vergüenza

SECRET_KEY = "django-insecure-<clave-real-hardcodeada-aquí>"
DEBUG = True
ALLOWED_HOSTS = ['*']

CORS_ALLOWED_ORIGINS = [
    "http://localhost:8000",
    "http://127.0.0.1:8080",
    # ...
]

Tres líneas, tres bombas: hardcodeo de la clave, debug global, host permisivo. Y CORS pegado al archivo como si la app móvil viviera siempre en localhost.

No reproduzco aquí el literal de la SECRET_KEY original aunque ya fue rotada. Publicar la cadena exacta en el blog la deja indexada para siempre y normaliza la idea de "es solo de desarrollo, no pasa nada". Pasa: el siguiente que copie el patrón no le ve la diferencia.

El después: django-environ

# latamradio/settings.py
import environ

env = environ.Env(
    DJANGO_DEBUG=(bool, False),
    DJANGO_ALLOWED_HOSTS=(list, []),
    DJANGO_CORS_ALLOWED_ORIGINS=(list, []),
    DJANGO_SECURE_SSL_REDIRECT=(bool, False),
    DJANGO_SECURE_HSTS_SECONDS=(int, 0),
    DJANGO_BEHIND_PROXY=(bool, False),
)
environ.Env.read_env(BASE_DIR / ".env")

SECRET_KEY = env("DJANGO_SECRET_KEY")
DEBUG = env("DJANGO_DEBUG")
ALLOWED_HOSTS = env("DJANGO_ALLOWED_HOSTS")
CORS_ALLOWED_ORIGINS = env("DJANGO_CORS_ALLOWED_ORIGINS")
CORS_ALLOW_CREDENTIALS = False

Tres cambios sutiles pero clave:

  1. SECRET_KEY sin default. Si la env var no existe, Django explota al arrancar. Eso es lo que queremos: nunca correr con una clave conocida.
  2. DEBUG por defecto a False. El default seguro siempre debe ser el restrictivo.
  3. CORS_ALLOW_CREDENTIALS = False explícito. Aunque sea el default, dejarlo claro evita que alguien lo cambie sin pensar.

.env.example versionable

# .env.example  (extracto — el archivo real incluye comentarios explicativos
# de cada variable; aquí dejo solo las claves y valores ejemplo).
DJANGO_SECRET_KEY='replace-with-a-long-random-string'
DJANGO_DEBUG=False
DJANGO_ALLOWED_HOSTS=latamradio.rexvalentia.es
DJANGO_CORS_ALLOWED_ORIGINS=https://latamradio.rexvalentia.es
DJANGO_CSRF_TRUSTED_ORIGINS=https://latamradio.rexvalentia.es

DJANGO_THROTTLE_ANON=120/min
DJANGO_THROTTLE_BY_UUID=30/min
DJANGO_THROTTLE_AUTOCOMPLETE=60/min
DJANGO_THROTTLE_HEALTH=60/min
DJANGO_API_CACHE_TTL=3600

DJANGO_REDIS_URL=redis://redis:6379/1

# Base de datos. En producción se descomenta el bloque PostGIS.
# DATABASE_ENGINE=django.contrib.gis.db.backends.postgis
# DATABASE_NAME=latamradio
# DATABASE_USER=latamradio
# DATABASE_PASSWORD=changeme
# DATABASE_HOST=postgres-host.dokploy.internal
# DATABASE_PORT=5432

DJANGO_SECURE_SSL_REDIRECT=True
DJANGO_SECURE_HSTS_SECONDS=31536000
DJANGO_BEHIND_PROXY=True

Este es el archivo que entra en git. Documenta qué variables existen y los valores de producción del proyecto. El .env real con la clave aleatoria nunca se commitea (.gitignore lo cubre).

Un detalle al que llegué la mala manera: las comillas simples en DJANGO_SECRET_KEY son obligatorias. django-environ 0.11.x trata el # como inicio de comentario y trunca silenciosamente el valor; si tu clave generada incluye # en medio, sin comillas te quedas con una cadena más corta y Django no te avisa. Las comillas dobles tampoco bastan en esa versión del parser. El .env.example deja la nota grabada al lado de la variable para que el siguiente que clone el repo no caiga.

Hardening opt-in para producción

if not DEBUG:
    SECURE_SSL_REDIRECT = env("DJANGO_SECURE_SSL_REDIRECT")
    SECURE_HSTS_SECONDS = env("DJANGO_SECURE_HSTS_SECONDS")
    SECURE_HSTS_INCLUDE_SUBDOMAINS = SECURE_HSTS_SECONDS > 0
    SECURE_HSTS_PRELOAD = SECURE_HSTS_SECONDS > 0
    SECURE_CONTENT_TYPE_NOSNIFF = True
    SECURE_REFERRER_POLICY = "same-origin"
    SESSION_COOKIE_SECURE = True
    CSRF_COOKIE_SECURE = True
    X_FRAME_OPTIONS = "DENY"
    if env("DJANGO_BEHIND_PROXY"):
        SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")

El bloque entero se aplica solo si DEBUG=False. En local no se activa SSL redirect (que rompería el localhost:8000). En producción, sí.

SECURE_PROXY_SSL_HEADER es la línea que me hizo perder más tiempo: si la app está tras nginx que termina TLS, Django ve la request entrante como HTTP y entra en loop si SSL_REDIRECT=True. La env var DJANGO_BEHIND_PROXY me deja activarlo solo donde aplique.

Cerrar la API: ReadOnlyModelViewSet

El cambio en station_viewset.py:

- class StationViewSet(viewsets.ModelViewSet):
+ class StationViewSet(viewsets.ReadOnlyModelViewSet):
      queryset = (
          Station.objects.filter(lastcheckok=True)
          .prefetch_related("tags", "languages", "language_codes")
      )
-     http_method_names = ['get', 'post']

ReadOnlyModelViewSet solo expone list y retrieve. El POST a la raíz del recurso queda con 405. El action by-uuid sigue funcionando porque las @action no dependen de los métodos del viewset, solo de su decorador.

Throttling y paginación con techo

REST_FRAMEWORK = {
    "DEFAULT_PERMISSION_CLASSES": ["rest_framework.permissions.AllowAny"],
    "DEFAULT_AUTHENTICATION_CLASSES": [],
    "DEFAULT_PAGINATION_CLASS": "api.pagination.CappedLimitOffsetPagination",
    "PAGE_SIZE": 20,
    "DEFAULT_FILTER_BACKENDS": ["django_filters.rest_framework.DjangoFilterBackend"],
    "DEFAULT_THROTTLE_CLASSES": [
        "rest_framework.throttling.AnonRateThrottle",
        "rest_framework.throttling.ScopedRateThrottle",
    ],
    "DEFAULT_THROTTLE_RATES": {
        "anon": env("DJANGO_THROTTLE_ANON", default="120/min"),
        "by_uuid": env("DJANGO_THROTTLE_BY_UUID", default="30/min"),
        "autocomplete": env("DJANGO_THROTTLE_AUTOCOMPLETE", default="60/min"),
        "health": env("DJANGO_THROTTLE_HEALTH", default="60/min"),
    },
}
  • AllowAny explícito en lugar del implícito. Auditable.
  • DEFAULT_AUTHENTICATION_CLASSES = []: ningún middleware de auth, ningún coste por request.
  • Throttling por IP anónima a 120 req/min para todo lo "barato". Configurable vía env var.
  • Throttles dedicados para endpoints que amplifican carga o son sondeables: by_uuid (POST con lista), autocomplete (cheap pero scaneable) y health (sondas + monitoring). Cada viewset declara su throttle_scope y ScopedRateThrottle aplica el límite específico.
  • CappedLimitOffsetPagination con max_limit=100.

Tener cuatro buckets en vez de uno permite afinar: si mañana detecto que alguien está enumerando UUIDs vía by-uuid, bajo DJANGO_THROTTLE_BY_UUID a 5/min sin tocar el resto.

Bloque auth: eliminado

Las URLs de /users/auth/token/, password reset, etc., estaban registradas en api/urls.py. Ninguna se usaba (la API es pública). Las borré, y también django_rest_passwordreset y djangorestframework-simplejwt de INSTALLED_APPS y requirements.txt.

Menos código = menos superficie. La regla de oro.

# api/urls.py — versión final, una sola línea
from django.urls import include, path

from api.router import router_v1

urlpatterns = [
    path("", include(router_v1.urls)),
]

CORS: lista o comodín, según uso

El primer impulso es dejar DJANGO_CORS_ALLOWED_ORIGINS como CSV cerrado (https://app.tudominio.com). Funciona perfecto cuando el único cliente es tu propia app. Cuando hay terceros que quieren consumir el catálogo de radios desde sus propios frontends, el CSV se queda corto.

settings.py acepta los dos modos:

_raw_cors = env("DJANGO_CORS_ALLOWED_ORIGINS", default="")
if _raw_cors.strip() == "*":
    CORS_ALLOW_ALL_ORIGINS = True
    CORS_ALLOWED_ORIGINS = []
else:
    CORS_ALLOW_ALL_ORIGINS = False
    CORS_ALLOWED_ORIGINS = [o.strip() for o in _raw_cors.split(",") if o.strip()]
CORS_ALLOW_CREDENTIALS = False

Con DJANGO_CORS_ALLOWED_ORIGINS=* la API contesta con Access-Control-Allow-Origin: *. La línea clave para que esto no sea un problema es CORS_ALLOW_CREDENTIALS = False: sin credenciales en CORS, el comodín no permite a un origen malicioso leer datos autenticados (no hay sesión ni JWT que robar) — solo el catálogo público que ya es público.

Resultado

Después de esta fase, la API es:

  • Pública y de solo lectura.
  • Limitada en velocidad por scope (anon, by_uuid, autocomplete, health).
  • Limitada en payload (100 estaciones por request).
  • Configurada por entorno, sin nada hardcodeado.
  • Endurecida automáticamente cuando DEBUG=False.

Falta lo más interesante: cuando ataqué la integridad del dominio. Próximo post: cómo arreglar modelos sin perder los datos que tenía.