Reverse Proxy : HAProxy, Traefik et la chaîne de sécurité

La chaîne de reverse proxy

Chaque requête entrante traverse cinq couches avant d'atteindre l'application. Chacune a un rôle unique, et c'est cet ordre précis qui compte : l'inspection vient avant le cache, faute de quoi une réponse déjà en cache serait servie sans jamais repasser par le contrôle.

sequenceDiagram
    participant Client as Client Internet
    participant HAP as HAProxy<br/>terminaison TLS
    participant TFK as Traefik<br/>Gateway API
    participant WAF as WAF nginx<br/>ModSecurity CRS
    participant VRN as Varnish<br/>cache HTTP
    participant WP as Application<br/>nginx + php-fpm
    Client->>HAP: HTTPS
    Note over HAP: routage par SNI
    HAP->>TFK: HTTPS re-chiffre<br/>certificat interne verifie
    Note over TFK: route unique<br/>filtre sur l adresse source
    TFK->>WAF: HTTP
    Note over WAF: inspection du contenu<br/>par domaine
    alt Requete refusee
        WAF-->>TFK: 403
    else Requete acceptee
        WAF->>VRN: transmission
        alt Present en cache
            VRN-->>WAF: reponse depuis la memoire
        else Absent du cache
            VRN->>WP: requete au backend
            WP-->>VRN: reponse
        end
        VRN-->>WAF: reponse
    end
    WAF-->>TFK: reponse
    TFK-->>HAP: reponse
    HAP-->>Client: HTTPS

Deux conséquences de cet ordre, qui ne se lisent pas sur le schéma :

  • Le pare-feu applicatif voit tout le trafic, le cache n'en voit qu'une partie. Une réponse servie depuis le cache ne touche pas l'application, mais elle a déjà traversé l'inspection.
  • Le pare-feu applicatif est aussi le routeur. C'est lui qui porte la seule route du cluster pour les sites web : Traefik ne connaît pas Varnish, il ne connaît que le service du pare-feu, qui redirige ensuite vers le cache.

HAProxy : terminaison TLS et routage par SNI

HAProxy tourne en conteneur sur le routeur, dans un réseau dédié. C'est le premier point de contact du trafic entrant.

Sélection du certificat

Une liste associe chaque fichier de certificat aux domaines qu'il couvre, et la sélection se fait sur le nom annoncé par le client pendant la négociation TLS :

# Format : chemin_pem [options] domaine [domaine2...]
/etc/haproxy/WWW/blog.pem blog.exemple.fr www.exemple.fr
/etc/haproxy/WWW/app.pem  app.exemple.fr
/etc/haproxy/WWW/home.pem home.exemple.fr

C'est le mécanisme qui rend la panne décrite plus bas si brutale : la liste est validée en entier au démarrage.

Routage par domaine

frontend ft_https
    bind *:443 ssl crt-list /etc/haproxy/WWW.crt_list alpn h2,http/1.1
    mode http

    # Normalisation de l en-tete Host
    http-request set-var(txn.txnhost) hdr(host),lower,regsub(:[0-9]+$,"")

    acl host_blog var(txn.txnhost) -m str blog.exemple.fr
    acl host_app  var(txn.txnhost) -m str app.exemple.fr

    use_backend bk_traefik if host_blog
    use_backend bk_traefik if host_app

La normalisation de l'en-tête — minuscules, suppression du port — évite qu'une requête échappe au routage pour une différence de casse.

Le trafic interne est re-chiffré

backend bk_traefik
    mode http
    timeout connect 30s
    timeout server  600s
    timeout tunnel  3600s

    server traefik1 10.0.2.101:30443 ssl \
        verify required \
        ca-file /etc/ssl/internal-ca.crt \
        sni str(traefik.internal)

Le trafic entre les deux couches est re-chiffré avec vérification du certificat interne. Le nom envoyé pendant la négociation est fixe : le routage applicatif se fait sur l'en-tête HTTP, pas sur le nom TLS.

Pourquoi se donner cette peine sur un réseau interne ? Parce que « interne » n'est pas une propriété de sécurité : si un conteneur voisin est compromis, du trafic en clair est du trafic lisible. La vérification garantit en plus qu'on parle bien à la bonne couche, et pas à quelque chose qui a pris sa place.

Traefik : route unique et filtrage sur la réputation

Une seule route pour tous les sites

apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
  name: waf
spec:
  hostnames:
    - "blog.exemple.fr"
  rules:
    - matches:
        - path:
            type: PathPrefix
            value: /
      filters:
        - type: ExtensionRef
          extensionRef:
            kind: Middleware
            name: crowdsec-bouncer
      backendRefs:
        - name: waf
          port: 80

La destination désigne le pare-feu applicatif, pas le cache : c'est précisément ce qui met l'inspection avant Varnish. Tous les sites du cluster partagent cette route unique, et le tri par domaine se fait ensuite dans les blocs server du pare-feu.

Un filtre vérifie l'adresse source contre une base de réputation avant même que la requête n'atteigne le pare-feu applicatif. Les deux filtrages ne font pas le même travail : l'un juge qui appelle, l'autre ce qu'il envoie.

L'ordre inspection puis cache n'est pas négociable

Le pare-feu applicatif reçoit le trafic et le renvoie vers le cache ; celui-ci est donc derrière l'inspection, jamais devant.

L'ordre inverse aurait une conséquence simple et durable : une page mise en cache serait resservie à toutes les requêtes suivantes sans que le pare-feu les voie passer — et une règle ajoutée ensuite ne s'appliquerait qu'aux requêtes absentes du cache.

L'inspection se règle par domaine, pas globalement : chaque bloc server porte son propre interrupteur. Un domaine peut donc traverser la couche sans être inspecté du tout pendant que ses voisins le sont, et le fichier de configuration a l'air normal dans les deux cas.

Ce que ça a coûté ici

Le domaine du blog a porté un modsecurity off; pendant trois mois, avec en commentaire « géré par le nginx du pod applicatif ». C'était vrai à l'écriture.

Trois semaines plus tard, un autre commit a retiré ModSecurity de ce pod, au motif que « le pare-feu central s'en charge en amont ». Chaque changement était défendable seul ; leur composition a laissé le domaine sans aucune inspection, et aucun des deux fichiers ne devenait faux — chacun décrivait une délégation vers l'autre.

La leçon tient en une phrase : une couche de sécurité se vérifie par une requête, pas par une lecture de configuration. Une sonde qui envoie une charge que le moteur doit refuser, et qui exige ce refus, aurait signalé le trou le jour même. Aucune relecture de fichiers ne l'aurait trouvé. Le principe et sa mise en œuvre sont dans la supervision.

Les délais d'attente doivent être alignés sur toute la chaîne

Un délai plus court en amont coupe la connexion en cours de transfert et produit des erreurs qui désignent la mauvaise couche.

Couche Paramètre Ordre de grandeur
HAProxy, côté client timeout client quelques minutes
HAProxy, côté backend timeout server identique
HAProxy, connexions longues timeout tunnel nettement supérieur
Traefik lecture et écriture identiques aux précédents
Pare-feu applicatif lecture et écriture vers l'amont identiques
Backend lecture vers l'amont identiques

Le mécanisme de panne mérite d'être connu, parce qu'il est trompeur : un délai court en amont coupe la connexion pendant un envoi, la couche suivante reçoit un corps tronqué, le backend répond une erreur de requête malformée, et l'entrée voit une coupure qu'elle rapporte comme une panne de passerelle. Trois couches signalent trois erreurs différentes, et aucune ne pointe celle qui a coupé.

Deux pannes qui ne se limitent pas à leur cause

Un certificat manquant met tous les sites hors ligne. La liste de certificats est validée dans son intégralité au démarrage, et un seul fichier absent empêche le processus de démarrer. La panne ne se limite donc pas au domaine fautif : tout tombe ensemble. C'est la raison pour laquelle un certificat se génère et se vérifie avant d'être référencé, jamais l'inverse.

Une connexion maintenue ouverte est coupée comme une requête ordinaire. Une connexion promue en WebSocket reste ouverte sans échanger de données pendant de longues périodes. Elle est comptée comme une requête HTTP normale et coupée au bout du délai serveur ; seul un délai dédié aux connexions longues la sort de ce compteur une fois la promotion acceptée.

Deux autres pièges de cette chaîne se corrigent ailleurs, et sont documentés là où ils se règlent : une règle ajoutée ne s'applique pas tant que le processus n'a pas rechargé sa configuration — voir le cluster K3s — et la sonde du cache doit viser un endpoint dédié plutôt que la racine, expliqué dans le routage VCL.


Liens

Articles connexes