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_KEYoriginal 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:
SECRET_KEYsin default. Si la env var no existe, Django explota al arrancar. Eso es lo que queremos: nunca correr con una clave conocida.DEBUGpor defecto aFalse. El default seguro siempre debe ser el restrictivo.CORS_ALLOW_CREDENTIALS = Falseexplí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 sí 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"),
},
}
AllowAnyexplí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) yhealth(sondas + monitoring). Cada viewset declara suthrottle_scopeyScopedRateThrottleaplica el límite específico. CappedLimitOffsetPaginationconmax_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.