12min.

Symfony, reverse proxies et protection par IP

This blog post is also available in 🇬🇧 English: Symfony, reverse proxies and IP protection.

Suite à un souci rencontré sur un de mes projets, j’ai dû me plonger dans le fonctionnement de la protection par IP dans nos applicatifs Symfony lorsque des reverse proxies se trouvent devant. Après quelques recherches et tâtonnements, je me suis dit que c’était l’occasion parfaite pour reprendre les bases, puis expliquer comment trouver l’origine du problème et le résoudre. Mais d’abord, mettons un peu de contexte.

Section intitulée contexteContexte ✍️

Le projet concerné est composé de plusieurs applications Symfony. En local, nous utilisons une stack Docker qui est pilotée par une surcouche Fabric pour simplifier notre DX (c’était les prémisses de notre docker-starter il y a plus de 7 ans). Le but était d’avoir une infrastructure locale similaire à l’infrastructure de production. Nous avons donc cette architecture :

Contexte - l'infrastructure du projet

Comme les applications Symfony sont encore avec l’ancienne architecture, nous utilisons toujours les front controllers web/app.php et web/app_dev.php. Le app_dev.php est même déployé en production – pour simplifier le debug distant – mais n’est accessible que depuis les IPs de nos bureaux.

<?php

use Symfony\Component\ErrorHandler\Debug;
use Symfony\Component\HttpFoundation\Request;

require __DIR__ . '/../app/autoload.php';

// …

$request = Request::createFromGlobals();

if (!\Symfony\Component\HttpFoundation\IpUtils::checkIp($request->getClientIp(), [
    'XXX.XXX.XXX.XXX', // IP JoliCode
    '127.0.0.1', // localhost
    'fe80::1',
    '::1',
])) {
    exit();
}

$kernel = new AppKernel(dev, true);
$response = $kernel->handle($request);
$response->send();
$kernel->terminate($request, $response);

Depuis la semaine dernière, la CI est devenue rouge 🔴 et tous les tests fonctionnels échouent avec l’erreur suivante :

The request has both a trusted "FORWARDED" header and a trusted "X_FORWARDED_FOR" header, conflicting with each other. You should either configure your proxy to remove one of them, or configure your project to distrust the offending one.

Grosso-modo, avec la stack trace, on comprend que la méthode $request->getClientIp() – appelée dans notre app_dev.php – lève une erreur car elle détecte plusieurs headers contradictoires. Pour le moment, cette erreur est encore un peu floue. Essayons déjà de comprendre la logique pour trouver l’IP du client 🤓.

Section intitulée recuperer-l-ip-du-clientRécupérer l’IP du client 🪧

Récupérer l’IP du client qui appelle notre app PHP peut sembler être une problématique anodine. Que nenni !

Section intitulée le-probleme-avec-les-proxiesLe problème avec les proxies

Pour avoir l’IP du client dans notre application, la documentation PHP nous informe qu’il suffit de récupérer la variable $_SERVER['REMOTE_ADDR'] qui est créé par le serveur web.

Mais ici, le client de notre serveur web n’est pas l’utilisateur, c’est le proxy varnish. Et c’est d’ailleurs souvent le cas en production : le serveur applicatif peut être situé derrière un ou plusieurs proxies (load balancer, cache http, cdn ou autre).

Le problème avec les proxies

Pour palier ça, il existe un header standard que les reverses proxies peuvent transmettre, à savoir le header FORWARDED, pour faire remonter l’IP de l’utilisateur. Pour simplifier, chaque reverse proxy va donc regarder si la requête fournit un header FORWARDED. Si le header est absent, il va créer ce header avec dedans l’IP de son client. Si le header est déjà présent, il lui suffit de le passer tel quel. Ainsi, le premier reverse proxy de la chaîne va créer le header, et les suivants ne feront que transmettre ce header. Côté application PHP, ceci revient à lire la variable $_SERVER['HTTP_FORWARDED'] au lieu de $_SERVER['REMOTE_ADDR'] pour récupérer la bonne ip du client.

Le header Forwarded en action

Malheureusement, on ne peut pas s’arrêter là et avoir une confiance aveugle dans ce header 🙎.

Section intitulée a-quel-proxy-faire-confianceA quel proxy faire confiance ?

En effet, le header Forwarded est un simple header HTTP. Et qui dit header, dit que n’importe quel client peut passer ce header avec la valeur qu’il souhaite. Le risque ? Qu’un attaquant 🦹‍♀️ se fasse passer pour une IP autorisée à accéder à des services, normalement sécurisés. Dans notre cas, il serait possible de se faire passer pour l’IP de nos locaux et ainsi accéder au web/app_dev.php.

Un utilisateur malveillant se fait passer pour un proxy

Pour éviter cela, la plupart des reverses proxies et applications HTTP ont introduit une notion de « trusted proxy » (sous forme d’un ensemble d’IP). Avant de lire la valeur du header Forwarded, le service va vérifier si son client possède une IP qui fait partie de la liste des proxys connus 🕵️. Si c’est le cas, alors il va pouvoir prendre en compte le header qu’il a reçu. Sinon, le header sera tout simplement ignoré.

L'utilisateur malveillant n'est pas dans la liste des proxies de confiance

Cette fois, c’est bon, c’est fini ? Spoiler : non ✨.

Section intitulée quelles-donnees-faut-il-transfererQuelles données faut-il transférer ?

Il n’est pas rare que le proxy appelle le service suivant (un autre reverse proxy ou notre application) en changeant le host ou au moins le protocole. Dans notre cas, c’est HAProxy qui fait la terminaison SSL. Il reçoit donc des requêtes HTTPs qu’il va transférer en HTTP aux éléments suivants de notre infrastructure.

En bout de piste, notre application reçoit ainsi une requête HTTP. Mais elle a besoin de savoir que la requête originelle était en HTTPs, notamment pour qu’elle puisse générer des URLs avec le bon schéma. C’est pour cette raison que le header Forwarded comporte plusieurs informations, et pas juste l’identifiant du client. Il se structure de cette façon :

Forwarded: by=<identifier>;for=<identifier>;host=<host>;proto=<http|https>

On peut noter entre autres l’identifiant/ip du client (dans l’attribut for) ainsi que les host et protocole demandés initialement.

Section intitulée forwarded-ou-x-forwardedForwarded ou X-Forwarded-* ?

Depuis le début de cet article, je parle du header Forwarded – qui est le standard – pour faire remonter des informations du client qui seraient normalement perdues. En réalité, il existe d’autres headers qui sont devenus des standards de-facto car mis en place par nombre de logiciels, bien avant que Forwarded ne soit standardisé.

Je vous présente donc la petite famille de headers X-Forwarded-* qui apportent chacun une information dédiée :

Il en existe d’autres, pour récupérer notamment le port, l’identifiant du service du forward, etc.

La question qui peut venir naturellement est donc celle-ci :

Quel header dois-je lire pour récupérer les informations initiales ?

Et la réponse est :

Ça dépend ! 🤷

Dans l’idéal, votre service ne devrait recevoir au choix, que le header Forwarded, ou que les headers X-Forwarded-*. S’il reçoit les 2, alors il faudra voir pour changer la config des éléments précédent dans l’infrastructure, pour supprimer l’un ou l’autre.

Section intitulée mise-en-place-dans-symfonyMise en place dans Symfony 👨‍💻

Maintenant que nous savons tout ça, voyons comment implémenter – dans un projet Symfony – une restriction d’accès à notre application en fonction de l’IP de l’utilisateur.

Section intitulée restriction-par-ipRestriction par IP

Plusieurs options sont possibles pour implémenter cette restriction. La première, plus globale, consiste à protéger toute l’application. C’est ce que nous faisons dans notre app_dev.php :

if (!\Symfony\Component\HttpFoundation\IpUtils::checkIp($request->getClientIp(), [
    'XXX.XXX.XXX.XXX', // IP JoliCode
    '127.0.0.1', // localhost
    'fe80::1',
    '::1',
])) {
    exit();
}

La deuxième option peut être de protéger seulement certaines URLs. Pour cela, direction le fichier de configuration de la sécurité :

# config/packages/security.yaml

security:
    # ...
    access_control:
        -
        	path: ^/secure-api
        	role: IS_AUTHENTICATED_ANONYMOUSLY
        	ips:
            	- 127.0.0.1 # localhost
            	- ::1
            	- XXX.XXX.XXX.XXX # IP JoliCode
        -
        	path: ^/secure-api
            role: ROLE_NO_ACCESS

C’est généralement la deuxième option qu’il faut privilégier pour rester dans les règles de l’art.

Maintenant que nous avons vu comment configurer la protection par IP, voyons comment Symfony fait pour chercher l’ip du client.

Section intitulée le-fonctionnement-dans-symfonyLe fonctionnement dans Symfony

Si on regarde un peu plus en détail la classe Request de Symfony, on remarque qu’elle contient toute la logique pour récupérer les bonnes informations dans les bons headers, comme expliqué précédemment :

class Request
{
    /**
     * Returns the client IP addresses.
     *
     * In the returned array the most trusted IP address is first, and the
     * least trusted one last. The "real" client IP address is the last one,
     * but this is also the least trusted one. Trusted proxies are stripped.
     *
     * Use this method carefully; you should use getClientIp() instead.
     *
     * @see getClientIp()
     */
    public function getClientIps(): array
    {
        $ip = $this->server->get('REMOTE_ADDR');

        if (!$this->isFromTrustedProxy()) {
        	return [$ip];
        }

        return $this->getTrustedValues(self::HEADER_X_FORWARDED_FOR, $ip) ?: [$ip];
    }
}

Si la requête ne provient pas d’un proxy connu, l’IP du client original se trouve dans la variable REMOTE_ADDR. Sinon, on va la chercher dans les headers forwarded.

La même logique existe pour la méthode isSecure() qui détermine si la requête originale a été faite avec le protocole HTTPs.

C’est la méthode getTrustedValues() qui va chercher l’information (IP, protocole, etc) soit dans le header Forwarded, soit dans le header équivalent X-Forwarded-*. C’est d’ailleurs cette même méthode qui lève l’erreur mentionnée au début de cet article 💁.

Section intitulée configurer-symfonyConfigurer Symfony

Comme Symfony fait tout pour nous pour trouver la bonne IP, la seule chose qu’il nous reste à faire est de l’informer de nos trusted proxies. La méthode a un peu évolué au fil des versions mais la documentation nous donne toutes les informations nécessaires :

# .env.local
TRUSTED_PROXIES=XXX,YYY
# config/packages/framework.yaml
framework:
    trusted_proxies: '%env(TRUSTED_PROXIES)%'
    trusted_headers: ['forwarded', 'x-forwarded-for', 'x-forwarded-proto']

Au passage, on configure également Symfony pour qu’il sache à quel header il doit faire confiance. C’est le rôle de la configuration trusted_headers.

Note : Il est conseillé de ne pas croire le header X-Forwarded-Host pour ne pas être vulnérable à une attaque sur le host HTTP. De manière générale, nous vous recommandons, quand c’est possible, de forcer explicitement le host et le protocole à utiliser par votre application Symfony (https dans tous les cas par exemple) pour ne pas dépendre du header Forwarded qui pourraient être mal configuré par un élément de votre infrastructure.

Note : avant Symfony 5.2, à la place de la configuration yaml, il fallait appeler manuellement la méthode Request::setTrustedProxies($ips, $headers) dans le front controller public/index.php, où $headers était un champ de bit qui permettait de spécifier n’importe quelle configuration :

  • Le Forwarded mais aussi X-Forwarded-For : Request::HEADER_FORWARDED | Request::HEADER_X_FORWARDED_FOR ;
  • Seulement le header Forwarded : Request::HEADER_FORWARDED;
  • Tous les headers X-Forwarded-* sauf X-Forwaded-Host : Request::HEADER_X_FORWARDED_ALL ^ Request::HEADER_X_FORWARDED_HOST

Section intitulée des-reverses-proxies-dans-dockerDes reverses proxies dans Docker

Si comme nous, votre projet tourne en local dans une stack Docker, il faut penser à configurer votre application – et tous les reverses proxies de l’infrastructure – pour faire confiance à tous les services HTTP qui tournent dans cette stack.

Par défaut, les IPs des conteneurs ne sont pas fixes et vont changer à chaque fois que vous redémarrez le projet. Il n’est donc pas possible de spécifier une IP pour chacun en tant que trusted proxy. Après quelques recherches, il semblerait que Docker utilise par défaut des IPs contenu dans le range 172.17.xxx.xxx – 172.31.xxx.xxx ainsi que 192.168.xxx.xxx. Il est possible de changer cela dans la configuration de Docker si nécessaire, mais nous n’en tiendrons pas compte pour la suite, libre à vous d’adapter ce qui suit 😉.

On va pouvoir configurer Symfony (et tous les reverses proxies de la stack) pour accepter ces IPs là :

# trust localhost and local docker containers
TRUSTED_PROXIES=127.0.0.1,172.17.0.0/16,172.18.0.0/15,172.20.0.0/14,172.24.0.0/13,192.168.0.0/16

Protip : pour convertir un range d’IPs en notation CIDR, il est possible d’utiliser des outils en ligne comme https://www.ipaddressguide.com/cidr.

Section intitulée retour-au-problemeRetour au problème 🔎

Vous vous souvenez de l’erreur mentionnée au début de cet article ?

The request has both a trusted "FORWARDED" header and a trusted "X_FORWARDED_FOR" header, conflicting with each other. You should either configure your proxy to remove one of them, or configure your project to distrust the offending one.

C’est la méthode $request->getTrustedValues() qui lève cette erreur car elle détecte que notre application reçoit ces 2 headers. Il faut donc que nous cherchions, dans l’infrastructure, qui émet ces 2 headers.

Section intitulée a-la-recherche-du-coupableÀ la recherche du coupable

Et pour le débusquer, nous allons utiliser httpbin. Ce service permet de retourner toutes les propriétés des requêtes HTTP qu’il reçoit, dans un format JSON. On va donc l’ajouter dans notre stack docker, et l’intercaler entre les différents proxies qui se trouvent devant le conteneur qui sert notre application.

Tout d’abord, on ajoute httpbin dans notre docker-compose.yml :

services:
    # …

    httpbin:
        image: kennethreitz/httpbin

On commence par modifier la configuration du Varnish pour qu’il transfère les requêtes vers httpbin au lieu du conteneur servant notre application, puis on rebuild la stack Docker. L’idée, c’est d’abord de confirmer que httpbin fonctionne bien et qu’il reçoit bien plusieurs headers forwarded, comme s’en plaint Symfony.

Pour cela, on ouvre le nom de domaine auquel répondait notre application auparavant. Si httpbin répond, tout est bon.

Nous pouvons maintenant regarder l’url http://<host de l'application locale>/headers?show_env=1 et nous obtenons ceci :

{
  "headers": {
    "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
    "Accept-Encoding": "gzip,deflate,br",
    "Accept-Language": "en-US,en;q=0.5",
    "Cookie": "<redacted>",
    "Date": "Wed, 05 Apr 2023 20:25:26 GMT",
    "Forwarded": "by=redirectionio-proxy/dev-docker-server;for=172.19.0.1;proto=http",
    "Host": "<host de l'application locale>",
    "Sec-Fetch-Dest": "document",
    "Sec-Fetch-Mode": "navigate",
    "Sec-Fetch-Site": "none",
    "Sec-Fetch-User": "?1",
    "Upgrade-Insecure-Requests": "1",
    "User-Agent": "Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/111.0",
    "Via": "1.1 redirectionio-proxy/dev-docker-server",
    "X-Forwarded-By": "redirectionio-proxy/dev-docker-server",
    "X-Forwarded-For": "172.19.0.1, 172.19.0.1, 172.19.0.35",
    "X-Forwarded-Host": "<host de l'application locale>",
    "X-Forwarded-Port": "443",
    "X-Forwarded-Proto": "https,http",
    "X-Forwarded-Server": "c1bb13649082",
    "X-Real-Ip": "172.19.0.1",
    "X-Varnish": "65547"
  }
}

Protip : pensez bien à rajouter l’option non documentée ?show_env=1 dans l’url, sinon httpbin n’affiche pas toutes les infos, notamment les headers Forwarded, X-Forwarded-For. Ça m’a d’ailleurs fait perdre pas mal de temps en me laissant penser, à tort, que ces headers n’étaient pas transmis 🤬.

Ici, on constate bien que les 2 headers contradictoires sont présents. Symfony n’a pas menti :

{
  "headers": {
    "Forwarded": "by=redirectionio-proxy/dev-docker-server;for=172.19.0.1;proto=http",
    "X-Forwarded-For": "172.19.0.1, 172.19.0.1, 172.19.0.35"
  }
}

Nous pouvons remonter la chaîne, petit à petit. On va cette fois modifier le reverse proxy précédent pour transférer vers httpbin au lieu de Varnish. En l’occurrence, il s’agit ici de l’agent redirection.io. On rebuild la stack Docker et on rafraîchit l’url. Les 2 headers sont encore là. Le problème existe donc avant Varnish.

On passe au proxy précédent. Cette fois, c’est HAProxy que nous allons modifier pour pointer sur httpbin au lieu de l’agent redirection.io. Rebuild de la stack, CTRL + F5 dans le navigateur. Cette fois, nous n’avons qu’un seul header, à savoir le X-Forwarded-For.

Le coupable est identifié, il s’agit donc de l’agent redirection.io qui ajoute le header Forwarded – en plus de passer les headers X-Forwarded-* qu’il reçoit.

Section intitulée explication-et-resolutionExplication et résolution

Après avoir parlé aux copains de chez redirection.io, il s’avère que le bug était connu dans la version 2.6.0 de l’agent. Il s’agissait bien d’une mauvaise gestion du header Forwarded dans le cas où l’agent reçoit un X-Forwarded-For.

Comme nous utilisions l’image Docker officielle en version latest, la CI utilisait la version 2.6.0 fraîchement sortie alors que la stack de développement restait avec l’image locale en 2.5.4, sans le bug. Impossible donc de reproduire en local sans supprimer l’image Docker correspondante.

En attendant qu’un fix soit disponible – il l’a été au bout de quelques heures avec la sortie de la version 2.6.1 – il aura suffit de fixer la version utilisée dans le Dockerfile – plutôt que d’utiliser la latest – pour confirmer le bon fonctionnement et retrouver une CI verte 🎉.

Section intitulée conclusionConclusion

Un bug est souvent l’occasion de creuser le fonctionnement du code ou du logiciel impliqué. Ici, une erreur levée par Symfony aura permis de trouver un dysfonctionnement dans l’infrastructure Docker utilisée en développement, d’approfondir le fonctionnement de la récupération de l’adresse IP d’un utilisateur ainsi que d’apprendre la différence entre les headers Forwarded et X-Forwarded-*. Ça aura également été l’occasion de découvrir httpbin, un outil hyper pratique pour débugger une infrastructure HTTP.

C’est également une super occasion pour mettre en forme ses recherches et partager ça dans un article. Cela permet aussi de donner quelques astuces qui peuvent aider à faire gagner du temps si d’autres personnes rencontrent un souci similaire.

Enfin, avant de terminer cet article, je voulais partager une information sur laquelle je suis tombé lors de mes recherches, qui n’est pas directement en lien avec cet article, mais qu’il m’a semblé intéressant de connaître. En effet l’un des problèmes du header Forwarded, c’est qu’il fait partie du protocole HTTP. Par conséquent, ce header – et l’information qu’il contient – ne sont disponible que dans la couche 6/7 du modèle OSI et ne sont donc pas accessible aux proxies plus bas niveau (« dumb proxies »). Un protocole a donc été proposé – le protocole PROXY – pour faire transiter les informations initiales du client dans un nouveau format, directement au niveau TCP (donc dans la couche 4 du modèle OSI).

Commentaires et discussions

Ces clients ont profité de notre expertise