Kodokon kodokon.com

Sécurité web : XSS, CSRF et injections

Démontez XSS, CSRF et injection SQL pour comprendre exactement ce que bloquent htmlspecialchars, les jetons et les requêtes préparées.

12 min · 3 questions

Ouvrir cette leçon dans Kodokon

Les trois grandes familles de failles web - XSS, CSRF et injections - partagent la même cause : une donnée fournie par l'utilisateur finit interprétée comme du code (HTML, SQL ou requête légitime). Commençons par le XSS : si vous affichez une valeur de $_GET sans échappement, un attaquant peut injecter <script> et exécuter du JavaScript chez vos autres visiteurs - vol de session, actions à leur insu. Avec ?comment=<script>alert(1)</script>, la première sortie ci-dessous exécute le script ; la seconde le neutralise. Deux drapeaux comptent : ENT_QUOTES convertit aussi les apostrophes (sinon un attribut délimité par des guillemets simples reste injectable) et ENT_SUBSTITUTE remplace les séquences UTF-8 invalides au lieu de renvoyer une chaîne vide. Depuis PHP 8.1, ces deux drapeaux sont enfin le défaut, mais les expliciter documente l'intention et protège le code exécuté sur des versions plus anciennes.

PHP
<?php
declare(strict_types=1);

$comment = $_GET['comment'] ?? '';

echo '<div>' . $comment . '</div>';

function e(string $value): string
{
    return htmlspecialchars(
        $value,
        ENT_QUOTES | ENT_SUBSTITUTE,
        'UTF-8'
    );
}

echo '<div>' . e($comment) . '</div>';
La première sortie est vulnérable, la seconde correctement échappée.

Le CSRF exploite un fait simple : le navigateur joint automatiquement les cookies de session à toute requête vers votre domaine, même déclenchée depuis un site tiers. Un formulaire hostile peut donc soumettre /account/delete avec la session de la victime. La parade classique est le jeton synchronisé : un secret aléatoire stocké en session et exigé dans chaque requête mutative. Un site tiers ne peut ni le lire ni le deviner, donc pas le fournir.

HTML
<?php
session_start();

if (!isset($_SESSION['csrf'])) {
    $_SESSION['csrf'] = bin2hex(random_bytes(32));
}

$sent = $_POST['csrf'] ?? '';
$valid = is_string($sent)
    && hash_equals($_SESSION['csrf'], $sent);

$isPost = $_SERVER['REQUEST_METHOD'] === 'POST';
if ($isPost && !$valid) {
    http_response_code(403);
    exit('Invalid CSRF token');
}
?>
<form method="post">
  <input type="hidden" name="csrf"
    value="<?= htmlspecialchars($_SESSION['csrf']) ?>">
  <button>Delete my account</button>
</form>
Jeton généré une fois par session, vérifié avant toute action mutative.

L'injection SQL suit le même schéma : concaténer une valeur utilisateur dans une requête revient à laisser l'utilisateur écrire du SQL - ' OR 1=1 -- transforme un filtre en table entière. La solution n'est pas l'échappement mais la requête préparée : le texte de la requête (avec ses marqueurs :email) et les valeurs voyagent séparément jusqu'au moteur, qui ne peut donc jamais confondre données et code. Détail d'expert : avec MySQL, PDO émule les préparations par défaut en échappant côté client ; passez PDO::ATTR_EMULATE_PREPARES à false pour des préparations réellement natives.

PHP
<?php
declare(strict_types=1);

$pdo = new PDO('sqlite::memory:');
$pdo->setAttribute(
    PDO::ATTR_ERRMODE,
    PDO::ERRMODE_EXCEPTION
);
$pdo->exec(
    'CREATE TABLE users (
        id INTEGER PRIMARY KEY,
        email TEXT NOT NULL
    )'
);

$email = $_GET['email'] ?? '';

$stmt = $pdo->prepare(
    'SELECT id FROM users WHERE email = :email'
);
$stmt->execute(['email' => $email]);

var_dump($stmt->fetch(PDO::FETCH_ASSOC));
La valeur ne rejoint jamais le texte SQL : injection impossible.

Quiz de validation

Vérifiez que vous avez bien retenu les points clés de cette leçon.

  1. Pourquoi passer le drapeau ENT_QUOTES à htmlspecialchars ?
    • Pour convertir aussi les apostrophes, sans quoi un attribut délimité par des guillemets simples reste injectable
    • Pour forcer la conversion de la chaîne en UTF-8
    • Pour supprimer les balises script du contenu
    • Pour accélérer l'échappement des chaînes longues
  2. Pourquoi comparer le jeton CSRF avec hash_equals plutôt qu'avec === ?
    • hash_equals compare en temps constant, ce qui empêche de deviner le jeton octet par octet en mesurant les temps de réponse
    • hash_equals recalcule automatiquement le hachage du jeton avant comparaison
    • === échoue dès que les deux chaînes ont des encodages différents
  3. Qu'est-ce qui rend une requête préparée immunisée contre l'injection SQL ?
    • Elle échappe automatiquement les guillemets contenus dans les valeurs
    • La requête et les valeurs sont transmises séparément au moteur, qui ne réinterprète jamais les valeurs comme du SQL
    • Elle vérifie que chaque valeur correspond au type de la colonne visée
    • Elle limite l'exécution à une seule instruction SQL