23 septembre 2009
Imprimer ce billet

Sécuriser Tomcat 5 derrière un proxy Apache 2 HTTPS

Configurer Tomcat 5 derrière un proxy Apache avec HTTPS, dit comme ça, le novice éclairé pourrait croire que c'est facile. Cependant les problèmes dus à de mauvaises configurations sont nombreux et parfois difficiles à diagnostiquer comme les boucles infinies de redirection par exemple. Donc facile oui (maintenant), mais il m'a fallu plusieurs heures de sueur avant de trouver, non pas une, mais deux solutions. L'avantage de ces deux techniques est de n'utiliser que le driver SSL d'Apache pour fournir le protocole HTTPS. Notez bien cette distinction, car il est facile d'avoir la configuration Apache HTTPS en proxy de Tomcat 5 HTTPS. Toute la difficulté est d'avoir une configuration pleinement fonctionnelle dans laquelle Apache HTTPS est proxy de Tomcat 5 HTTP. Cela permet de sniffer les paquets entre le frontal Web et l'application métier, tout en évitant de doubler les ressources utilisées pour encrypter et décrypter les données.

Un détail pour ceux qui utilisent le mod_jk, le projet Apache Httpd propose depuis Apache 2.0, le module mod_proxy qui permet notamment d'assurer la liaison Apache-Tomcat. Ce module, complété en version 2.2 de mod_proxy_balancer pour le load balancing, supporte les protocole HTTP (mod_proxy_http) et AJP 1.3 (mod_proxy_ajp). Si AJP a longtemps été le protocole utilisé par Tomcat en production, il est aujourd'hui possible, plus simple et plus fiable d'utiliser HTTP. HTTP est notamment recommandé par Mark Thomas et Philip Hanik, SpringSource, qui sont les plus gros contributeurs du projet Tomcat en ce moment.
Si vous êtes habitués à mod_jk, vous apprécierez notamment la simplicité d'un module embarqué par la distribution standard de Tomcat, la fin des interrogations sur la fermeture des connexions en présence d'un firewall entre Apache et Tomcat ou encore le débuggage avec un sniffeur réseau comme wireshark pour voir passer en clair les requêtes HTTP.

Prérequis

Nous passerons les étapes fastidieuses de création des certificats déjà largement documentées à travers le net. Vous devez donc être muni d'une installation Apache déjà fonctionnelle en HTTP comme en HTTPS. Pensez aussi à vérifier que le mod_proxy_http est bien installé et chargé au démarrage d'Apache. Puisqu'il s'agit de Tomcat 5, récupérez aussi la dernière version (Tomcat 5.5.28 actuellement). A priori, ce tutoriel est valide pour toutes les versions de Tomcat 5.5 mais je l'ai uniquement testé sur la version sus-mentionnée.

Voilà, c'est à peu près tout, en dehors de l'application web à tester. Par manque de temps, je ne vous proposerai pas d'application web de démonstration. A vous donc de choisir le war que vous allez tester.

Configurer le proxy Apache HTTP

L'objectif de cette étape est d'avoir accès à travers Apache, sur le port 80, à notre application web testapp, hébergée par Tomcat. Nous allons donc configurer un proxy Apache redirigeant les requêtes HTTP sur le contexte /testapp vers le contexte du même nom, sur le port 8080 du Tomcat. Pour que tout reste clair, j'ai choisi de considérer que Tomcat et Apache sont sur la même machine à l'adresse IP 192.168.42.42, vous devrez donc remplacer à chaque fois qu'elle apparaît cette IP par celle de votre machine. N'hésitez pas non plus, à remplacer l'utilisation de l'adresse IP par celle d'un nom de domaine DNS pour plus de flexibilité.

Apache

Tout d'abord, assurez-vous que le mod_proxy et le mod_proxy_http sont bien chargés au démarrage. Vérifiez dans le fichier httpd.conf ou dans les fichiers de chargement de modules que ces deux lignes sont bien présentes et dé-commentées :


LoadModule proxy_module libexec/apache2/mod_proxy.so
LoadModule proxy_http_module libexec/apache2/mod_proxy_http.so

Attention, ces lignes sont issues de mon httpd.conf sous OS X, les chemins et l'extension peuvent donc varier selon votre système. Recherchez le terme mod_proxy dans les fichiers de configuration d'Apache. Si vous ne le trouvez pas, alors vous devrez sans doute installer le module par vous-même.

Il faut créer un hôte virtuel qui recevra les requêtes sur le port 80 et les passera au serveur Tomcat. Créez d'abord le fichier proxyTomcat.conf

#
# proxyTomcat.conf Proxy Server directives.
#
<IfModule mod_proxy.c>
<VirtualHost _default_:80>
    ProxyRequests Off       # Le proxy n'est accessible que pour les urls définies
    ProxyPreserveHost On    # Conserver le nom d'hôte d'origine dans la requête

        # Rediriger les requêtes sur /testapp vers http://192.168.42.42:8080/testapp
    ProxyPass    /testapp    http://192.168.42.42:8080/testapp
        # Ajuster les URL de la réponse HTTP du serveur Tomcat
    ProxyPassReverse /testapp http://192.168.42.42:8080/testapp
</VirtualHost>
</IfModule>

Si vous êtes totalement novice en configuration Apache, commentez les balises IfModule en plaçant un # en début de ligne. Ces commandes permettent à Apache de ne prendre en compte les paramétrages dans la balise que si le module est bien présent. Si vous les commentez et que le module n'est pas disponible, Apache vous remontera un problème de configuration à son prochain démarrage.

La balise VirtualHost crée un hôte virtuel qui va nous permettre d'isoler la configuration du proxy pour le port 80. Cela nous apporte la garantie que seules les requêtes arrivant sur ce port utiliseront notre configuration. Si les directives ProxyPass* sont placées en dehors d'un hôte virtuel, elles s'appliqueront quelque soit le port de connexion 80/443 en entrée. Ce qui sera totalement transparent en HTTP, mais vous causera de sérieuses difficultés dès que vous accéderez au serveur par HTTPS.

Copiez le fichier dans le répertoire contenant les fichiers chargés au démarrage d'Apache. Là encore, si vous ne trouvez pas où copier le fichier proxyTomcat.conf, placez-le à côté de votre httpd.conf. Éditez ensuite le fichier httpd.conf et ajoutez-y, à la fin, la ligne :

Include chemin/absolu/vers/proxyTomcat.conf

Attention : Apache utilise '/' comme séparateur de fichier sous Windows, le chemin devrait ressembler à c:/apache2/conf/proxyTomcat.conf. Vous pouvez aussi rechercher la directive Include contenant un chemin finissant par .../*.conf pour déterminer le répertoire où stocker notre fichier de configuration.

Pour éviter tout problème, vous pouvez aussi vérifier que l'hôte virtuel default:80 n'existe pas déjà. Le cas échéant, contentez-vous de copier le contenu de notre hôte virtuel à la fin de celui qui existe déjà.

Tomcat

Cette étape est la partie facile ! Il suffit d'ajouter deux petits attributs au connecteur du port 8080 de votre server.xml. Éditez le fichier server.xml qui se trouve dans le répertoire conf de Tomcat, et ajoutez-y les attributs proxyPort="80" proxyName="192.168.42.42" au connecteur du port 8080.

<Connector port="8080" ... proxyPort="80" proxyName="192.168.42.42" />

Tomcat utilisera la valeur de ProxyPort pour les requêtes traitées par ce connecteur. De la même façon, la valeur de ProxyName sera utilisée comme nom d'hôte du serveur.

Relancez Tomcat et Apache pour vous assurer que les modifications sont bien prises en compte. Testez l'accès à votre application web avec votre navigateur préféré http://localhost/testapp.

Configurer le proxy Apache HTTPS

Pour cette étape, l'objectif est d'accéder à travers Apache en HTTPS à l'application web testapp hébergée par Tomcat. Nous allons donc créer un proxy Apache redirigeant les requêtes HTTPS sur le contexte /testapp vers le contexte du même nom sur le port 8443 du serveur Tomcat. L'utilisation d'un deuxième port nous permettra plus tard de faire la distinction entre les requêtes sécurisées request.isSecure() et les autres.

Apache

Ici, le travail est simple puisqu'il suffit d'ajouter les mêmes directives de proxy que tout à l'heure à la configuration SSL d'Apache.

Il faut d'abord localiser le fichier dans lequel le support SSL d'Apache est configuré. Sur mon mac, c'est dans /private/etc/apache2/extra/httpd_ssl.conf. Pour trouver le nom du fichier, cherchez la ligne Listen 443 ou mieux <VirtualHost _default_:443>. Éditez le fichier et ajoutez à la fin de l'hôte virtuel les lignes suivantes.

#
# HTTPS Proxy Server directives.
#
<IfModule mod_proxy.c>
    ProxyRequests Off       # Le proxy n'est accessible que pour les urls définies
    ProxyPreserveHost On    # Conserver le nom d'hôte d'origine dans la requête

        # Rediriger les requêtes sur /testapp vers http://192.168.42.42:8443/testapp
    ProxyPass    /testapp    http://192.168.42.42:8443/testapp
        # Ajuster les URL de la réponse HTTP du serveur Tomcat
    ProxyPassReverse /testapp http://192.168.42.42:8443/testapp
</IfModule>

Notez bien que cette fois j'ai utilisé le port 8443 pour les redirections.

Tomcat

Pour cette partie, il faut juste ajouter un connecteur sur le port 8443 au fichier server.xml de Tomcat.

<Connector port="8443" proxyPort="443" proxyName="192.168.42.42"/>

Vous pourrez affiner la configuration de ce connecteur plus tard. Pour le moment le principal est d'avoir notre accès en HTTPS.

Relancez Tomcat et Apache pour vous assurer que les modifications sont bien prises en compte. Testez l'accès à votre application web avec votre navigateur préféré https://localhost/testapp. Tout à l'air de fonctionner correctement, pourtant, vous vous rendrez vite compte que les redirections entre HTTP et HTTPS finissent en boucle de redirection infinie, n'aboutissent pas, bref ne fonctionnent pas. J'ai eu le problème avec le ChannelProcessingFilter de Spring Security, mais d'une manière générale, tout ce qui utilise la méthode HttpServletRequest.isSecure() sera affecté.

Forcer le retour de isSecure() à true

Premier piège à éviter, passer sur le connecteur du port 8443, l'attribut secure à true. Dans les faits cela pourrait marcher, car avec cet attribut, Tomcat forcera bien la valeur de la propriété secure de la requête. Le problème c'est qu'elle force aussi le chargement du driver SSL pour le connecteur. Attention, dans Tomcat 6 le problème semble avoir été corrigé.

Il existe à ma connaissance deux façons de modifier la requête avant qu'elle ne soit traitée par notre application. La première solution utilise une Valve Tomcat pour modifier la requête en fonction du port de destination (8080 ou 8443). La deuxième solution passe par la création d'un filtre HTTP dans l'application testapp.

Valve Tomcat

Les valves sont des filtres spécifiques de Tomcat fonctionnant en quelque sorte comme des filtres de servlet. Ils ont l'avantage d'être exécutés avant tous les processeurs de requêtes (filtre, servlet, ...) et d'être définis pour l'ensemble du serveur et non pour l'application web uniquement.

Nous allons donc créer une valve qui comparera le port de réception de la requête à un paramètre de configuration securePort. S'ils sont égaux, la valve passe secure à true et scheme à HTTPS pour que l'illusion soit parfaite. Elle finit en invoquant l'exécution du filtre suivant.

package fr.xebia.tomcat;

import java.io.IOException;
import java.util.logging.LogManager;
import java.util.logging.Logger;

import javax.servlet.ServletException;

import org.apache.catalina.connector.Request;
import org.apache.catalina.connector.Response;
import org.apache.catalina.valves.ValveBase;

/**
  * Valve passant les requetes sur le port {@link SecurePortValve#securePort}, en mode securise
  *
  * @author slm
  *
  */

public class SecurePortValve extends ValveBase {

    /**
      * Par defaut le port securise est le 8443
      */

    private int securePort = 8443;

    private static final Logger LOG = LogManager.getLogManager().getLogger(SecurePortValve.class.toString());

    public void setSecurePort(int securePort) {
        this.securePort = securePort;
    }

    public int getSecurePort() {
        return securePort;
    }

    /**
      * execution du filtre
      */

    public void invoke(Request req, Response resp) throws IOException, ServletException {
        LOG.info("Invocation de la valve pour securiser le port "
                 + securePort + " pour une requete sur le port " + req.getLocalPort());
        if (req.getLocalPort() == securePort) {
            req.setSecure(true);
            req.setScheme("https");
            LOG.info("La requete vient du port securise: Passage de secure a true");
        } else {
            LOG.info("La requete ne vient pas du port securise.");
        }

        if (getNext() != null) {
            getNext().invoke(req, resp);
        }
    }

}

Vous pouvez récupérer ici le projet maven et ici le jar que j'ai utilisé pour les tests. Copiez le jar dans le répertoire server/lib de Tomcat. Il faut maintenant ajouter la valve à la configuration du serveur. Éditez le fichier server.xml et insérez avec les autres Valve de la balise Engine, la ligne suivante.

<Valve className="fr.xebia.tomcat.SecurePortValve" securePort="8443"/>

Reste à croiser les doigts, redémarrer Tomcat et vérifier que cela fonctionne correctement. Notez que j'ai volontairement mis les logs en info dans le code pour avoir un retour rapide sans plus de configuration. Supprimez-les, ou passez-les en debug une fois les premiers tests réalisés avec succès.

ServletFilter

Pour cette solution il suffit de monter un filtre décrit dans le web.xml. Le filtre quand il sera exécuté, fera approximativement la même chose que la Valve, à savoir comparer le port de réception à une valeur de configuration securePort. Si les deux valeurs sont égales, notre filtre substituera la requête d'origine par un objet HttpServletRequestWrapper qui surchargera les méthodes isSecure() et getScheme(). L'avantage de cette technique est d'obtenir une solution portable qui ne s'appliquera qu'à l'application web sans nécessiter de reconfigurer le serveur Tomcat.

package fr.xebia.tomcat;

import java.io.IOException;
import java.util.logging.Level;
import java.util.logging.LogManager;
import java.util.logging.Logger;

import javax.servlet.Filter;
import javax.servlet.FilterChain;
import javax.servlet.FilterConfig;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletRequestWrapper;

/**
  * Filtre de servlet permettant de passer la requete en mode securise sur la base du port de réception.
  *
  * @author slm
  */

public class SecurePortFilter implements Filter {

    private static final String SECURE_PORT_PARAM_NAME = "securePort";

    private static final Logger LOG = LogManager.getLogManager().getLogger(SecurePortFilter.class.toString());

    private int securePort = 8443;

    public void destroy() {
    }

    /**
     * Filtrage des requetes pour passer en mode securise sur les requetes venant du port {@link SecurePortFilter#securePort}
     */

    public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException {

        if (req.getLocalPort() == securePort) {
            if (LOG.isLoggable(Level.FINEST)) {
                LOG.log(Level.FINEST, "La requete vient du port securise " + securePort
                        + ", on positionne secure a true et scheme a 'https'");
            }
            if (req instanceof HttpServletRequest) {
                req = new HttpServletRequestWrapper((HttpServletRequest) req) {

                    public boolean isSecure() {
                        return true;
                    }

                    public String getScheme() {
                        return "https";
                    }
                };
            }

        }

        chain.doFilter(req, resp);

    }

    /**
     * Chargement du parametre de configuration {@link SecurePortFilter#securePort}
     */

    public void init(FilterConfig config) throws ServletException {
        String paramValue = config.getInitParameter(SECURE_PORT_PARAM_NAME);
        if (paramValue != null) {
            try {
                securePort = Integer.parseInt(paramValue);
            } catch (NumberFormatException e) {
                LOG.log(Level.WARNING, "La valeur " + paramValue +
                        " du parametre d'initialisation " + SECURE_PORT_PARAM_NAME
                        + " n'est pas un entier valide !", e);
            }
        }
        LOG.info("Les requetes venant du port " + securePort + " seront securise");

    }
}

Notez bien le test réalisé avec l'opérateur instanceof. Le risque était de perdre des informations liées au type HttpServletRequest. Donc je teste le type avant de construire un HTTPServletRequestWrapper et d'invoquer le filtre suivant de la chaîne. Pour tout dire, j'avais des erreurs remontées par les OncePerRequestFilter de Spring que nous utilisons dans le projet pour gérer le chargement des locales notamment.

Si vous avez fait le test avec la SecurePortValve et que vous désirez maintenant utiliser le SecurePortFilter, pensez avant toute chose à commenter la ligne qui active son utilisation dans le server.xml. Intégrez ensuite le filtre à votre application. Vous pouvez copier le jar récupéré plus haut dans le répertoire WEB-INF/lib de votre application, le mettre en dépendance dans votre pom Maven ou simplement recopier le code dans vos sources. Il faut ensuite ajouter la configuration du filtre dans le web.xml. Attention, nous voulons que SecurePortFilter soit le premier filtre exécuté de la chaîne pour garantir que la requête sera bien sécurisée en amont de tous nos traitements. Éditez le web.xml et ajoutez avant le premier mapping de filtre les lignes suivantes.

<!-- [...] -->
    <filter>
        <filter-name>securePortFilter</filter-name>
        <filter-class>fr.xebia.tomcat.SecurePortFilter</filter-class>
        <init-param>
            <param-name>securePort</param-name>
            <param-value>8443</param-value>
        </init-param>
    </filter>
    <filter-mapping>
        <filter-name>securePortFilter</filter-name>
        <url-pattern>/*</url-pattern>
    </filter-mapping>
<!-- [...] -->

Redéployez l'application et vérifiez que tout fonctionne. A ce stade, vous avez une configuration complètement fonctionnelle en HTTP et en HTTPS avec le serveur Apache en frontal devant Tomcat.

Aller plus loin

Commencez par savourer votre succès, un peu d'auto-statisfaction ne fait pas de mal. Parmi les possibilités, vous pouvez déporter Apache ou Tomcat sur une autre machine, ou même utiliser des noms de contextes différents grâce au ProxyPassReverse d'Apache. Avec le mod_proxy_balancer vous pourrez aussi configurer Apache pour faire de la répartition de charge sur un cluster de serveurs Tomcat.

Mots-clefs :, , ,