Exposition involontaire : quand un environnement devient public tout seul
Ce guide raconte une trouvaille du 2026-07-26 : les environnements dev et staging
étaient publiquement joignables sur internet, en clair, et branchés sur la base de
données de production. Personne ne l'avait demandé, personne ne l'avait remarqué.
Il est né de deux issues, #146 et #147, et il accompagne l'ADR 029 (multi-environnements) et l'ADR 017 (exposition).
L'idée en une phrase
Trois briques qui font chacune correctement leur travail — un Gateway permissif, une HTTPRoute sans contrainte, un ExternalDNS obéissant — s'enchaînent pour publier sur internet un environnement que personne n'a décidé d'exposer.
1. Ce qu'on a constaté
$ curl -sS -o /dev/null -w '%{http_code}\n' http://dev.api.devopsyouss.com/healthz/ready
200
Un 200. En HTTP, pas HTTPS. Sur un host dev. Depuis n'importe où sur internet.
Et le même test sur la prod donnait aussi 200 en clair, ce qui a élargi le sujet : le
défaut de transport ne concernait pas que les envs de test.
Le symptôme trompeur qui a failli masquer le problème
La première alerte n'était pas celle-là. C'était une erreur de certificat sur
https://dev.api.devopsyouss.com : le wildcard *.devopsyouss.com ne couvre qu'un
seul niveau de label, donc pas dev.api..
On pouvait s'arrêter là et conclure « dev n'est pas accessible, le certificat le
bloque ». Faux. Un certificat invalide n'est pas un contrôle d'accès : le client
décide s'il l'accepte. Et il suffisait d'enlever le s de https pour que tout
passe, sans le moindre avertissement.
2. La chaîne : trois briques qui s'enchaînent
Brique 1 — le Gateway a deux portes, toutes deux grandes ouvertes
# k8s/platform/gateway.yaml
listeners:
- name: http
port: 80
allowedRoutes:
namespaces: { from: All } # accepte les routes de n'importe quel namespace
- name: https
port: 443
allowedRoutes:
namespaces: { from: All }
Le from: All est voulu : le Gateway est partagé, un seul NLB sert les trois envs
plus Grafana et ArgoCD, qui vivent tous dans des namespaces différents. Sans lui, rien
ne fonctionnerait.
Brique 2 — une HTTPRoute sans sectionName s'attache à tout
C'est le point contre-intuitif de Gateway API.
Quand une HTTPRoute déclare son parentRefs sans préciser sectionName, elle ne dit pas
« attache-moi au bon listener ». Elle dit « attache-moi à tous les listeners que ce
Gateway veut bien m'ouvrir ».
flowchart LR
R["HTTPRoute<br/>parentRefs: fastapi-gateway<br/><b>sans sectionName</b>"]
L80[":80 http"]
L443[":443 https"]
R ==>|"attachement<br/>implicite"| L80
R ==>|"attachement<br/>implicite"| L443
L80 --> C["Trafic en clair<br/>identifiants lisibles"]
L443 --> T["Trafic chiffré"]
Aucune des 4 routes du dépôt (api, app, grafana, argocd) ne portait de
sectionName. Les quatre servaient donc en clair autant qu'en TLS.
Brique 3 — ExternalDNS publie ce qu'il voit
ExternalDNS surveille les HTTPRoutes (source gateway-httproute) et crée dans Cloudflare
un enregistrement par hostname rencontré. C'est de l'automatisation pure, et elle est
excellente : c'est elle qui évite de créer des CNAME à la main à chaque aws-start.
Mais elle n'a aucune notion d'« environnement pas encore prêt ». Elle voit un hostname dans un manifeste, elle le publie.
Le résultat, bout à bout
flowchart TB
G["Commit sur develop<br/>(overlay dev avec un hostname)"]
A["ArgoCD synchronise<br/>l'Application fastapi-dev"]
H["HTTPRoute créée<br/>dans le namespace fastapi-dev"]
L["Attachement AUX DEUX listeners<br/>(pas de sectionName)"]
D["ExternalDNS publie<br/>dev.api.devopsyouss.com"]
P["<b>Endpoint public, en clair</b>"]
G --> A --> H --> L --> D --> P
Personne n'a exécuté d'action d'exposition. La seule décision humaine est le commit du tout début. Le reste est de l'automatisation qui fonctionne parfaitement.
3. Pourquoi c'était grave ici
Un env de dev public, en soi, c'est discutable mais pas dramatique. Ce qui rend la situation sérieuse, c'est ce qu'il y avait derrière.
Les overlays dev et staging ne surchargent pas l'ExternalSecret. Le base tire la clé
fastapi-eks/app depuis AWS Secrets Manager — la même pour les trois environnements.
Les trois envs partagent donc le même DATABASE_URL.
flowchart TB
subgraph PUB["Internet"]
U["N'importe qui"]
end
subgraph K8S["Cluster"]
DEV["fastapi-dev<br/>image bumpée à CHAQUE merge<br/>sur develop"]
PROD["fastapi-prod<br/>image promue par MR"]
end
RDS[("RDS<br/><b>même database</b>")]
U -->|"http:// en clair"| DEV
U -->|"https://"| PROD
DEV --> RDS
PROD --> RDS
Deux risques, et le plus probable n'est pas celui qu'on croit.
| Risque | Qui le déclenche | Probabilité |
|---|---|---|
| Intégrité : du code non validé écrit dans les données de prod | toi, à chaque merge sur develop |
élevée |
| Confidentialité : identifiants et JWT lisibles sur le réseau | un observateur du trafic | faible mais réelle |
Le tag de dev est bumpé automatiquement par le write-back à chaque merge sur
develop. Autrement dit, du code n'ayant passé ni staging ni E2E servait publiquement,
sur le jeu de données de production. Le risque d'intégrité précède le risque d'intrusion.
Le volet transport (#147) : pourquoi c'est bien une faille
POST /login accepte identifiants et mot de passe et renvoie un JWT
(app/routers/auth.py). La CLI argocd transporte un token de session. Sur
http://, les deux circulent en clair et sont lisibles par tout observateur du
réseau. C'est CWE-319,
Cleartext Transmission of Sensitive Information.
Le TLS existait et fonctionnait. Rien n'obligeait simplement à l'emprunter.
4. La vraie leçon : un garde-fou qui n'en était pas un
La consigne existait. Elle était écrite noir sur blanc dans les notes de pilotage : « ne pas remonter le multi-env en live avant la fin du socle ».
Elle n'a servi à rien, parce qu'elle n'était écrite nulle part où elle s'appliquait. Aucune ligne de code, aucun manifeste, aucun test ne la portait. L'exposition s'est faite au démarrage du cluster, sans que personne ait rien à faire ni rien à valider.
flowchart LR
subgraph AV["Garde-fou documentaire"]
A1["Une phrase dans une note"]
A2["Dépend de la mémoire<br/>de celui qui démarre le cluster"]
A3["❌ Ne s'applique jamais<br/>toute seule"]
end
subgraph AP["Garde-fou technique"]
B1["Pas de HTTPRoute<br/>dans l'overlay"]
B2["Rien à oublier :<br/>l'absence EST la protection"]
B3["✅ Vérifiable par un test"]
end
AV ==>|"#146"| AP
C'est la même classe de problème que le gate prod « documentaire » déjà identifié sur
ce projet : CODEOWNERS est en place, mais l'approbation obligatoire est une
fonctionnalité payante, donc le gate suggère sans bloquer.
La règle à retenir
Une règle qui dépend de la mémoire d'un humain au bon moment n'est pas un contrôle, c'est un vœu. Un contrôle, c'est quelque chose qui échoue tout seul quand on l'oublie.
Corollaire opérationnel : quand tu écris « il ne faut pas faire X », demande-toi aussitôt qu'est-ce qui empêche X ?. S'il n'y a pas de réponse, la note ne protège rien.
5. Les deux correctifs
Deux problèmes distincts, deux correctifs indépendants.
#146 — ne pas exposer dev et staging
Les 4 overlays de dev et staging suppriment la HTTPRoute héritée du base au lieu
de lui donner un hostname :
# k8s/overlays/fastapi/dev/kustomization.yaml
patches:
- target:
kind: HTTPRoute
name: fastapi
patch: |
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: fastapi
$patch: delete
Pas de route → pas d'attachement au Gateway → pas d'enregistrement DNS. La protection est une absence, donc il n'y a rien à oublier de configurer.
État actuel : la suppression a été levée des deux côtés
Cet extrait décrit la protection telle que posée en #146. Elle a depuis été retirée, une fois la cause traitée dans chaque cas : les API de dev et staging ont retrouvé leur HTTPRoute en #138 (database, secret et rôle IRSA propres à chaque env), les fronts en #156 (URL d'API lue au runtime, voir Configuration runtime du frontend). Les quatre overlays patchent aujourd'hui le hostname au lieu de supprimer la route. La leçon de la section 4 vaut toujours : ce qui protégeait n'était pas la note, c'était l'absence de route.
#147 — le port 80 ne sert plus qu'à rediriger
Deux pièces, qui ne jouent pas le même rôle. C'est le point que l'on comprend mal en général :
sectionName: httpssur les 4 routes applicatives. C'est ceci qui ferme le clair, en retirant les routes du listener:80.- Une HTTPRoute de redirection sur
sectionName: http, sanshostnamesdonc attrape-tout, avec un filtreRequestRedirecten 301. Elle ne ferme rien : elle offre une sortie propre plutôt qu'un 404 sec.
flowchart TB
subgraph AV2["Avant"]
direction TB
X1["4 routes sans sectionName"]
X2[":80 sert l'application"]
X3[":443 sert l'application"]
end
subgraph AP2["Après #147"]
direction TB
Y1["4 routes en sectionName: https"]
Y2[":80 → une seule route<br/>RequestRedirect 301"]
Y3[":443 sert l'application"]
end
AV2 ==> AP2
Le 301 (permanent) plutôt que 302 : il est mis en cache par le navigateur, donc les
visites suivantes partent directement en HTTPS, sans aller-retour en clair.
Deux chemins de déploiement différents, et c'est piégeux
#146 arrive par ArgoCD : les overlays sont dans le périmètre GitOps, un merge suffit.
La redirection de #147 est posée par le bootstrap Ansible, avec le Gateway. Elle décrit le comportement d'un listener, donc elle appartient à la plomberie du Gateway et non à une application (cf. ADR 010). Conséquence pratique : sur un cluster déjà monté, merger ne suffit pas, il faut rejouer le bootstrap ou appliquer le fichier à la main.
6. Vérifier que ça tient
La procédure complète est au runbook de validation. L'essentiel :
# Les routes n'existent plus en dev/staging (c'est kubectl qui fait foi, pas curl)
kubectl get httproute -A
# Plus rien ne répond publiquement
for h in dev.api staging.api dev.app staging.app; do
curl -sS -m 5 -o /dev/null -w "$h %{http_code}\n" http://$h.devopsyouss.com/
done
# La prod n'a rien perdu
curl -sS https://api.devopsyouss.com/healthz/ready
# L'accès légitime fonctionne toujours
kubectl -n fastapi-dev port-forward svc/fastapi 8080:80
La preuve la plus parlante est côté ExternalDNS. Comme il dérive ses enregistrements des HTTPRoutes, le voir supprimer les enregistrements prouve que les routes ont disparu à la source :
level=info msg="Changing record." action=DELETE record=dev.api.devopsyouss.com type=CNAME
level=info msg="Changing record." action=DELETE record=dev.api.devopsyouss.com type=TXT
level=info msg="Changing record." action=DELETE record=cname-dev.api.devopsyouss.com type=TXT
Trois enregistrements par host : le CNAME, plus deux TXT de propriété
(<host> et cname-<host>) qui portent la marque txtOwnerId=fastapi-eks. C'est ainsi
qu'ExternalDNS sait quels enregistrements lui appartiennent et lesquels ne pas toucher.
Validé en live le 2026-07-26 : les 4 hosts purgés en deux passes de réconciliation, la
prod intacte, le port-forward fonctionnel.
7. Ce que ça ne règle pas
Point d'honnêteté, parce qu'il serait facile de croire le sujet clos.
Ces deux correctifs réduisent l'exposition, ils ne corrigent pas la cause profonde.
Après leur mise en place, dev et staging écrivent toujours dans la base de
production — simplement plus depuis internet.
Le correctif structurel, c'est :
| Issue | Ce qu'elle apporte |
|---|---|
| #137 | 3 databases distinctes + utilisateurs + GRANT scopés |
| #138 | secrets, IRSA, DNS et certificats par environnement |
Une fois ces deux-là livrées, les routes de dev et staging seront rétablies
délibérément. Les commentaires à retirer en #138 sont posés dans les 4 overlays et dans
l'ADR 029 pour que cette levée soit un geste conscient, et non un effet de bord.
Pour aller plus loin
- La stratégie multi-environnements et ses arbitrages : ADR 029.
- Le choix du NLB, du Gateway partagé et la décision sur le listener
:80: ADR 017. - Qui possède quoi entre bootstrap et GitOps : ADR 010.
- Le flux de promotion entre environnements : promotion multi-env.
- Les tests, positifs et négatifs : runbook de validation.