Accueil >PHP >Sécurité PHP >XSS et affichage sûr des données

XSS et affichage sûr des données

Le XSS (cross-site scripting) est l’erreur la plus fréquente des applications web : un JavaScript étranger arrive dans la page et s’exécute avec les droits du visiteur connecté. Comment afficher les données en toute sécurité dans le texte, les attributs, les liens et le JavaScript.

Comment fonctionne l’attaque

La page affiche le texte du visiteur exactement sous la forme où il est arrivé. S’il contient des balises HTML, le navigateur les exécute :

// code vulnérable – ce que saisit le visiteur est inséré tel quel dans la page
echo "<p>Vous avez cherché : ".$_GET["q"]."</p>";

L’attaquant envoie à sa victime un lien vers search.php?q=<script>…</script> et le script s’exécute sur votre site. Il peut lire le contenu des pages, envoyer des formulaires au nom de l’utilisateur connecté ou afficher un faux formulaire de connexion. Selon l’origine du texte malveillant, on distingue plusieurs types :

  • XSS réfléchi (reflected) – le texte est dans l’adresse ou dans un formulaire et la page l’affiche aussitôt (exemple ci-dessus) ;
  • XSS stocké (stored) – le texte est enregistré dans la base de données (commentaire, nom dans un profil) et s’exécute chez tous ceux qui ouvrent la page, souvent aussi chez l’administrateur ;
  • XSS en JavaScript (DOM) – un script de la page insère des données dans le HTML par innerHTML. PHP n’y peut rien, en JavaScript utilisez textContent.

Affichage dans le texte de la page

Tout texte qui ne vient pas directement de votre code passe par htmlspecialchars à l’affichage. Cela vaut aussi pour les données de la base de données – elles aussi ont pu venir un jour d’un visiteur. Le plus pratique est une courte fonction utilitaire :

function e($text)
  {
    return htmlspecialchars((string) $text, ENT_QUOTES | ENT_SUBSTITUTE, "UTF-8");
  }
echo "<p>Vous avez cherché : ".e($_GET["q"])."</p>";

Pour q=<script>alert("XSS")</script>, elle affiche :

<p>Vous avez cherché : &lt;script&gt;alert(&quot;XSS&quot;)&lt;/script&gt;</p>

La fonction remplace cinq caractères : & par &amp;, < par &lt;, > par &gt;, " par &quot; et l’apostrophe par &#039;. Le navigateur affiche alors le texte, mais ne l’exécute pas.

Pourquoi indiquer ENT_QUOTES et ENT_SUBSTITUTE

Les options par défaut de htmlspecialchars ont changé en PHP 8.1. Dans les versions plus anciennes, la fonction sans paramètre supplémentaire ne remplace pas l’apostrophe, si bien qu’une valeur dans un attribut entre apostrophes (value='…') peut être refermée. Depuis PHP 8.1, la valeur par défaut est ENT_QUOTES | ENT_SUBSTITUTE. Si vous indiquez les options explicitement, le code se comporte de la même façon dans toutes les versions.

ENT_SUBSTITUTE gère un texte dont l’encodage UTF-8 est abîmé. Sans cette option, la fonction renvoie une chaîne vide au lieu du texte entier – en PHP 7, c’est aussi le cas lors d’un appel sans options :

$text = "Prix : 100 \xE2\x82 EUR";   // caractère abîmé (le dernier octet manque)
echo "[".htmlspecialchars($text, ENT_QUOTES, "UTF-8")."]\n";
echo "[".htmlspecialchars($text, ENT_QUOTES | ENT_SUBSTITUTE, "UTF-8")."]\n";

Résultat :

[]
[Prix : 100 � EUR]

Le caractère abîmé est remplacé par le symbole �, le reste du texte est conservé.

Valeurs d’attributs toujours entre guillemets

<input type="text" name="q" value="<?= e($search) ?>">

Pour la valeur " autofocus onfocus="alert(1), elle affiche :

<input type="text" name="q" value="&quot; autofocus onfocus=&quot;alert(1)">

Sans guillemets autour de la valeur, l’échappement ne sert à rien. Une espace suffit à l’attaquant pour commencer un nouvel attribut :

<input type="text" name="q" value=<?= e($search) ?>>
<input type="text" name="q" value=x onmouseover=alert(1)>

N’insérez jamais de données des visiteurs dans les attributs d’événements (onclick, onmouseover…) ni dans l’attribut style.

Liens

Pour une adresse dans href ou src, l’échappement ne suffit pas. L’adresse javascript:… ne contient aucun caractère dangereux et lance pourtant un script au clic. FILTER_VALIDATE_URL n’aide pas non plus, il ne vérifie que la forme de l’adresse :

echo filter_var("javascript://comment%0Aalert(1)", FILTER_VALIDATE_URL) === false ? "refusé" : "accepté";
accepté

N’acceptez donc une adresse du visiteur (par exemple le site d’un profil) qu’avec le schéma http ou https, ou comme chemin sur votre propre site :

function safe_link($url)
  {
    $url = trim((string) $url);
    // chemin sur votre propre site : commence par une seule barre oblique, sans caractères de contrôle
    if(preg_match('~^/(?![/\\\\])[^\x00-\x1F\x7F]*\z~', $url)) return $url;
    $scheme = strtolower((string) parse_url($url, PHP_URL_SCHEME));
    if(($scheme === "http" || $scheme === "https") && filter_var($url, FILTER_VALIDATE_URL)) return $url;
    return "#";
  }
foreach(["https://www.example.com/", "/articles/", "javascript:alert(1)", "JavaScript://x%0Aalert(1)", "//example.com/"] as $url)
  echo '<a href="'.e(safe_link($url)).'">lien</a>'."\n";
<a href="https://www.example.com/">lien</a>
<a href="/articles/">lien</a>
<a href="#">lien</a>
<a href="#">lien</a>
<a href="#">lien</a>

Une adresse qui commence par deux barres obliques (//example.com/) mène vers un site étranger, c’est pourquoi la fonction ne l’accepte pas comme chemin. Les navigateurs traitent /\ au début de la même façon et suppriment de l’adresse les caractères de contrôle comme la tabulation – c’est pourquoi l’expression régulière les vérifie aussi.

Insérez les paramètres dans l’adresse par http_build_query (ou rawurlencode), puis passez seulement ensuite l’adresse entière par e() :

$address = "/search/?".http_build_query(["q" => $search, "page" => 2]);
echo '<a href="'.e($address).'">Page suivante</a>';
<a href="/search/?q=chats+%26+chiens&amp;page=2">Page suivante</a>

Données pour JavaScript

Dans un bloc <script>, les données ne s’insèrent pas par htmlspecialchars, mais par json_encode avec les options qui remplacent aussi les caractères < > & ' ". Le texte </script> ne peut alors pas terminer le bloc de script :

$data = ["name" => $name, "count" => 3];
$flags = JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_UNESCAPED_UNICODE;
echo "<script>var settings = ".json_encode($data, $flags).";</script>";
<script>var settings = {"name":"\u003C\/script\u003E\u003Cscript\u003Ealert(1)\u003C\/script\u003E","count":3};</script>

Autre possibilité : enregistrer les données dans un attribut data-… (par e()) et les lire en JavaScript par dataset et JSON.parse.

strip_tags ne protège pas

La fonction strip_tags supprime les balises, mais garde tous les attributs des balises autorisées :

echo strip_tags('<b onmouseover="alert(1)">gras</b> <script>alert(2)</script>', "<b>");
<b onmouseover="alert(1)">gras</b> alert(2)

Si les visiteurs doivent écrire du texte mis en forme (commentaires, discussions), laissez-les écrire du texte brut et ajoutez la mise en forme (paragraphes, liens) seulement à l’affichage, à partir du texte échappé. Si vous devez vraiment accepter du HTML, utilisez une bibliothèque qui le nettoie selon une liste de balises et d’attributs autorisés, par exemple HTML Purifier.

Autres couches de protection

  • Envoyez la page avec son encodage : header("Content-Type: text/html; charset=utf-8") ou <meta charset="utf-8"> au début de l’en-tête de la page.
  • Définissez le cookie de session comme HttpOnly pour que les scripts ne puissent pas le lire (voir l’article sur les sessions sécurisées).
  • L’en-tête Content-Security-Policy peut bloquer l’exécution d’un script étranger, même si l’échappement a été oublié quelque part (voir l’article sur les en-têtes de sécurité).

Articles connexes :
Validation des données utilisateur
En-têtes de sécurité et HTTPS
Open redirect et SSRF
Sessions et connexion sécurisées