Quand on déploie sa première application Django en production, il y a toujours ce moment un peu inconfortable : laisser /admin exposé sur Internet. Même avec un mot de passe solide, même avec un compte superuser bien protégé, une interface d'administration accessible publiquement reste une surface d'attaque évidente.

Je suis étudiant en informatique et je monte progressivement mon propre homelab. Ce type de projet me passionne, autant côté hardware que software. Très vite, en exposant mes premiers services, je me suis rendu compte qu'un simple login/mot de passe, aussi robuste soit-il, ne suffisait pas à me rassurer. Une page d'administration ne devrait pas être visible du monde entier par défaut. C'est de cette réflexion qu'est née l'architecture que je présente ici.

Les bonnes pratiques s'arrêtent au niveau applicatif

Par défaut, Django expose l'interface d'administration sur /admin. Les recommandations classiques tiennent en cinq lignes :

  • changer l'URL
  • imposer un mot de passe fort
  • activer la double authentification
  • limiter les IP autorisées
  • désactiver les fonctionnalités inutiles Elles sont justes, et je ne les jette pas. Mais elles partagent toutes le même angle mort : elles agissent une fois la requête arrivée. L'interface, elle, reste joignable. Un scanner peut la trouver, reconnaître un Django à ses cookies et à son formulaire de login, mesurer les temps de réponse, tenter du bruteforce. Pendant ce temps, il y a un bot quelque part qui teste admin/admin sur votre formulaire avec la patience d'un moine copiste, et il ne se décourageras jamais.

La première fois que j'ai eu la bonne idée de ma dire, qu'est ce qu'il se passe réellement sur mon log nginx (en front du django), petit arrêt cardique...Des dizaines de requêtes par minutes sur le /admin en plus de toutes les autres tentatives farfelues pour énumérer toutes les routes pontentiellement craignos du site.

La question que je me suis posée n'est donc pas « comment mieux verrouiller cette page », mais « pourquoi cette page répond-elle à quelqu'un qui n'est pas moi ».

Déplacer le problème vers le réseau

L'architecture repose sur trois briques :

  1. séparer le site public et l'admin sur deux sous-domaines distincts
  2. rendre l'admin joignable uniquement depuis le réseau privé, donc via VPN
  3. utiliser django-hosts pour router proprement les requêtes selon l'hôte L'objectif est simple : site.fr est accessible publiquement, admin.site.fr ne répond que sur une IP privée. Sans tunnel actif, il n'y a personne au bout du fil.

Deux enregistrements DNS, deux visibilités

Côté zone DNS, on ajoute deux enregistrements :

  • site.fr vers l'IP publique du serveur
  • admin.site.fr vers l'IP privée, par exemple 10.0.0.5 Concrètement, pour atteindre admin.site.fr, il faut être dans le réseau privé : WireGuard, OpenVPN, ou n'importe quel tunnel qui vous place dedans. Même en connaissant l'URL exacte, un attaquant sur Internet n'a aucune route vers 10.0.0.5.

J'utilise moi même wireguard pour cet usage en particulier, une fois bien configurable, il est leger et m'a souvent permis de me connecter sans aucune latence à mon admin dans le bus en revenant du taf.

Trois limites à poser honnêtement, parce qu'aucune n'est bloquante mais que les ignorer donne une fausse impression d'invisibilité.

L'IP privée est publiée en clair. N'importe qui peut interroger la zone et lire 10.0.0.5. Ce n'est pas idéal, mais cette adresse n'est pas routable depuis Internet : ça donne du renseignement sur la topologie interne, pas une connexion.

Certains résolveurs refuseront de vous répondre. La protection anti-rebinding DNS consiste précisément à filtrer les adresses privées renvoyées par une zone publique. La documentation de pfSense est explicite sur ce point :

Lorsque la protection contre le DNS rebinding est active, le résolveur retire les adresses privées des réponses DNS, et le validateur DNSSEC peut en plus marquer ces réponses comme invalides.

Documentation pfSense, DNS Rebinding Protections

Ce comportement existe aussi dans dnsmasq (--stop-dns-rebind) et dans unbound (private-address). Traduction pratique : selon le réseau depuis lequel vous vous connectez, admin.site.fr peut simplement ne pas résoudre, et vous allez perdre un temps fou à chercher la panne du mauvais côté. La parade propre consiste à faire résoudre ce nom par un résolveur interne, poussé par le VPN, plutôt que de compter sur la zone publique.

Le nom du sous-domaine finiras public de toute façon. Dès que vous obtenez un certificat pour admin.site.fr, il part dans les journaux de Certificate Transparency, que Let's Encrypt alimente pour tous les certificats qu'il émet . C'est important à intégrer : cette architecture n'est pas de la dissimulation, c'est du cloisonnement réseau. Le nom est connu, la route n'existe pas.

Router les hôtes avec django-hosts

Reste à ce que Django serve deux choses différentes selon l'hôte demandé. C'est le rôle de django-hosts, qui associe des sous-domaines à des schémas d'URL via des modules appelés hostconfs.

La mise en place tient en quatre points : ajouter HostsRequestMiddleware en tête de MIDDLEWARE et HostsResponseMiddleware en fin, créer un module hosts.py à côté de urls.py, pointer ROOT_HOSTCONF dessus, et définir DEFAULT_HOST.

Exemple de configuration hosts.py :

from django.conf import settings
from django_hosts import host, patterns

host_patterns = patterns(
    "",
    host(r"admin", "projet.urls_admin", name="admin"),
    host(r"www", settings.ROOT_URLCONF, name="www"),
)

Les motifs de gauche sont des expressions régulières évaluées dans l'ordre, ce qui compte dès qu'on introduit un pattern générique : un (\w+) placé avant www capturerait tout et enverrait le site public au mauvais URLconf.

Et surtout, il y a cette phrase dans la documentation, qui change la façon de lire tout le reste :

Les motifs sont évalués dans l'ordre. Si aucun ne correspond, la requête est traitée normalement, c'est-à-dire avec le ROOT_URLCONF habituel.

Documentation django-hosts

django-hosts route, il n'interdit rien. Si path("admin/", admin.site.urls) reste dans votre urls.py principal, alors site.fr/admin continue de répondre publiquement, VPN ou pas, et toute l'architecture ne sert plus à rien. La séparation n'a de valeur que si l'admin sort réellement du ROOT_URLCONF.

D'où le fichier urls_admin.py dédié :

from django.contrib import admin
from django.urls import path

urlpatterns = [
    path("", admin.site.urls),
]

Pensez aussi à ajouter admin.site.fr dans ALLOWED_HOSTS, sinon Django rejetteras la requête bien avant que django-hosts ait son mot à dire (oui, j'ai cherché un moment la première fois).

Les bénéfices ne sont pas seulement cosmétiques :

  • l'admin n'est plus mélangé aux routes publiques
  • on peut lui appliquer des middlewares spécifiques sans toucher au reste
  • on peut durcir sa configuration indépendamment du site principal

Un certificat pour un nom qui ne mène nulle part

Même sur un réseau privé, il faut du HTTPS. Passer en clair sous prétexte qu'on est « chez soi » revient à faire confiance à tout ce qui traîne sur le LAN, ce qui est une hypothèse optimiste.

Le problème, c'est que la validation habituelle ne peut pas fonctionner ici. Let's Encrypt le dit sans détour :

Le challenge HTTP-01 ne peut se faire que sur le port 80.

Let's Encrypt, Challenge Types

Or personne, à l'extérieur, ne peut atteindre le port 80 de 10.0.0.5. Il reste le challenge DNS-01 : on prouve qu'on contrôle le domaine en publiant un enregistrement TXT sous _acme-challenge.admin.site.fr, et l'autorité de certification n'a jamais besoin de joindre le serveur. C'est exactement le cas d'usage.

Le résultat est un certificat parfaitement valide sur un service que l'Internet ne voit pas : HTTPS actif, aucun avertissement navigateur, et une admin utilisable depuis un Wi-Fi public tant que le tunnel est monté.

La contrepartie mérite d'être dite : DNS-01 suppose de confier à votre client ACME des identifiants d'API sur la zone DNS. C'est un secret autrement plus sensible qu'un certificat, et il vaut mieux le limiter à la zone concernée quand le registrar le permet.

Empiler, mais pas n'importe quoi

Cette architecture est une base, pas un aboutissement. Elle se complète naturellement :

  • 2FA sur les comptes admin
  • restriction par IP interne
  • reverse proxy avec une authentification supplémentaire, voire du mTLS
  • journalisation des tentatives d'accès En revanche, mettre fail2ban devant une interface que seul le VPN peut atteindre relève surtout du réflexe : il ne bannira jamais que des machines déjà authentifiées sur le tunnel. Le point de contrôle qui compte s'est déplacé avec l'interface. Ce sont les accès VPN qu'il faut surveiller, pas les 404 de l'admin.

C'est d'ailleurs ce qui me plaît dans cette approche : on ne modifie pas Django en long, en large et en travers, on ne développe pas de solution exotique. On exploite la séparation DNS, le cloisonnement réseau et la modularité de django-hosts. Le problème descend du niveau applicatif vers le niveau réseau, où il est généralement plus robuste.

Ce que cette architecture a vraiment changé chez moi, ce n'est pas le nombre de couches de protection : c'est l'ordre des questions. Avant de me demander comment protéger un service que je déploie, je me demande maintenant qui a besoin de l'atteindre. La réponse est souvent « moi, et personne d'autre », et elle rend la moitié des mesures de durcissement inutiles avant même de les écrire. Parce que la meilleure surface d'attaque reste celle qui n'est pas exposée.