Espace tutos dev’ & informatique 🐧

Filtrer les cookies first-party en Javascript

Publié le
Article sur la mise en place d'un filtrage en Javascript des cookies first-party non-autorisés

Introduction

La loi sur le stockage des données dans le navigateur (appelée aussi loi cookie) est claire : le temps ou les sites pouvaient enregistrer tout et n’importe quoi est révolu. Pour tout ce qui sort du strictement nécessaire pour le bon fonctionnement du site (on parle de cookies fonctionnels), il est nécessaire d’obtenir le consentement éclairé du visiteur avant de pouvoir lire et écrire les données dans le navigateur. Il est aussi nécessaire de pouvoir documenter le consentement, et de laisser la possibilité de le retirer. Les cookies, c’est pas du gâteau…

L’article d’aujourd’hui s’intéresse au blocage de l’écriture et de la lecture des cookies non-autorisés dans le navigateur du visiteur. En implémentant ce qui est proposé, on peut facilement n’autoriser que les cookies first-party nécessaires et consentis.

Note : comme la plupart des codes sources proposés sur le blog, le code présent dans cet article est compatible avec Internet Explorer à partir de la version 11.

Ce que l’on veut obtenir

En javascript côté visiteur, que cela soit depuis notre propre code ou depuis une librairie, les cookies first-party (ceux correspondant au site actuellement visité) peuvent être lus et écrit en passant par la propriété document.cookie. Voici deux exemples, l’un de lecture et l’autre d’écriture.

// Ecriture d'un cookie
document.cookie = "mon_cookie=1234";

// Lecture d'un cookie
var cookies = document.cookie.split(";");
var cookieValue = null;
cookies.forEach(function(cookie){
  var name = cookie.split("=")[0].trim();
  var value = cookie.split("=")[1];
  if(name == "mon_cookie") cookieValue = value;
});
console.log(cookieValue);

Notre objectif est de filtrer les accès à la propriété document.cookie, pour que :

  • Les scripts (les nôtres et ceux des librairies tierces) ne puissent écrire que des cookies autorisés
  • Les scripts ne puissent lire que des cookies autorisés

Le filtrage sera transparent : aucune exception ne sera levée en cas de cookie interdit, afin de réduire le risque de disfonctionnement du code tierce-partie. Les opérations de lecture ne renverront que des cookies autorisés, et les opérations d’écriture ne seront appliquées que si le cookie est autorisé.

// Ecriture : si mon_cookie est interdit, ne fait rien
document.cookie = "mon_cookie=1234";

// Lecture : n'inclut pas mon_cookie s'il est interdit
var cookies = document.cookie;

Cela sera un bon début pour nous mettre en conformité avec la législation en vigueur.

Implémentation pas à pas

Remplacer la propriété document.cookie

Dans un premier temps, il va nous falloir remplacer la propriété native du navigateur document.cookie par notre propre implémentation, qui fera office de proxy de filtrage. On utilisera pour cela deux fonctions :

  • La fonction Object.defineProperty() qui permet de redéfinir une propriété d’un objet. Elle nous servira à remplacer le getter (lecture) et le setter (écriture) de la propriété document.cookie par notre logique de filtrage
  • La fonction Object.getOwnPropertyDescriptor() qui permet de récupérer la définition d’une propriété existante d’un objet. Elle nous servira à récupérer le getter et le setter natifs du navigateur de la propriété document.cookie, afin de pouvoir lire et écrire les cookies une fois la validation effectuée

Bien que la propriété cookie soit lue depuis l’objet global document, elle est définie au niveau de son prototype Document.prototype. C’est donc sur lui que nous redéfinirons la propriété.

// Stockera la propriété document.cookie originale
var originalCookieProperty = null;

// Fonction de mise en place du remplacement de la propriété document.cookie
function installCookiesHook(){
  if(! originalCookieProperty)
    originalCookieProperty = Object.getOwnPropertyDescriptor(Document.prototype, "cookie");

  Object.defineProperty(Document.prototype, "cookie", {
    enumerable : true,
    configurable : true,
    get : getCookieFiltered,
    set : setCookieFiltered
  });
};

// Fonction d'annulation du remplacement de la propriété document.cookie
function uninstallCookiesHook(){
  if(originalCookieProperty){
    Object.defineProperty(Document.prototype, "cookie", originalCookieProperty);
    originalCookieProperty = null;
  }
};

La fonction installCookiesHook() remplace la propriété document.cookie par notre propre implémentation qui fait appel à deux fonctions qui vont être implémentées dans les sections suivantes, getCookieFiltered() et setCookieFiltered(). Elle sauvegarde la propriété document.cookie native du navigateur pour pouvoir y faire appel pour les cookies passant le filtrage, ainsi que pour pouvoir la restaurer si besoin.

La fonction uninstallCookiesHook() retire notre propriété document.cookie maison et restaure la sauvegarde de la propriété native du navigateur.

Filtrer la lecture de document.cookie

Notre propriété proxy étant en place, nous devons définir son getter et son setter. Commençons d’abord par la fonction de lecture, qui doit renvoyer une chaine contenant l’ensemble des cookies autorisés, sous la forme réglementaire :

cookie1=valeur1; […]; cookieN=valeurN

Il faut d’abord récupérer la chaine non-filtrée des cookies stockés dans le navigateur, pour cela on pourra appeler le getter de la propriété native document.cookie, dont nous avons sauvegardé la définition dans la section précédente. On pourra ensuite filtrer les cookies par leur nom.

// Fonction getter non-filtrée
function getCookieUnfiltered(){
  return originalCookieProperty.get.call(document);
}

// Fonction getter filtrée
function getCookieFiltered(){
  var unfilteredCookies = getCookieUnfiltered();

  var cookies = unfilteredCookies.split(";");
  var allowedCookies = [];
  for(var i = 0; i < cookies.length; i++){
    var cookie = cookies[i];
    var name = cookie.split("=")[0].trim();
    if(isCookieAllowed(name, unfilteredCookies)) allowedCookies.push(cookie);  
  }

  var filteredCookies = allowedCookies.join(";");
  return filteredCookies;
};

On définit d’abord une fonction getCookieUnfiltered() qui sert de raccourci pour appeler le getter de la propriété document.cookie native du navigateur. Cela permettra d’éviter d’avoir à tout ressaisir par la suite. Elle renvoie l’ensemble non-filtré des cookies.

On définit ensuite la fonction getCookieFiltered(), qui est le getter de notre propriété maison document.cookie. Elle récupère dans un premier temps la liste non-filtrée de tous les cookies grâce à la fonction définie précédemment. Elle filtre ensuite cette liste, en validant les noms des cookies auprès de la fonction isCookieAllowed() qui sera définie un peu plus bas. Elle renvoie ensuite une chaine ne contenant que les cookies qui ont passé la validation, dans le format attendu.

Filtrer la modification de document.cookie

Après le filtrage de la lecture, voici celui de l’écriture. On va définir une fonction getter qui va valider le nom du cookie passé en paramètre, et ne faire quelque chose que si ce dernier est autorisé. On ne peut écrire qu’un seul cookie à la fois, le format officiel attendu est le suivant :

nom=valeur

Comme pour la lecture, on utilisera la définition de la fonction native pour enregistrer le cookie une fois ce dernier validé.

// Fonction setter non-filtrée
function setCookieUnfiltered(value){
  return originalCookieProperty.set.call(document, value);
}

// Fonction setter filtrée
function setCookieFiltered(value){
  var name = value.split("=")[0].trim();

  if(! isCookieAllowed(name, getCookieUnfiltered()))
    return value;
  else
    return setCookieUnfiltered(value);
};

Comme précédemment, on définit d’abord une fonction non-filtrée pour appeler facilement le setter natif de la propriété document.cookie. Il s’agit de setCookieUnfiltered().

On crée ensuite la fonction setCookieFiltered(), qui jouera le rôle de setter de notre propriété maison document.cookie. Elle récupère le nom du cookie et le valide auprès de la fonction isCookieAllowed() qui sera définie dans la section suivante. Si la validation réussit, alors le setter de la propriété native du navigateur est appelé pour enregistrer le cookie, sinon on ne fait rien et le cookie sera silencieusement ignoré.

Définir des règles de filtrage

Le filtrage des cookies peut s’effectuer selon une logique de whitelist (seuls les cookies présents en liste blanche seront autorisés, tous les autres seront interdits), ou de blacklist (tous les cookies seront autorisés sauf ceux présents en liste noire).

Dans la pratique, une fois le code du site ou de l’application stable et en production, on choisira souvent de whitelister les cookies connus (et autorisés par le visiteur dans le cas des cookie de tracking), et de blacklister tous les autres.

Evidemment, en cas d’évolution de composants externes au site ou à l’application (librairies, gestionnaires de tags, analytics) qui rajouteraient des cookies, ces derniers se retrouveraient bloqués. Il est donc important de se tenir à l’écoute des changements, et de revoir les listes si nécessaire.

Quoi qu’il en soit, il s’agit d’une logique spécifique métier, il n’y a pas de bon ou de mauvais choix. Voici un exemple d’implémentation de filtrage :

// Règles de filtrage des cookies :
// - On laisse passer les cookies WordPress
// - On laisse passer le cookie de consentement
// - Si le cookie de consentement est présent, on laisse passer les cookies Matomo
// - On bloque tous les autres cookies
function isCookieAllowed(name, unfilteredCookies){
  if( name.match(/^wordpress_/)
   || name.match(/^wp-settings-/)
   || name.match(/^comment_author_/) ) return true;

  if( name == "consentement_tracking" ) return true;

  if( unfilteredCookies.match(/consentement_tracking/) ){
    if( name == "_pk_id"
     || name == "_pk_ref"
     || name == "_pk_ses"
     || name == "_pk_cvar"
     || name == "_pk_hsr" ) return true;
  }
  
  return false;
};

La fonction isCookieAllowed() reçoit en paramètre le nom du cookie et doit renvoyer true si le cookie est autorisé, et false sinon. Elle reçoit également la valeur non-filtrée de document.cookie, afin de pouvoir tester la présence de certains cookies (ex : consentement visiteur au tracking) pour prendre sa décision.
Elle ne doit pas faire appel directement à document.cookie, car cela causerait une boucle infinie.

Dans l’exemple elle valide :

  • Les cookies commençant par wordpress_, par wp-settings-, ou par comment_author_, qui sont des standards sur le CMS WordPress.
  • Le cookie s’appelant exactement consentement_tracking, cookie fonctionnel symbolisant l’acceptation des cookies de tracking par le visiteur
  • Les cookies _pk_id, _pk_ref, _pk_ses, _pk_cvar et _pk_hsr, qui sont des cookies de tracking Matomo, uniquement si l’utilisateur a donné son consentement (présence du cookie consentement_tracking)

Tous les autres cookies sont considérés invalides.

Activation et désactivation du filtrage

A ce point nous n’avons plus qu’à activer le filtrage avec installCookiesHook(), et éventuellement le désactiver avec uninstallCookiesHook(). Attention à ce que la fonction isCookieAllowed() soit bien définie au moment de l’appel cependant.

// Activation du filtrage le plus tôt possible (les règles doivent être en place)
installCookiesHook();

// VOTRE CODE ICI

// Désactivation du filtrage (optionnel)
uninstallCookiesHook();

Il est plutôt rare de devoir désactiver le filtrage dans la pratique. Il s’agit en général d’une décision d’urgence prise en attendant un correctif, causée par la nécessité de supporter des cookies non-prévus lors du développement initial.

Tests

On peut tester notre code pour vérifier qu’il fait bien son travail. Il ne s’agit pas d’un test unitaire, mais ca nous donnera quand même une idée du bon fonctionnement.

// Test simple :
// - On active le filtrage
// - On écrit un cookie autorisé et un cookie bloqué
// - On lit les cookies
// - On vérifie que tout s'est passé comme prévu

installCookiesHook();

document.cookie = "wordpress_test_cookie=1234";
document.cookie = "mechant_cookie=1234";

console.log(document.cookie.match(/wordpress_test_cookie/) ? "Le cookie WordPress est passé" : "Le cookie WordPress a été bloqué");
console.log(document.cookie.match(/mechant_cookie/) ? "Le méchant cookie est passé" : "Le méchant cookie a été bloqué");

uninstallCookiesHook();

Le cas du consentement peut être testé aussi :

// Test consentement :
// - Avec filtrage, on écrit un cookie de tracking Matomo
// - On vérifie qu'il n'a pas été écrit
// - Avec filtrage, on écrit le cookie de consentement et celui de tracking
// - On vérifie que tout s'est passé comme prévu

installCookiesHook()

document.cookie = "_pk_id=abcd";

console.log(document.cookie.match(/_pk_id/) ? "Le cookie Matomo non-consenti est passé" : "Le cookie Matomo non-consenti a été bloqué");

document.cookie = "consentement_tracking=1";
document.cookie = "_pk_id=abcd";

console.log(document.cookie.match(/consentement_tracking/) ? "Le cookie de consentement est passé" : "Le cookie de consentement a été bloqué");
console.log(document.cookie.match(/_pk_id/) ? "Le cookie Matomo consenti est passé" : "Le cookie Matomo consenti a été bloqué");

uninstallCookiesHook();

Le code complet est disponible plus bas.

Pour aller plus loin

Cookies first-party VS third-party

En général, la propriété document.cookie n’inclut que les cookies first-party (ceux associés au site actuellement visité). On ne pourra donc pas bloquer les cookies third-party (ceux associés à un autre site à partir duquel le site d’origine récupère une ressource : image de tracking…) par ce biais.

Cela ne devrait pas être un problème, car ces cookies n’ont plus le vent en poupe : les navigateurs bloquent les cookies third-party de manière générale (Safari, Brave) ou en mode navigation privée (Firefox). Quant à Chrome, il ne les supportera plus à partir de mi-2023. En conséquence, leur utilisation est réduite, au profit des cookies first-party. Par exemple, Google Analytics n’utilise que des cookies first-party.

Les limitations du javascript

Notre champ d’action est ici limité à la propriété document.cookie. C’est suffisant pour bloquer chaque étape du processus de tracking le plus fréquent :

  1. Créer un identifiant unique de visiteur, le stocker dans un cookie first-party (filtrage de l’écriture)
  2. Inscrire d’autres identifiants dans des cookies, tels que des identifiants de campagne ou de provenance du visiteur (filtrage de l’écriture)
  3. Récupérer les valeurs enregistrées dans les cookies et les insérer dans des paramètres de requête GET ou POST quand des appels au serveur de tracking ont lieu (filtrage de la lecture)

Les cookies peuvent cependant aussi être créés côté serveur, hors de portée de notre code javascript. Par exemple, un cookie peut être déployé lors du chargement de la page par le visiteur (via un entête Set-Cookie), via une balise d’image de tracking, via une requête AJAX ou un appel à la fonction fetch()

De plus, si un cookie existe déjà, alors le navigateur l’enverra automatiquement lors des requêtes sans passer par le filtrage de document.cookie. On pourrait envisager de nettoyer régulièrement les cookies, par exemple au chargement de la page et lors d’une requête AJAX, pour mitiger le problème.

Loi cookies, mais pas que !

Celle qui est appelée la « loi cookies » porte plutôt mal son surnom. En effet, sa portée ne se limite pas aux cookies, elle inclut toute forme de stockage sur le navigateur, par exemple le session storage et le local storage.

La logique développée ici peut être assez simplement adaptée aux autres mécanismes de stockage. Après tout, rien ne nous empêche de définir des proxys de filtrage transparents pour les fonctions localStorage.setItem() et localStorage.getItem() par exemple.

On notera que les cookies restent quand même le mécanisme le plus utilisé en général. Contrairement aux autres, il est facilement accessible à la fois côté navigateur et côté serveur, et reste compatible avec les navigateurs plus anciens.

Le code complet

Voici le code complet développé dans cet article. Il peut être copié-collé et exécuté directement dans une console javascript de navigateur.
Rendez-vous auparavant en navigation privée sur une page web neutre, par exemple le site example.com, pour ne pas fausser les tests (cookies déjà existants, filtrage déjà actif, etc.). N’oubliez pas qu’en local (adresses file://) les cookies ne fonctionnent pas.

(function(){

"use strict";

/**
 * Code générique
 */

// Stockera la propriété document.cookie originale
var originalCookieProperty = null;

// Fonction de mise en place du remplacement de la propriété document.cookie
function installCookiesHook(){
  if(! originalCookieProperty)
    originalCookieProperty = Object.getOwnPropertyDescriptor(Document.prototype, "cookie");

  Object.defineProperty(Document.prototype, "cookie", {
    enumerable : true,
    configurable : true,
    get : getCookieFiltered,
    set : setCookieFiltered
  });
};

// Fonction d'annulation du remplacement de la propriété document.cookie
function uninstallCookiesHook(){
  if(originalCookieProperty){
    Object.defineProperty(Document.prototype, "cookie", originalCookieProperty);
    originalCookieProperty = null;
  }
};

// Fonction getter non-filtrée
function getCookieUnfiltered(){
  return originalCookieProperty.get.call(document);
}

// Fonction getter filtrée
function getCookieFiltered(){
  var unfilteredCookies = getCookieUnfiltered();

  var cookies = unfilteredCookies.split(";");
  var allowedCookies = [];
  for(var i = 0; i < cookies.length; i++){
    var cookie = cookies[i];
    var name = cookie.split("=")[0].trim();
    if(isCookieAllowed(name, unfilteredCookies)) allowedCookies.push(cookie);  
  }

  var filteredCookies = allowedCookies.join(";");
  return filteredCookies;
};

// Fonction setter non-filtrée
function setCookieUnfiltered(value){
  return originalCookieProperty.set.call(document, value);
}

// Fonction setter filtrée
function setCookieFiltered(value){
  var name = value.split("=")[0].trim();

  if(! isCookieAllowed(name, getCookieUnfiltered()))
    return value;
  else
    return setCookieUnfiltered(value);
};


/**
 * Code métier à personnaliser
 */

// Règles de filtrage des cookies :
// - On laisse passer les cookies WordPress
// - On laisse passer le cookie de consentement
// - Si le cookie de consentement est présent, on laisse passer les cookies Matomo
// - On bloque tous les autres cookies
function isCookieAllowed(name, unfilteredCookies){
  if( name.match(/^wordpress_/)
   || name.match(/^wp-settings-/)
   || name.match(/^comment_author_/) ) return true;

  if( name == "consentement_tracking" ) return true;

  if( unfilteredCookies.match(/consentement_tracking/) ){
    if( name == "_pk_id"
     || name == "_pk_ref"
     || name == "_pk_ses"
     || name == "_pk_cvar"
     || name == "_pk_hsr" ) return true;
  }
  
  return false;
};

// Activation du filtrage le plus tôt possible (les règles doivent être en place)
installCookiesHook();

// VOTRE CODE ICI

// Désactivation du filtrage (optionnel)
uninstallCookiesHook();


/**
 * Tests
 */

// Test simple :
// - On active le filtrage
// - On écrit un cookie autorisé et un cookie bloqué
// - On lit les cookies
// - On vérifie que tout s'est passé comme prévu

installCookiesHook();

document.cookie = "wordpress_test_cookie=1234";
document.cookie = "mechant_cookie=1234";

console.log(document.cookie.match(/wordpress_test_cookie/) ? "Le cookie WordPress est passé" : "Le cookie WordPress a été bloqué");
console.log(document.cookie.match(/mechant_cookie/) ? "Le méchant cookie est passé" : "Le méchant cookie a été bloqué");

uninstallCookiesHook();

// Test consentement :
// - Avec filtrage, on écrit un cookie de tracking Matomo
// - On vérifie qu'il n'a pas été écrit
// - Avec filtrage, on écrit le cookie de consentement et celui de tracking
// - On vérifie que tout s'est passé comme prévu

installCookiesHook()

document.cookie = "_pk_id=abcd";

console.log(document.cookie.match(/_pk_id/) ? "Le cookie Matomo non-consenti est passé" : "Le cookie Matomo non-consenti a été bloqué");

document.cookie = "consentement_tracking=1";
document.cookie = "_pk_id=abcd";

console.log(document.cookie.match(/consentement_tracking/) ? "Le cookie de consentement est passé" : "Le cookie de consentement a été bloqué");
console.log(document.cookie.match(/_pk_id/) ? "Le cookie Matomo consenti est passé" : "Le cookie Matomo consenti a été bloqué");

uninstallCookiesHook();

})();

Conclusion

Voila, nous avons vu comment filtrer la création et la lecture des cookies first-party en javascript. Cela n’est pas une garantie certaine de sécurité ou de respect de la vie privée, mais c’est tout de même un pas vers le respect de la législation, et un web plus responsable.

Bon code à vous, à la prochaine !

Références

CNIL – Ce que dit la loi sur les cookies et les traceurs
CookieStatus – Différences du support des cookies dans les navigateurs
Mozilla Blog – Total Cookie Protection
Mozilla Developers – Document.cookie
Mozilla Developers – Object.defineProperty()
Mozilla Developers – Object.getOwnPropertyDescriptor()

Photo de profil de Loïc Burtin Code Colibri développeur web à Dijon

A propos de l'auteur

Loïc Burtin

Développeur web indépendant, avec une tendance à couper les plumes en quatre pour que les sites se chargent plus rapidement.

Partagez cet article  =>