Skip to content

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 :

  1. sectionName: https sur les 4 routes applicatives. C'est ceci qui ferme le clair, en retirant les routes du listener :80.
  2. Une HTTPRoute de redirection sur sectionName: http, sans hostnames donc attrape-tout, avec un filtre RequestRedirect en 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.