Tous les articles

Comment les endpoints JSONP contournent votre CSP

CentralCSP Team ·

Dernière mise à jour:

Une politique de sécurité du contenu (CSP) qui allowliste un host tiers peut se retourner contre vous. Si ce host sert un endpoint JSONP, un attaquant capable d'injecter une balise <script> dans votre page peut exécuter du JavaScript arbitraire, et le navigateur l'autorise parce que le host est sur votre liste. La politique que vous aviez ajoutée pour bloquer le cross-site scripting (XSS) finit par laisser passer l'attaque.

La version courte : les allowlists de hosts et de schémas sont le point faible de la CSP. Le correctif consiste à ne plus faire confiance aux hosts pour les scripts, mais aux nonces, avec 'nonce-...' et 'strict-dynamic'. Le reste de cet article montre comment fonctionne JSONP, comment le contournement se déroule précisément, et comment abandonner les allowlists sans casser votre site.

Vous découvrez ce header ? Démarrer avec la Content Security Policy couvre d'abord les bases.

Qu'est-ce que JSONP ?

JSONP (« JSON with padding ») est une ancienne façon de récupérer des données entre origines. Elle fonctionne parce que l'élément HTML <script> n'est pas soumis à la same-origin policy : un src de script peut pointer vers n'importe quel domaine, et le navigateur exécute le JavaScript qui revient.

Le client et le serveur s'accordent sur un paramètre de requête, par convention callback ou jsonp. Le serveur place cette valeur en tête de sa réponse et enveloppe le JSON dans un appel de fonction, pour que toute la réponse forme un programme JavaScript valide. La page ajoute alors dynamiquement un <script> dont le src combine l'endpoint et le nom de callback :

// The client adds a script that points at a cross-origin endpoint
const script = document.createElement('script');
script.src = 'https://cdn.example.com/api?callback=handleData';
document.body.appendChild(script);

// The function the server will call back into
function handleData(data) {
  console.log(data.name);
}

Le serveur répond avec le nom de callback enveloppé autour des données, et le navigateur l'exécute :

handleData({"name": "John"})

Concrètement, la requête et la réponse donnent ceci :

GET https://cdn.example.com/api?callback=handleData

handleData({"name":"John"})

Les données n'arrivent qu'en exécutant une fonction dont le serveur choisit le nom. C'est ce détail qui rend JSONP dangereux.

Comment un endpoint JSONP contourne la CSP

Imaginez une politique typique. Vous allowlistez votre propre origine ainsi qu'un CDN de confiance :

Content-Security-Policy:
  default-src 'self';
  script-src 'self' https://cdn.example.com;

La directive script-src dit que les scripts peuvent se charger depuis votre origine et depuis cdn.example.com. Cela semble raisonnable. Le navigateur bloquera tout script venant d'un autre host.

Supposons maintenant que cdn.example.com expose un endpoint JSONP, et qu'un attaquant a déjà un pied dans la place grâce à une injection HTML sur votre page (un point d'entrée XSS réfléchi ou stocké qui n'a pas encore mené à une exécution de script, justement à cause de votre politique). L'attaquant injecte une balise de script qui pointe vers le host allowlisté :

<script src="https://cdn.example.com/api?callback=alert(document.cookie)//"></script>

L'endpoint reflète la valeur de callback au début de son corps de réponse, généralement sans la nettoyer. La réponse devient donc du JavaScript contrôlé par l'attaquant, servi depuis un host auquel vous faites confiance :

alert(document.cookie)//({"name":"John"})

Le // final commente le reste de la ligne pour que ce qui suit ne provoque pas d'erreur de syntaxe. Le navigateur l'exécute. La CSP ne bronche pas : la requête venait d'un host allowlisté. La politique est respectée et l'attaque réussit.

L'OWASP énonce le test sans détour : quand une politique allowliste des domaines tiers comme des CDN, vérifiez si ces domaines exposent des endpoints JSONP ou du contenu contrôlé par l'utilisateur, car un attaquant peut se servir de l'injection de callback pour exécuter du JavaScript arbitraire tout en respectant la politique.

Une réserve mérite d'être posée clairement. Beaucoup d'endpoints JSONP restreignent le callback aux caractères valides d'un identifiant JavaScript, ce qui bloque les charges les plus simples comme celle ci-dessus. Le vrai risque vient des endpoints dont la validation de callback est insuffisante, pas de n'importe quel endpoint JSONP. L'exemple ci-dessus est illustratif : la charge exacte qui défait un filtre donné dépend de l'endpoint.

Pourquoi les allowlists de hosts sont la cause racine

Ce n'est pas une bizarrerie propre à un CDN, c'est la faiblesse structurelle des politiques fondées sur des allowlists. Faire confiance à un host, c'est faire confiance à tout ce qu'il sert, y compris à des endpoints que vous ne contrôlez pas et que vous n'avez jamais audités. Un gestionnaire de tags de confiance pose le même problème, vu de l'autre côté ; voyez comment les attaquants détournent Google Tag Manager.

L'étude « CSP Is Dead, Long Live CSP! » (Weichselbaum, Spagnuolo, Lekies et Janc, de Google, présentée à l'ACM CCS 2016) a mesuré le phénomène sur de vraies politiques. Elle conclut que 94,72 % des politiques distinctes étaient contournables, que 75,81 % s'appuyaient sur des allowlists de scripts qui laissaient les attaquants contourner la CSP, et que 14 des 15 hosts de scripts les plus souvent allowlistés hébergeaient des endpoints dangereux, la catégorie dont JSONP fait partie.

La leçon que web.dev en tire : les politiques à allowlist laissent souvent la page exposée à la XSS, parce qu'on peut les contourner dans la plupart des configurations. Une politique bâtie sur des hosts ne vaut jamais mieux que l'endpoint le plus faible des hosts qu'elle liste.

Le correctif, nonces et strict-dynamic

Ne faites plus confiance aux hosts pour les scripts. Faites confiance à un nonce propre à chaque réponse, et laissez 'strict-dynamic' étendre cette confiance aux scripts que vos scripts de confiance chargent.

Content-Security-Policy:
  script-src 'nonce-r4nd0m' 'strict-dynamic';
  object-src 'none';
  base-uri 'none';

Vous marquez ensuite vos propres scripts avec le nonce correspondant :

<script nonce="r4nd0m">
  // your trusted code
</script>

Quand 'strict-dynamic' est présent, les navigateurs qui le prennent en charge ignorent 'unsafe-inline', 'self', les listes de sources basées sur des hosts, et les listes de sources basées sur des schémas comme https:. Seuls restent de confiance les scripts qui portent un nonce ou un hash valide, et ceux que ces scripts créent à leur tour. L'allowlist sur laquelle reposait l'endpoint JSONP ne s'applique plus, et le contournement se referme.

Le code des navigateurs le confirme. Dans Chromium, dès que 'strict-dynamic' est présent, le navigateur ignore les entrées de host et de schéma de la liste de sources au moment de décider si un script peut s'exécuter : un host JSONP allowlisté ne compte plus.

Quelques conditions doivent être réunies pour que cela tienne :

  • Le nonce doit être imprévisible, d'au moins 128 bits, en base64, et régénéré à chaque réponse. Un nonce statique ou prévisible est lui-même un contournement.
  • 'strict-dynamic' ne règle que la famille de contournements par allowlist et JSONP. Il suppose que le nonce reste secret et qu'aucun 'unsafe-inline' ou 'unsafe-eval' n'affaiblisse la politique, ce qui est sans objet ici puisque 'strict-dynamic' fait ignorer ces mots-clés.
  • Migrez avec soin. Ajouter le mot-clé peut casser des sites qui chargent des hosts allowlistés par un simple <script src> dans le balisage, sauf si un loader de confiance porteur de nonce s'en charge. La marche à suivre est « mettez un nonce sur vos propres scripts et laissez 'strict-dynamic' propager la confiance », pas « ajoutez le mot-clé et c'est réglé ».

'strict-dynamic' fait partie de CSP Level 3 et est largement pris en charge dans les navigateurs actuels. Dans un navigateur qui ne le comprend pas, le nonce et l'allowlist de hosts continuent de s'appliquer : la politique se dégrade au lieu de casser d'un coup. Si vous mettez ça en place dans un framework, les nonces CSP dans Next.js détaille une configuration concrète.

Préférez CORS à JSONP

Le correctif de fond consiste à retirer JSONP partout où vous le pouvez. CORS permet le partage de données entre origines sans exécuter de script arbitraire. Les navigateurs ont commencé à le prendre en charge vers 2009, le W3C l'a ratifié en 2014, et c'est aujourd'hui le bon outil pour récupérer du JSON entre origines :

const res = await fetch('https://cdn.example.com/api');
const data = await res.json();

Le serveur donne son accord avec un header de réponse plutôt qu'en remettant au client une fonction à exécuter :

Access-Control-Allow-Origin: https://yourdomain.com

Si un endpoint JSONP doit rester en place, validez strictement le nom de callback en le confrontant à une allowlist de caractères d'identifiant, pour qu'aucun code contrôlé par l'attaquant n'atteigne le corps de réponse.

Trouvez votre exposition

Le plus dur, en pratique, est de savoir lesquels des hosts allowlistés par votre politique en production servent du JSONP ou d'autres endpoints dangereux. Passez votre politique dans l'évaluateur CSP pour repérer les sources qui l'affaiblissent et les endroits où une allowlist vous laisse exposé, puis basculez les directives qui comptent sur des nonces et 'strict-dynamic'.

Pour aller plus loin, construire une CSP solide montre comment déployer une politique stricte d'abord en Report-Only, pour observer ce qui casserait avant de l'imposer.

L'allowlist n'est que la moitié de l'exposition. L'autre moitié, c'est ce que ces hosts servent vraiment sur vos pages aujourd'hui. CentralCSP le reconstitue à partir du CSP hash reporting : un inventaire, site par site, de chaque script qui s'est exécuté et de son origine. Un endpoint JSONP sur un CDN allowlisté apparaît alors comme un script que vous n'aviez pas prévu de charger, et non comme une ligne dans une politique que vous supposiez sûre.

Sources

Articles liés