❯ networking · Running

Défense en profondeur : l'app vérifie elle-même le badge Cloudflare Access Defence in depth: the app checks the Cloudflare Access badge itself

Une console d'administration interne, derrière un tunnel Cloudflare et Access, répondait pourtant à n'importe qui dans le cluster. La parade : vérifier le JWT Cf-Access-Jwt-Assertion dans l'application, en mode journal puis strict — et les pièges de l'audience, des WebSocket, des journaux et du cache de clés rencontrés en route. An internal admin console sat behind a Cloudflare tunnel and Access, yet answered anyone inside the cluster. The fix: verify the Cf-Access-Jwt-Assertion JWT in the app, roll it out in log-only mode, then strict — and the audience, WebSocket, logging and key-cache traps found on the way.

· 12 min de lecture 12 min read ·
k3s-clustersecuritycloudflarenetworkingpythonk8s
🇬🇧 Read this article in English

Le constat : « Tableau de bord » depuis le mauvais conteneur

Le 24 septembre 2026, je vérifie une chose qui me semblait acquise. La console d’administration d’une petite app web Python est publiée derrière un tunnel Cloudflare, et son hôte est protégé par Cloudflare Access : écran de connexion obligatoire, politique réservée à mon compte. Depuis Internet, impossible d’entrer sans badge.

Depuis l’intérieur, c’est une autre histoire. Le pod de l’application regroupe plusieurs conteneurs, dont la partie publique, celle qui parle à Internet toute la journée. Depuis ce conteneur-là, une requête sur l’interface locale du pod :

curl -s http://127.0.0.1:<port>/

répond 200, avec le titre « Tableau de bord ». Pas de badge, pas de connexion, rien. Et le conteneur voisin n’a rien de spécial : n’importe quel autre pod du cluster qui atteint le Service obtient la même réponse.

La phrase qui résume le problème est restée en tête du module qui l’a corrigé : Access protège une URL, pas un port. L’administration n’avait aucune authentification propre. Elle déléguait tout au bord du réseau, et le bord du réseau ne voit que ce qui passe par lui.

Le modèle de menace : tout ce qui ne passe pas par Cloudflare

Un tunnel Cloudflare doublé d’Access est une très bonne porte d’entrée. Le problème, ce sont les autres portes :

  • Le conteneur voisin. Les conteneurs d’un pod partagent la même interface réseau. Si la partie publique est compromise, l’intrus est à un curl de l’administration.
  • Le reste du cluster. Sans NetworkPolicy restrictive, un pod joint n’importe quel Service. Une faille dans une autre application du homelab suffit.
  • Toute route qui contourne le tunnel. Un Ingress joignable depuis le LAN, un port-forward oublié, un changement de routage futur : Access n’en voit rien.
  • Une erreur de configuration Access. Une politique trop large, ou une application Access mal ciblée. J’y reviens plus bas, parce que c’est exactement ce qui s’est produit.

La réponse classique s’appelle défense en profondeur : l’application ne fait pas confiance au chemin, elle exige la preuve que la requête est passée par Access, et la vérifie elle-même.

Le badge : un JWT que l’app peut vérifier seule

À chaque requête qu’il laisse passer, Access ajoute un en-tête Cf-Access-Jwt-Assertion. C’est un JWT signé en RS256 par la clé privée de l’équipe Zero Trust. Les clés publiques correspondantes sont servies sur https://<équipe>.cloudflareaccess.com/cdn-cgi/access/certs. Le jeton porte notamment une audience (aud, l’identifiant de l’application Access), un émetteur (iss, le domaine de l’équipe) et une expiration (exp).

Point important : vérifier ce badge ne demande aucun secret. Le domaine de l’équipe et l’AUD sont des valeurs publiques. Elles vont en clair dans le manifeste, sans scellement.

Toute la cryptographie est celle de pyjwt (avec l’extra [crypto], qui tire cryptography) ; rien n’est écrit à la main. Le cœur de la vérification tient en quelques lignes :

def verifier(self, jeton: str) -> dict:
    if not jeton:
        raise AccesRefuse("en-tête absent")
    try:
        entete = jwt.get_unverified_header(jeton)
    except Exception as exc:
        raise AccesRefuse("jeton illisible") from exc
    # Refusé AVANT de chercher la clé : un `alg: none` ou un HS256 à `kid`
    # inventé ne doit même pas coûter un rechargement.
    if entete.get("alg") != "RS256":
        raise AccesRefuse("signature invalide")
    cle = self._cle(entete.get("kid"))
    try:
        return jwt.decode(jeton, cle, algorithms=["RS256"],
                          audience=list(self.audiences),
                          issuer=self.domaine, leeway=60,
                          options={"require": ["exp", "iat", "iss", "aud"]})
    except jwt.ExpiredSignatureError as exc:
        raise AccesRefuse("jeton expiré") from exc
    except jwt.InvalidAudienceError as exc:
        raise AccesRefuse("audience inattendue") from exc
    # … émetteur, signature, puis un `except Exception` final

Quelques choix qui comptent. La liste algorithms=["RS256"] est ce qui ferme la confusion d’algorithme (un HS256 signé avec la clé publique comme secret). Les quatre revendications sont exigées, pas seulement vérifiées si présentes. La tolérance d’horloge est de 60 s. Et tout échec devient une AccesRefuse avec un motif court : aucune autre exception ne remonte, sinon elle deviendrait un 500. Dans les journaux, uniquement ce motif ; ni le jeton, ni l’e-mail, ni aucune revendication.

Le middleware : tout, sauf la sonde

La vérification est branchée dans un middleware ASGI pur, ajouté en dernier dans create_app, donc le plus extérieur : il passe avant tout le reste, fichiers statiques et prévisualisations compris.

async def __call__(self, scope, receive, send) -> None:
    if scope["type"] == "lifespan" or (scope["type"] == "http"
                                       and scope["path"] == CHEMIN_SONDE):
        await self.app(scope, receive, send)
        return
    try:
        await run_in_threadpool(self.verificateur.verifier,
                                Headers(scope=scope).get(EN_TETE, ""))
    except AccesRefuse as refus:
        ...

Une seule exemption, /healthz, à l’égalité exacte : les sondes du kubelet n’ont pas de badge. verifier est synchrone (le chargement des clés fait un appel HTTP bloquant), d’où le run_in_threadpool. Le middleware n’est monté que sur l’administration ; la partie publique et le site vitrine ne sont pas touchés.

Le déploiement : d’abord journaliser, ensuite refuser

Brancher un 403 sur une console qu’on utilise soi-même, c’est risquer de s’enfermer dehors pour une AUD mal recopiée. D’où deux modes, pilotés par une variable :

  • journal : un badge invalide est journalisé, la requête passe quand même. Un badge valide est annoncé (« badge Cloudflare Access valide »).
  • strict (le défaut) : badge absent ou invalide, 403.

Côté GitOps, trois variables sur le seul conteneur d’administration :

- name: ADMIN_ACCES_DOMAINE
  value: https://<équipe>.cloudflareaccess.com
- name: ADMIN_ACCES_AUDIENCES
  value: <aud-de-64-caractères-hexadécimaux>
- name: ADMIN_ACCES_MODE
  value: journal

La chronologie, telle que les deux dépôts la racontent :

QuandQuoi
30.09, 22 h 15Manifeste : les trois variables, en mode journal
30.09, 22 h 18 – 22 h 26Dépendance pyjwt, module de vérification, tests, documentation
30.09, 22 h 49 – 23 h 19Correctifs issus d’une revue de sécurité (deux passes)
01.10, 10 h 10AUD remplacée par celle d’une application Access dédiée
01.10, 10 h 26 – 10 h 27Fusion côté GitOps, puis côté application
01.10, 10 h 55Passage en strict

L’ordre des fusions n’est pas un hasard : la nouvelle image refuse de démarrer dans un pod sans ces variables (on y vient). Le manifeste devait donc les porter avant que l’image arrive. Le mode journal a duré moins d’une demi-heure : le temps de voir un vrai badge accepté dans les journaux. Le commentaire du manifeste le dit sans détour : revenir à journal seulement pour diagnostiquer, jamais durablement, puisque ce mode laisse passer l’intrus en le signalant.

Le piège de l’audience : un badge valide, mais pour qui ?

La première AUD posée dans le manifeste avait été lue dans la redirection de connexion. C’était celle de l’application Access générique, un joker qui couvre tous les sous-domaines du homelab. Grafana, n8n, la partie publique de l’app : tous partagent cette application, donc cette AUD.

Le badge ne dit pas par quel hôte il est passé. Il dit : « signé par l’équipe, pour l’application X, valable jusqu’à telle heure ». Si l’administration accepte l’AUD de l’application générique, un badge capté sur n’importe lequel de ces services est accepté par l’administration. Or la partie publique reçoit un badge à chaque visite que j’y fais. Un conteneur public compromis n’avait qu’à le garder et le rejouer sur l’admin pendant toute la durée de la session Access.

Autrement dit, la vérification de signature était parfaite et ne protégeait pas contre le scénario même qui l’avait motivée. Le 1er octobre, une application Access dédiée au seul hôte d’administration a été créée, et sa propre AUD a remplacé l’ancienne. Un test de manifeste contrôle que la valeur est bien une AUD (64 caractères hexadécimaux), mais aucun test ne peut savoir qu’elle est dédiée : c’est écrit dans le README et dans le commentaire du manifeste, en toutes lettres.

Les WebSocket : la route qui n’existait pas encore

La première version du middleware laissait passer tout ce qui n’était pas une requête HTTP. Aucune route WebSocket n’existait, donc aucun trou visible. Mais le jour où une page de suivi en direct apparaîtrait, elle naîtrait ouverte à tout le pod.

Désormais, seul lifespan passe sans contrôle. Un scope websocket est vérifié avec le même en-tête. En strict, un échec ferme la connexion avec le code 1008 (« policy violation ») avant accept, ce que le serveur traduit en 403 ; en journal, avertissement puis passage. L’exemption de /healthz ne vaut que pour HTTP.

Les journaux : quand l’URL est le secret

L’administration monte aussi le parcours de commande, et plusieurs de ses URL sont des secrets porteurs : un jeton dans le chemin donne accès à une commande. Deux endroits les écrivaient.

Le premier, c’est le refus du middleware lui-même. Le chemin journalisé est réduit à son premier segment (/a/…), et passé en %r pour neutraliser les retours à la ligne : un intrus qui rejoue un jeton volé ne doit pas le faire écrire dans les journaux.

Le second, plus gros, c’est le journal d’accès d’uvicorn, qui écrit chaque requête avec son chemin complet, chaîne de requête comprise. Ces lignes partent via Vector dans VictoriaLogs, gardées 8 jours. Un filtre de logging les réécrit :

_SUITE_JETON = re.compile(r"[A-Za-z0-9_-]{16,}")

def chemin_sans_jeton(chemin: str) -> str:
    return _SUITE_JETON.sub("…", chemin.split("?", 1)[0])

Les jetons sont des HMAC-SHA256 en base64url, soit 43 caractères. Toute suite de 16 caractères ou plus de cet alphabet, où qu’elle soit dans le chemin, devient … ; la chaîne de requête disparaît. La première version ne masquait que les segments entiers ; la seconde passe de revue a montré qu’un jeton collé à un caractère étranger (une parenthèse, un point, un %20, un espace de largeur nulle d’un lien copié depuis une phrase) passait en entier. Conséquence assumée : une adresse de site un peu longue est masquée elle aussi. Le filtre ne lève jamais, ne supprime jamais une ligne, et il est posé sur les trois applications du dépôt, pas seulement l’administration.

Les clés publiques : un cache qui ne doit ni mentir ni bloquer

Aller chercher les clés à chaque requête serait absurde. Le cache a quatre constantes :

DUREE_CACHE = 3600                  # clés gardées une heure
INTERVALLE_RECHARGEMENT = 60        # un rechargement par minute au plus
CONSERVATION_MAX = 24 * 3600        # clés périmées servies 24 h au plus
ATTENTE_PREMIER_CHARGEMENT = 6      # au-dessus du délai d'httpx (5 s)

Les clés sont chargées paresseusement, à la première requête : un Cloudflare injoignable n’empêche jamais le démarrage. Un kid inconnu (rotation des clés) déclenche un rechargement, mais jamais plus d’une tentative par minute, réussie ou non : un attaquant qui forge des jetons à kid inventé ne doit pas pouvoir faire marteler Cloudflare par l’administration.

La revue de sécurité a ensuite trouvé trois défauts, corrigés le même soir.

Des clés périmées pour toujours. Si le rechargement échouait, le jeu de clés connu continuait de servir, sans limite. Cloudflare fait tourner ses clés avec un long recouvrement, donc c’était confortable ; mais une clé révoquée après une fuite restait valable aussi longtemps que le pod ne joignait plus Cloudflare. Désormais : 24 h au plus au-delà de l’heure de cache, puis « clés publiques injoignables ».

Un verrou tenu pendant l’appel réseau. Un chargement lent bloquait toutes les autres requêtes. La règle est devenue : on décide sous verrou, on charge hors verrou, on remplace sous verrou. Pendant un rechargement, les autres fils servent le cache existant sans attendre. Dans la foulée, /healthz est passée en async def : la sonde, qui a une seconde de délai, n’attend plus un fil libre du pool partagé avec la vérification du badge.

Le tout premier chargement. Sans aucun cache, un fil arrivé pendant le premier chargement était refusé aussitôt, avec « clés publiques injoignables », alors que Cloudflare répondait très bien. Deux onglets ouverts juste après un redémarrage donnaient donc un 403 et une fausse piste réseau. Désormais, ces fils attendent la fin du chargement (un threading.Event, 6 s au plus) puis vérifient avec les clés obtenues. L’attente est bornée : aucun fil ne s’empile indéfiniment derrière Cloudflare.

Fermé par défaut : pas de badge, pas de démarrage

En local, l’administration doit pouvoir tourner sans rien configurer. En production, elle ne doit jamais tourner sans vérification. La configuration encode les deux :

  • domaine et audiences vont ensemble : l’un sans l’autre, refus de démarrer (une configuration à moitié faite n’est pas une admin « un peu » protégée, c’est une faute de frappe qui l’ouvre en silence) ;
  • le domaine doit commencer par https:// et perd sa barre finale, puisqu’il est comparé à l’égalité à l’émetteur ;
  • les deux vides dans un pod : refus de démarrer.
elif os.environ.get("KUBERNETES_SERVICE_HOST"):
    raise RuntimeError(
        "administration SANS vérification du badge Cloudflare Access "
        "dans un pod Kubernetes ... : refus de démarrer.")

Kubernetes pose toujours KUBERNETES_SERVICE_HOST dans un pod. Le scénario visé : un manifeste réorganisé, synchronisé par ArgoCD sans que la CI de l’application ne tourne, qui perd les trois variables. Plutôt qu’une admin redémarrée ouverte, une admin qui tombe. Hors pod, un simple avertissement au démarrage.

Le revers, découvert aussitôt : mon éditeur de code tourne lui-même dans le cluster, donc la variable y est posée et une admin lancée depuis son terminal refuse de démarrer. Le README documente le contournement, limité au seul processus : env -u KUBERNETES_SERVICE_HOST uv run uvicorn …. La configuration de pytest retire la variable avant d’importer l’application, pour la même raison : une suite de tests lancée depuis le cluster n’est pas un déploiement.

La dépendance : un plancher pour une CVE

La dépendance s’écrit pyjwt[crypto]>=2.10.1, et le plancher n’est pas décoratif. La CVE-2024-53861 touche pyjwt 2.10.0 : quand issuer est passé comme chaîne, l’émetteur est comparé en sous-chaîne. C’est précisément la forme utilisée ici (issuer=self.domaine). La version verrouillée (2.15.1) n’était pas concernée ; le plancher empêche simplement d’y redescendre un jour, par un verrou régénéré ou une résolution malheureuse.

Les tests : verrouiller qui porte quoi

Le module de vérification a 55 fonctions de test, le filtre de journal 10. On y trouve les classiques (alg: none, confusion HS256 avec la clé publique, audience et émetteur étrangers, jeton expiré, minute de tolérance), le cache (une heure, un rechargement par minute, 24 h de conservation, attente bornée à 6 s), la fermeture 1008 des WebSocket et le refus de démarrer en pod.

Deux tests portent sur les manifestes du dépôt GitOps, et ce sont eux que je trouve les plus utiles :

def test_seule_l_admin_porte_les_reglages_du_badge():
    for conteneur in _containers():
        if conteneur["name"] == "admin":
            continue
        noms = {e["name"] for e in conteneur.get("env", [])}
        assert not noms & set(VARIABLES_ACCES)

Le premier exige que le conteneur d’administration porte les trois variables, avec une AUD au bon format et un mode connu. Le second interdit ces variables aux autres conteneurs : leur donner un badge à vérifier ne protégerait rien et brouillerait la lecture du manifeste. Les contrôles de déploiement passent ainsi de 41 à 43.

Ce que je garde

  1. Access protège une URL, pas un port. Tout ce qui atteint le Service sans passer par Cloudflare (conteneur voisin, autre pod, route oubliée) ignore Access. Une application sensible vérifie elle-même le badge.
  2. Vérifier l’audience, et la rendre spécifique. Une signature valide prouve seulement que le badge vient de l’équipe. Une AUD partagée par plusieurs hôtes rend rejouable sur l’admin un badge capté ailleurs : une application Access par hôte sensible.
  3. Journal, puis strict, rapidement. Le mode journal sert à constater de vrais badges acceptés, pas à vivre avec.
  4. Tous les protocoles. Les WebSocket exigent le badge, même quand aucune route WebSocket n’existe encore.
  5. Sortir les jetons des journaux. Si une URL est un secret, le journal d’accès est une fuite ; masquer par motif, pas par segment.
  6. Un cache borné et non bloquant. Clés périmées 24 h au plus, aucun verrou pendant un appel réseau, attente bornée au premier chargement.
  7. Fermé par défaut là où ça compte. Dans un pod, pas de configuration, pas de démarrage ; en local, un avertissement.
  8. Un plancher de version quand une CVE touche exactement l’appel qu’on fait.

La limite est claire : rien de tout ça ne remplace une NetworkPolicy qui empêcherait le reste du cluster d’atteindre l’administration. C’est une couche de plus, pas la seule. Mais depuis le 1er octobre, la porte de service répond 403, et un badge emprunté à un autre service aussi.

k3s-clustersecuritycloudflarenetworkingpythonk8s