Content Security Policy (CSP) est un entête HTTP (la version sous forme d'attribut dans la balise XHTML « meta » est incomplète) qui permet, à un site web, de fournir, à un navigateur web, des instructions portant sur l'origine des contenus de ce site web. Exemple : "le seul javascript que tu peux exécuter sur ce site web doit provenir de https://jscdn.example". Cela fonctionne avec une politique par défaut couplée à une liste blanche des contenus autorisés à être récupérés. Cela va très loin puisque ça permet d'indiquer l'origine des scripts javascript, des images, des polices de caractère, la destination d'un formulaire web, les pages qui peuvent être incluses dans une balise XTML frame, etc.
Le but de tout cela est d'empêcher le navigateur web du visiteur d'un site web d'inclure et de charger des contenus malveillants, notamment des scripts javascript qui peuvent ensuite servir à tromper l'utilisateur d'un site web, à voler des données (dont des identifiants), à usurper un contenu, etc.
Évidemment, les autorisations sont transitives : si un script JS autorisé tente de charger un JS pas autorisé dans la politique, ça ne fonctionnera pas. De même, la politique est appliquée après la modification du DOM par un script JS, donc il est inutile de tenter de modifier une balise avec du JS autorisé dans l'intention de charger un script JS non autorisé, par exemple.
Comme tout entête, il peut être positionné par le site web (via la fonction header()
de PHP, par exemple) et par le serveur web (module headers d'Apache httpd, par exemple). Il y a un ordre (source) : un entête positionné par une application PHP peut être écrasé par un entête positionné dans la configuration principale du serveur web, qui peut lui-même être supplanté par un entête positionné dans la configuration déportée du serveur web (dite htaccess). Évidemment, il y a des pièges (nous en observerons un plus bas)…
Par défaut, en l'absence d'un entête CSP, les navigateurs web appliquent aucune politique : tout contenu peut être téléchargé depuis n'importe où et utilisé. Évidemment, ça ne veut pas dire qu'une ressource, notamment un script Javascript, peut accéder à n'importe quelle ressource distante, car un autre mécanisme, Cross-origin resource sharing (CORS), empêche cela, j'y reviendrai à la fin de ce shaarli.
L'interprétation de cet entête par les navigateurs web est plutôt strict, donc dès que l'on commence à l'utiliser, il vaut mieux avoir doublement vérifié toute les ressources téléchargées directement ou indirectement sur le site web. Exemple : par défaut, CSP bloque l'exécution du Javascript inline (c'est-à-dire positionné dans la balise XHTML « script ») et l'utilisation de la fonction Javascript « eval() ». Néanmoins, un mécanisme de rapport existe : un navigateur web qui a bloqué du contenu à cause d'une CSP peut le signaler en envoyant un blob JSON à un formulaire web indiqué dans la CSP.
D'un côté, nous avons une application web Java (Tomcat) derrière des frontaux Apache httpd qui effectue une authentification centralisée (Single Sign-On, SSO). Convenons qu'elle est accessible par une seule URL : https://sso.example/ .
De l'autre côté, nous avons un portail internet Java (Tomcat) derrière des frontaux Apache httpd qui inclu (avec une balise XHTML « iframe ») la plupart des sites web que nous mettons à la disposition de notre communauté. Convenons qu'elle est accessible par une seule URL : https://portailweb.example/ .
Il faut être identifié et authentifié (par le SSO) sur le portail web pour que les onglets "contenant" les sites web apparaissent (car on ne présente pas les mêmes à tous nos utilisateurs). Cette authentification n'est pas transitive : le site web inclus ne sait pas si un utilisateur est authentifié sur le portail ou non, il réinterroge toujours le SSO. Nous faisons cela car, d'un point de vue fonctionnel, la plupart des sites web inclus doivent pouvoir être appelés directement (en dehors du portail). Si nous utilisions le portail web comme un proxy SSO, alors les sites web ne pourraient plus être consultés en dehors du portail.
Or, la durée de conservation du ticket délivré par le SSO est variable en fonction des sites web, ce n'est pas le SSO qui peut l'imposer aux applications (enfin si, mais la marge est grande). Donc, il est possible que le portail web continue à considérer qu'un utilisateur est connecté alors que le SSO ne le considère plus de son côté. Ainsi, quand le site web inclus dans le portail interrogera le SSO, celui-ci l'informera que l'utilisateur n'est plus authentifié. Ce site web inclus redirigera alors l'utilisateur vers notre SSO. On aura donc le SSO inclus dans notre portail web.
Voyons comment configurer nos frontaux Apache httpd pour tenir compte de tout ça.
Ces deux entêtes HTTP permettent d'indiquer à un navigateur web qu'un site web peut être inclus (ou non) dans un autre. Elles permettent d'éviter les attaques de type clickjacking genre j'inclus, sur mon site web, le formulaire de ton site web qui octroie des privilèges à un compte utilisateur et je le camoufle derrière du XHTMl/CSS qui te promet ceci ou cela… Tu cliques, comme t'es identifié sur ton site web, ça passe et tu octroies implicitement des droits au compte que j'ai désigné dans la requête. Suite logique des attaques CSRF, attaques que l'on contre facilement avec un token stocké dans la session…
Comme le laisse deviner son nom préfixé par « X- » (pour expérimental), X-Frame-Options est un entête déprécié qui a été remplacé par l'attribut « frame-ancestors » dans la deuxième version de CSP. Source : la norme CSPv2.
Évidemment, tous les navigateurs ne prennent pas en charge tous les entêtes ni tous les attributs et valeurs pour chaque entête. Les navigateurs "récents" (Firefox >= 36, Chrome >= 39, Safari >= 10, Edge >= 15) prennent en charge CSP frame-ancestors et X-Frame-Options. Les "vieux" navigateurs (IE, Edge >= 12, Firefox >= 18) implémentent uniquement X-Frame-Options. Notons que même les versions récentes de Safari, Chrome et Opera ne prennent pas en charge la valeur « ALLOW-FROM » de X-Frame-Options. Sources : 1, 2.
Lors de la résolution des problèmes évoqués ci-dessous, il m'avait semblé qu'il fallait les deux entêtes pour couvrir fonctionnellement notre parc, notamment les machines qui avaient un Firefox pas à jour. En rédigeant ce qui précède des mois après, je suis beaucoup plus sceptique : je pense que nous devrions pouvoir cesser notre utilisation de X-Frame-Options sans impact sur nos utilisateurs, car nous n'avons pas d'aussi vieilles versions de Firefox en circulation et que X-Frames-Options tel que nous l'utilisons (avec l'attribut « ALLOW-FROM ») n'est pas prise en charge par Safari, Opera et Chrome. Donc ça sert uniquement pour IE et les versions mobiles de navigateurs, lesquels ne sont pas dans notre périmètre.
Tu l'as compris : la suite de ce shaarli va traiter de la mise en pratique de CSP uniquement sous l'angle d'un seul de ses attributs, frame-ancestors.
Une dernière note : en cas d'inclusions multiples (site 1 inclus site 2 qui inclus site 3), les navigateurs web, comme la norme le leur permet, peuvent interpréter différemment la somme des CSP (celle du site 3 puis du site 2 puis du site 1) et la comparaison de l'origine (faut-il comparer site 3 avec site 1 ou site 3 avec site 2 ou… ?).
Par défaut, il était impossible d'intégrer notre SSO à tout site web. Nous croyions que c'était à cause d'une interprétation stricte des navigateurs web de l'absence de CSP/X-Frame-Options. Nous verrons, dans le point suivant, que ce n'est pas ça du tout.
Pour pallier à ça, voici le bout de conf' que l'on avait ajouté dans la configuration du virtualhost de nos frontaux SSO :
Header always set X-Frame-Options "SAMEORIGIN"
Header always set Content-Security-Policy "frame-ancestors 'self' *.example"
On notera que ces deux entêtes ne signifient pas la même chose. La première, pour les vieux navigateurs, indique que seul notre SSO peut s'inclure lui-même (nous verrons plus bas que ce n'est pas ce que nous voulons). La deuxième, pour les navigateurs web récents, signifie que notre SSO peut s'inclure lui-même ou être inclus par tout site web dont le nom termine par « example » (portailweb.example, autresiteweb.example, etc.). Du coup, le comportement sera différent entre les "vieux" et les "jeunes" navigateurs web. Mais c'est dans cet état que j'ai repris le flambeau.
Le premier problème a été un navigateur web Safari qui indiquait ce qui suit dans sa console lorsqu'on consultait notre portail web :
Multiple 'X-Frame-Options' headers with conflicting values ('SAMEORIGIN, DENY') encountered when loading 'https://sso.example/'. Falling back to 'DENY'.
Refused to display 'https://sso.example/' in a frame because it set 'X-Frame-Options' to 'SAMEORIGIN, DENY'.
Comme nous venons de le décrire, nous avions pourtant un unique « Header always set X-Frame-Options "SAMEORIGIN" » dans la configuration de nos Apache httpd. D'où peut donc bien venir ce « DENY » ?!
Le code de notre SSO positionne lui aussi cet entête avec la valeur « DENY » (on observe cela en capturant le trafic réseau entre le frontal web et le SSO). Cet entête est conservé par le mod_proxy_ajp d'Apache httpd (ce qui est normal). Notre bout de configuration ne l'écrase pas car le mod_proxy (ajp ou http) utilise la table globale pour stocker les entêtes HTTP récupérés depuis le backend (c'est par ici pour les explications). Or, la directive que nous avons ajoutée indique de positionner l'entête dans la table « always ». Oui, c'est un comportement inverse à celui du mod_fcgi_proxy utilisé avec PHP-FPM qui, lui, peuple la table always et non la table par défaut…
Pour résoudre ce problème, soit on utilise le bout de configuration suivant :
Header set X-Frame-Options "SAMEORIGIN"
Soit le bout de conf' suivant qui a l'avantage de laisser moins de marge d'appréciation :
Header unset X-Frame-Options
Header always set X-Frame-Options "SAMEORIGIN"
Header always set Content-Security-Policy "frame-ancestors 'self' *.example"
Notons que nos navigateurs, plus récents, utilisent CSP frame-ancestors et ignorent donc X-Frame-Options conformément à la norme, ce qui fait que nous n'avions pas identifié ce problème avant qu'un utilisateur se ramène avec son Mac + Safari.
Quand nous nous rendons sur notre portail web, la console de développement des anciens navigateurs web, ceux qui ne prennent pas en charge CSP frame-ancestors, affiche :
Refused to display 'https://sso.example' in a frame because it set 'X-Frame-Options' to 'SAMEORIGIN'.
Dans les normes du web, une origine, c'est un protocole d'accès (http, https, etc.) + un nom d'hôte (sso.example) + un port. Ainsi, https://portailweb.example:443 n'a pas la même origine que https://sso.example:443 car le nom d'hôte est différent. Même chose avec http://portailweb.example:443 et https://portailweb.example:443 (le protocole change) ainsi qu'avec https://portailweb.example:443 et https://portailweb.example:8443 (le port change).
OK, indiquons clairement que notre portail web peut inclure notre SSO. Changeons la config' du virtualhost du frontal de notre SSO pour celle-ci :
Header unset X-Frame-Options
Header always set X-Frame-Options "ALLOW-FROM https://portailweb.example"
Header always set Content-Security-Policy "frame-ancestors 'self' *.example"
Cela fonctionne. \o/
Ce qui devait arriver est arrivé… Notre SSO doit aussi être inclus par un autre site web que notre portail web.
L'ennui, c'est que, contrairement à CSP, « X-Frame-Options » peut contenir un et un seul « ALLOW-FROM », et ne peut pas contenir un joker (« *.example »), donc, in fine, un seul site web peut être autorisé.
On ne va quand même pas utiliser la valeur « ALLOWALL », sinon tout site web pourrait inclure notre SSO, bonjour l'absence de sécurité…
On ne peut pas renoncer à l'une ou à l'autre des inclusions (c'est-à-dire recoder l'un des sites web afin de se passer des iframes) dans un délai raisonnable…
Le module headers d'Apache httpd permet d'ajouter des entêtes de manière conditionnelle, en fonction de l'existence (ou non) d'une variable d'environnement ou d'une expression rationnelle (qui peut porter sur la valeur d'une variable d'environnement). Parmi la liste des variables d'environnement disponibles, « HTTP_REFERER » retient mon attention.
Si le navigateur web charge notre SSO à partir de notre portail web, alors le referer aura « […]portailweb.example[…] » pour valeur. S'il charge le SSO depuis l'autre site, le referer sera différent. On n'a plus qu'à insérer un entête « X-Frame-Options » en fonction de la situation.
Changeons encore une fois la config' du frontal de notre SSO :
Header unset X-Frame-Options
Header always set X-Frame-Options "ALLOW-FROM https://portailweb.example" "expr=%{HTTP_REFERER} =~ m#portailweb.example#"
Header always set X-Frame-Options "ALLOW-FROM https://autresiteweb.example" "expr=%{HTTP_REFERER} =~ m#autresiteweb.example#"
Header always set Content-Security-Policy "frame-ancestors 'self' *.example"
Cela fonctionne. \o/
Notons que cela fonctionne partiellement avec les extensions de navigateur qui bidouillent le referer afin de préserver la vie privée. Si l'extension est configurée pour effacer totalement le referer, alors notre SSO ne sera pas chargé. Si elle est configurée pour tronquer le referer afin de conserver uniquement le domaine (et d'effacer le reste de l'URL), notre SSO sera chargé. La première configuration est peu courante, car elle casse beaucoup de sites web, donc nous ne la supportons pas. À l'impossible nul n'est tenu.
L'application web shaarli peut être utilisée avec un bookmarlet, c'est-à-dire un bouton qui déclenche un bout de Javascript qui ouvre un pop-up contenant la page "ajout d'un lien" de shaarli et qui y pré-rempli certains champs (titre, URL, etc.).
Avec Firefox, mon bookmarklet ne fonctionne plus sur certains sites web (Github, par exemple) depuis quelques années, c'est-à-dire depuis que Debian empaquette une version de Firefox qui implémente CSP et/ou depuis que la CSP de ces sites web est devenue bloquante.
Je l'ai écrit dans l'introduction de ce shaarli : par défaut, CSP bloque le javascript inline. Or, le bookmarklet shaarli n'est rien de plus qu'un bout de Javascript exécuté dans le contexte d'exécution d'un site web…
Si j'installe un reverse proxy Apache httpd entre mon Firefox et Github, que je le configure pour remplacer l'attribut « script-src github.githubassets.com; » contenue dans la CSP de Github par « script-src 'unsafe-inline' github.githubassets.com; » (toujours avec le module headers), le bookmarlet shaarli fonctionne.
C'est un problème connu. La norme impose que les bookmarklets ne soient pas affectés par le traitement de CSP. Visiblement, Firefox n'avance pas sur ce sujet.
Une extension Firefox qui remplace le bookmarklet existe… Mais pouvons-nous avoir confiance en Aeris ? :))))
Par défaut, un certain nombre d'objets web ne peuvent pas demander à un navigateur web d'émettre des requêtes vers une ressource distante qui n'a pas la même origine (même protocole, même domaine, même port, voir ci-dessus pour plus d'infos) qu'eux. C'est le cas pour les API XMLHttpRequest (AJAX) et Fetch, les polices chargées avec @font-face en CSS, les textures WebGL, les canevas HTML5, etc. Liste complète chez Mozilla. La politique par défaut des navigateurs web est donc « même origine ». Les autres objets (une image, une vidéo, une feuille CSS, une inclusion, etc.) ne sont pas concernés, elles ne sont pas bloquées par défaut.
HTTP Cross-Origin Resource Sharing (CORS) est un ensemble d'entêtes HTTP, de la forme « Origin: » et « Access-Control-* », qui permettent de débrayer ce comportement et d'autoriser un navigateur web qui exécute un objet récupéré sur un site web A à émettre des requêtes destinées à un site web B. Évidemment, c'est le site web qui veut être accessible, le site B dans mon exemple, qui doit positionner des entêtes CORS autorisant le site web A ou tous les sites web.
Cela est utile dans des contextes ou des données sont exposées par des API qui peuvent être consultées en temps réel (XMLHttpRequest). Dans ces contextes, les données sont rarement sur le même domaine que le code qui les formate et les présente (architecture frontend / backend, fournisseurs différents, agrégation de fournisseurs de données, etc.).
Exemple : positionner l'entête « Access-Control-Allow-Origin: * » sur ton site web autorise d'éventuels objets XMLHttpRequest placé sur tout site web à émettre des requêtes destinées à ton site web. Évidemment, on peut préciser une seule origine avec « Access-Control-Allow-Origin: https://front.example.com » + « Vary: Origin ». On a le droit qu'à un seul domaine, mais on peut le faire varier en fonction du header « Origin » inséré par le navigateur web à l'aide de l'insertion conditionnelle d'un entête, comme nous l'avons vu précédemment.
Notons qu'il existe deux types de requêtes XMLHttpRequest / Fetch : des requêtes simples / principales contenant des données utilisateur et des requêtes préliminaires qui ne contiennent pas de données utilisateurs mais qui servent à interroger le serveur web sur les méthodes HTTP (GET, POST, etc.), les types de contenus (application/x-www-form-urlencoded, etc.) et les entêtes qu'il supporte. Les requêtes préliminaires sont obligatoires quand la requête principale utilise des méthodes, des types de contenus et des entêtes qui ne sont pas dans la norme de l'API Fetch. Elles sont générées automatiquement par le navigateur web avant l'émission de la requête principale codée par le développeur de l'application web.
Pour plus d'informations, je recommande la lecture de l'excellente page de Mozilla : Cross-origin resource sharing (CORS) - HTTP | MDN.