Recherche Publié le · 5 min de lecture

Échapper ne suffit pas : le second décodage dans un onclick

Une valeur correctement échappée redevient exécutable dans un attribut onclick. Trois défauts trouvés dans notre back-office, et pourquoi la CSP en cachait un.

Notre plateforme de bug bounty dispose d’un back-office. Il affiche des rapports, des comptes de chercheurs et des soumissions, sous forme de tableaux rendus côté serveur. Chacune de ces lignes contient des valeurs qui viennent d’un formulaire public : un nom, une adresse électronique, une organisation, un lien de profil.

C’est le cas d’école de l’injection : de la donnée non fiable, rendue dans une interface privilégiée. Le réflexe correct est d’échapper. Nous échappions. Cela ne suffisait pas.

Constat 1 : l’échappement survit au HTML, pas au second décodage

Une ligne de tableau porte des boutons d’action, et chaque bouton a besoin de savoir sur quelle entrée il agit. La construction naturelle consiste à interpoler la valeur dans le gestionnaire :

<button onclick="voirRapport('&#39;utilisateur&#39;')">Voir</button>

La valeur est bien échappée au sens HTML. Le document est valide. Et la construction est pourtant exploitable.

La raison est qu’un attribut gestionnaire d’évènement n’est pas du texte, c’est du code. Le navigateur procède en deux temps : il décode d’abord les entités HTML de la valeur de l’attribut, puis il livre le résultat à l’analyseur JavaScript. L’échappement HTML est donc défait avant que le JavaScript ne soit lu. Une apostrophe échappée en &#39; redevient une apostrophe, referme la chaîne littérale, et la suite est interprétée comme du code.

Une valeur suffit, et elle n’a rien d’exotique : un nom d’organisation contenant une apostrophe. Dans notre cas les noms attendus ressemblent à « Amadou Daouda M’Bodj ». L’apostrophe est légitime, fréquente, et elle était la charge utile.

La règle qui en découle : l’échappement dépend du contexte de destination, pas du type de la donnée. Échapper pour du HTML puis déposer le résultat dans un contexte JavaScript, c’est échapper pour la mauvaise cible. « Nous échappons tout » n’est pas une réponse tant que la question « échappé vers quoi ? » n’a pas été posée.

Le correctif : ne plus jamais interpoler dans un gestionnaire

La correction ne consiste pas à mieux échapper, elle consiste à ne plus placer de donnée dans un contexte exécutable. Les valeurs partent dans des attributs data-*, qui sont du texte et le restent, et un seul écouteur délégué sur le conteneur lit ces attributs au moment du clic :

<button data-act="status" data-ts="...">Voir</button>
document.getElementById('pb').addEventListener('click', function (ev) {
  var b = ev.target.closest('button[data-act]');
  if (!b) return;
  var act = b.dataset.act, ts = b.dataset.ts || '';
  if (act === 'status') pickStatus(ts, b.dataset.s);
});

La donnée ne traverse plus l’analyseur JavaScript. Elle est lue depuis le DOM comme une chaîne, à l’usage. Il n’existe plus de contexte dans lequel une apostrophe puisse signifier autre chose qu’une apostrophe.

Constat 2 : la CSP protégeait, et cassait l’interface en silence

Le deuxième constat est le plus instructif, et nous ne l’avons pas cherché.

L’administration sert une politique de sécurité de contenu stricte, avec un nonce généré par requête et sans 'unsafe-inline' :

script-src 'nonce-<valeur-aleatoire>';

Un nonce autorise des éléments <script> portant l’attribut correspondant. Il n’autorise pas les attributs gestionnaires d’évènement. Un onclick reste du script en ligne, il ne porte pas de nonce et ne peut pas en porter : il est donc bloqué purement et simplement.

Conséquence : tous les contrôles de haut niveau du back-office encore câblés en onclick étaient morts. Pas dégradés, pas lents : sans effet. Le bouton de langue, le rechargement, l’export CSV, la fermeture du panneau.

Personne ne l’avait signalé, et c’est le point qui mérite d’être retenu. Un contrôle qui ne fait rien du tout ne produit ni erreur visible, ni alerte, ni entrée de journal côté serveur. La console du navigateur affiche le refus de la CSP, mais personne ne garde la console ouverte dans un outil interne qu’il utilise quotidiennement.

Deux enseignements se superposent ici. D’abord, une CSP stricte est aussi un révélateur : elle transforme une mauvaise pratique en panne, et la panne est préférable à la vulnérabilité silencieuse. Ensuite, le silence n’est pas une preuve de bon fonctionnement. L’absence de plainte utilisateur ne mesure que la fréquence d’usage.

Constat 3 : javascript: acceptait de s’enregistrer

Troisième défaut, indépendant des deux premiers. Les chercheurs peuvent renseigner un lien de profil, rendu comme une ancre dans le panneau de compte. Ce champ n’était soumis à aucun contrôle de schéma. Une valeur telle que javascript:alert(1) s’enregistrait proprement, et attendait un clic d’administrateur.

Le contrôle est une liste blanche de schémas, pas un filtre de motifs dangereux :

_PROFILE_URL_RE = re.compile(r'^https?://[^\s<>"\']+$', re.IGNORECASE)

Un champ destiné à devenir un lien n’a pas à accepter autre chose que http ou https. Chercher à interdire ce qui est dangereux est une course perdue ; énumérer ce qui est acceptable se termine.

Une seconde ligne, dans le stockage

Nous avons ajouté un contrôle au niveau de l’enregistrement, qui refuse les chevrons et les caractères de contrôle dans les noms affichés :

_NAME_FORBIDDEN_RE = re.compile(r'[<>\x00-\x1f\x7f]')

Ce n’est pas la défense principale, et il faut être clair sur ce point : les gabarits d’affichage échappent, et doivent continuer de le faire. C’est une seconde ligne, dont l’objectif est précis. Les noms affichés atteignent aujourd’hui trois surfaces : le back-office, le palmarès et le profil public. Il y en aura d’autres. Garder le balisage hors de la base garantit qu’un affichage écrit dans six mois ne se verra pas remettre une charge utile déjà stockée, prête à l’emploi.

Les accents, les apostrophes et les traits d’union restent évidemment légaux. Un contrôle de sécurité qui rejette « M’Bodj » n’est pas un contrôle de sécurité, c’est un défaut.

Ce qu’on en retient

  1. Échapper est relatif à une destination. HTML, attribut, URL, JavaScript, CSS : ce sont cinq contextes et cinq règles. Une donnée qui traverse deux contextes est décodée deux fois.
  2. Ne pas mettre de donnée dans un contexte exécutable. data-* et délégation d’évènements suppriment la classe entière de problèmes, plutôt que d’en corriger une instance.
  3. Une CSP stricte révèle autant qu’elle protège. Si elle casse quelque chose, ce quelque chose était probablement à corriger.
  4. Autoriser explicitement plutôt qu’interdire. Une liste de schémas acceptables se termine, une liste de charges utiles dangereuses non.

Ces trois défauts ont été corrigés avant l’ouverture du programme au public. Si vous trouvez autre chose sur nos surfaces, le canal est prévu pour : [email protected].

xsscspsecurite-applicativejavascript