FlawOpen

flawopen.com/xss/Javascript

● CWE-79 · Haute
Recherche · FlawOpen

XSS en JavaScript : sécuriser DOM, React et Express

Préférez les API texte ; assainissez le HTML, validez les URL et appliquez Trusted Types pour protéger DOM/React (CWE-79).

💡 Explication en Langage Simple (ELI5)

Imaginez un pupitre de régie. Si le régisseur traite chaque billet du public comme une indication scénique, un billet peut ajouter une scène imprévue ; affiché comme une simple réplique, il reste du texte. Dans le navigateur, innerHTML et dangerouslySetInnerHTML de React demandent au DOM d’interpréter le texte fourni comme du balisage, tandis que textContent l’affiche littéralement. Échappez le texte ordinaire, assainissez le peu de HTML autorisé et vérifiez les schémas d’URL avant de créer des liens.

Concepts Clés et Termes

innerHTML vs textContent
Affecter innerHTML, outerHTML ou insertAdjacentHTML fait analyser la chaîne comme du HTML : un <img onerror> qu'elle contient devient un élément actif. textContent et createTextNode n'insèrent que du texte ; rien dans la valeur n'est jamais interprété comme du balisage.
Template literals in res.send()
res.send() d'Express envoie les chaînes en text/html par défaut. Un template literal évalue ${req.query.q} avant l'envoi : la réponse contient donc ce que l'utilisateur a mis dans la query string. req.query.q peut aussi être un tableau ou un objet ; vérifiez son type.
dangerouslySetInnerHTML + DOMPurify
React échappe automatiquement le texte dans le JSX ; dangerouslySetInnerHTML est l'exception explicite. Faites passer la valeur par DOMPurify.sanitize(), qui l'analyse avec le propre parseur du navigateur et retire scripts, gestionnaires d'événements et URL dangereuses avant que React ne l'insère.
javascript: URLs in href
Le JSX échappe la valeur de l'attribut, mais href="javascript:..." est une URL valide qui exécute du code au clic. Analysez-la avec new URL() et n'autorisez que http: et https: ; le parseur d'URL retire les espaces et tabulations initiaux que de simples tests de préfixe laissent passer.
Trusted Types
Avec la directive CSP require-trusted-types-for 'script', les navigateurs compatibles refusent les chaînes brutes dans des sinks comme innerHTML et n'acceptent que des objets TrustedHTML créés par une politique que vous définissez, généralement une politique qui appelle DOMPurify. Aucun nouveau sink ne passe inaperçu.

Correspondances de référentiels

Déroulement de l'Attaque Étape par Étape

Étape 1

La saisie atteint un sink

Une recherche, un champ de profil chargé depuis une API ou un lien enregistré par un autre utilisateur arrive sous forme de chaîne et est passé à innerHTML, dangerouslySetInnerHTML, href ou res.send().

Étape 2

La valeur devient du balisage

Avec la valeur de test inoffensive <img src=x onerror=alert(1)>, innerHTML et res.send() produisent un vrai élément <img> ; un lien enregistré javascript:alert(1) devient un script cliquable.

Étape 3

Le navigateur l'exécute

L'image ne se charge pas et déclenche onerror, ou l'utilisateur clique sur le lien. Le code s'exécute immédiatement, sans autre action de l'attaquant.

Étape 4

Le script agit au nom du site

Il s'exécute avec l'origine de la page : il peut lire le DOM, appeler les API du site avec les cookies de la victime ou les jetons du localStorage, et modifier ce que voit l'utilisateur.

Code Source : Vulnérable vs Sécurisé

IMPLÉMENTATION VULNÉRABLE
// profile.jsx : chaque fonction ci-dessous laisse une chaîne être analysée comme du HTML
import express from 'express';

const app = express();

// Express : la valeur de la query est placée dans le HTML avant l'envoi
app.get('/search', (req, res) => {
  res.send(`<h1>Results for ${req.query.q}</h1>`);
});

// innerHTML analyse name comme du balisage
export function showGreeting(el, name) {
  el.innerHTML = `Hello, ${name}`;
}

// React insère le HTML exactement tel qu'il est reçu
export function Bio({ html }) {
  return <div className="bio" dangerouslySetInnerHTML={{ __html: html }} />;
}

// une URL javascript: s'exécute au clic sur le lien
export function Website({ url }) {
  return <a href={url}>Website</a>;
}
PATCH SÉCURISÉ ET ROBUSTE
// profile.jsx : le texte reste du texte ; HTML et URL sont d'abord vérifiés
import DOMPurify from 'dompurify';
import escapeHtml from 'escape-html';
import express from 'express';

const app = express();

// seules les chaînes sont acceptées, et elles sont échappées en HTML
app.get('/search', (req, res) => {
  const q = typeof req.query.q === 'string' ? req.query.q : '';
  res.send(`<h1>Results for ${escapeHtml(q)}</h1>`);
});

// textContent n'analyse jamais de balisage
export function showGreeting(el, name) {
  el.textContent = `Hello, ${name}`;
}

// DOMPurify retire scripts et gestionnaires d'événements avant l'insertion
export function Bio({ html }) {
  return <div className="bio" dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(html) }} />;
}

// seules les URL http et https passent ; le reste devient #
function safeHref(url) {
  try {
    const parsed = new URL(url, window.location.origin);
    return ['http:', 'https:'].includes(parsed.protocol) ? parsed.href : '#';
  } catch {
    return '#';
  }
}

export function Website({ url }) {
  return <a href={safeHref(url)}>Website</a>;
}

Liste de Contrôle de Sécurité pour l'Ingénierie

Sources