Chapitre 1517 minIntermédiaire
Configuration, secrets et sécurité en production
Par Josselyn C., prof de BTS SIO. Mis à jour le .
À la fin de ce chapitre, tu sauras
- ranger les secrets hors du code et hors de Git ;
- régler php.ini et la gestion des erreurs pour la production ;
- journaliser les événements de sécurité ;
- vérifier une application avec la liste de contrôle de sécurité.
3 exercices corrigés
La configuration d'une application PHP (adresse de la base, mot de passe, clés d'API, mode développement ou production) ne s'écrit pas dans le code : elle se lit dans des variables d'environnement ou dans un fichier .env placé hors de la racine du site et jamais versionné. Ce chapitre explique comment ranger les secrets de Prêtmat, régler PHP pour la production, gérer les erreurs sans rien révéler, limiter les droits du compte MySQL, et termine par la liste de contrôle de sécurité de toute l'application. Le cours Sécurité des applications replace ces mesures dans le Top 10 de l'OWASP (mauvaise configuration).
Ce qu'est un secret
Un secret est une information qui donne un accès : le mot de passe du compte MySQL de l'application, une clé d'API d'un service de mail, le mot de passe SMTP, une clé de chiffrement. Celui qui l'obtient agit à la place de l'application.
| Où mettre un secret | Verdict |
|---|---|
Écrit dans le code (new PDO($dsn, 'root', 'Azerty123')) | Non : il part dans le dépôt Git, dans chaque copie du projet, dans chaque sauvegarde, et sur GitHub si le dépôt est public |
Dans un fichier config.php sous public/ | Non : une erreur de configuration du serveur (PHP désactivé, mauvais type de fichier) l'envoie en clair au navigateur |
Dans un fichier .env à la racine du projet, hors de public/, ignoré par Git | Oui, pour le développement et les petits déploiements |
| Dans les variables d'environnement du serveur ou du conteneur | Oui |
| Dans un coffre de secrets (Vault, gestionnaire du fournisseur cloud, secrets de la CI) | Oui, pour les déploiements plus importants |
Lire la configuration depuis l'environnement
Les variables d'environnement sont fournies au processus PHP par le système : par compose.yaml avec Docker (chapitre 1), par la configuration d'Apache (SetEnv) ou de PHP-FPM (env[DB_PASSWORD] = …). PHP les lit avec getenv(). En développement, sans Docker, un fichier .env à la racine du projet joue ce rôle :
# Configuration locale de Prêtmat, jamais versionnée
APP_ENV=dev
DB_HOST=127.0.0.1
DB_NAME=pretmat
DB_USER=pretmat_app
DB_PASSWORD="x7!Gq#2;p=Vb"
Dans ce fichier, un commentaire commence par # et ne contient ni parenthèses ni guillemets : parse_ini_file() les refuserait. Une valeur qui contient des caractères spéciaux (#, ;, =) s'écrit entre guillemets, comme le mot de passe ci-dessus.
Une fonction de quelques lignes le charge au démarrage, sans écraser une vraie variable d'environnement :
<?php
declare(strict_types=1);
// src/bootstrap.php (début)
function chargerEnv(string $fichier): void
{
if (!is_file($fichier)) {
return; // en production, les variables viennent du serveur
}
$valeurs = parse_ini_file($fichier, false, INI_SCANNER_RAW);
if ($valeurs === false) {
throw new RuntimeException("Fichier de configuration illisible : $fichier");
}
foreach ($valeurs as $cle => $valeur) {
if (getenv($cle) === false) { // une variable déjà définie par le serveur est prioritaire
putenv("$cle=$valeur");
}
}
}
chargerEnv(__DIR__ . '/../.env');
Mets entre guillemets les valeurs qui contiennent des caractères spéciaux (;, #, =, espaces). La fonction bdd() du chapitre 7 lit déjà ses paramètres avec getenv() : elle fonctionne telle quelle.
Le dépôt Git contient un modèle, .env.example, avec les noms des variables et des valeurs factices, pour que chaque développeur sache quoi remplir. Le vrai .env est dans le .gitignore :
# .gitignore
.env
/vendor/
/var/log/
/var/uploads/
Le cours Git explique le fichier .gitignore, et le cours Intégration continue la gestion des secrets dans un pipeline.
php.ini en production
| Directive | Valeur en production | Pourquoi |
|---|---|---|
display_errors | Off | Un message d'erreur révèle des chemins, des noms de tables, parfois des requêtes |
log_errors | On | Les erreurs vont dans un journal, lu par l'équipe |
error_log | /var/log/php/pretmat.log | Un fichier hors de la racine du site |
error_reporting | E_ALL | On veut tout savoir, mais dans le journal |
expose_php | Off | Supprime l'en-tête X-Powered-By: PHP/8.5.x, qui annonce la version exacte aux attaquants |
session.cookie_secure, session.cookie_httponly, session.use_strict_mode | 1 | Si les options ne sont pas passées à session_start() (chapitre 6) |
session.cookie_samesite | Lax | Idem |
upload_max_filesize, post_max_size | Selon le besoin | chapitre 13 |
date.timezone | Europe/Paris | Des dates justes, sans dépendre du serveur |
opcache.enable | 1 | Le code compilé est gardé en mémoire : l'application est nettement plus rapide |
Le fichier php.ini-production fourni avec PHP contient déjà la plupart de ces réglages : pars de lui, et vérifie le résultat avec php -i ou une page phpinfo() que tu supprimes aussitôt (elle affiche toute la configuration, variables d'environnement comprises).
Un gestionnaire d'erreurs global
Une exception que personne n'attrape doit aboutir à une page d'erreur propre, pas à une page blanche ni à un message technique. PHP permet de déclarer un gestionnaire d'exceptions global, appelé en dernier recours :
<?php
declare(strict_types=1);
// src/bootstrap.php (suite)
$enDev = (getenv('APP_ENV') ?: 'prod') === 'dev';
ini_set('display_errors', $enDev ? '1' : '0');
// Les avertissements (variable indéfinie, clé absente…) deviennent des exceptions : rien ne passe inaperçu
set_error_handler(function (int $niveau, string $message, string $fichier, int $ligne): bool {
if (!(error_reporting() & $niveau)) {
return false;
}
throw new ErrorException($message, 0, $niveau, $fichier, $ligne);
});
set_exception_handler(function (Throwable $e) use ($enDev): void {
error_log((string) $e); // message, fichier, ligne et pile d'appels, dans le journal
if (!headers_sent()) {
http_response_code(500);
}
$chemin = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/';
if (str_starts_with($chemin, '/api/')) {
header('Content-Type: application/problem+json');
echo json_encode(['type' => 'about:blank', 'title' => 'Erreur interne', 'status' => 500]);
return;
}
echo $enDev
? '<pre>' . htmlspecialchars((string) $e) . '</pre>'
: '<h1>Une erreur est survenue</h1><p>L\'équipe a été prévenue. Réessaie dans quelques minutes.</p>';
});
En développement, tu vois l'erreur complète ; en production, l'utilisateur voit un message neutre et le détail part dans le journal. La page d'erreur de production est volontairement du HTML simple : si l'erreur vient des vues, utiliser rendre() pour l'afficher provoquerait une nouvelle erreur.
Journaliser les événements de sécurité
Le journal ne sert pas qu'aux erreurs : il garde la trace des événements de sécurité, indispensables pour détecter une attaque et comprendre un incident.
| Événement | Exemple de ligne |
|---|---|
| Échec de connexion | 2026-11-02T09:14:07+01:00 echec_connexion email=karim.morel@sorbier.example ip=10.0.4.21 |
| Compte bloqué | … compte_bloque email=… echecs=5 |
| Accès refusé (403) | … acces_refuse utilisateur=2 route=POST /materiels/4/supprimer |
| Jeton CSRF invalide | … csrf_invalide utilisateur=1 route=POST /materiels |
| Action sensible réussie | … materiel_supprime utilisateur=1 materiel=PC-0087 |
<?php
function journaliser(string $evenement, array $contexte = []): void
{
$ligne = date(DATE_ATOM) . ' ' . $evenement;
foreach ($contexte as $cle => $valeur) {
$ligne .= ' ' . $cle . '=' . str_replace(["\r", "\n", ' '], '_', (string) $valeur);
}
error_log($ligne);
}
journaliser('acces_refuse', ['utilisateur' => 2, 'route' => 'POST /materiels/4/supprimer']);
Les retours à la ligne et les espaces d'une valeur sont remplacés : sinon, une saisie contenant un saut de ligne pourrait fabriquer une fausse ligne dans le journal (injection de journal). On ne journalise jamais un mot de passe, même faux, ni un jeton ; et comme le journal contient des données personnelles (adresses, adresses IP), on fixe sa durée de conservation (journalisation et gestion des erreurs).
Le compte MySQL de l'application
L'application se connecte avec un compte qui n'a que les droits nécessaires à son fonctionnement : lire et modifier les données, pas modifier la structure ni gérer les comptes.
CREATE USER 'pretmat_app'@'10.0.1.%' IDENTIFIED BY 'mot-de-passe-long-et-aléatoire';
GRANT SELECT, INSERT, UPDATE, DELETE ON pretmat.* TO 'pretmat_app'@'10.0.1.%';
- Pas de
DROP,ALTER,CREATEniGRANT: une injection réussie ne peut ni supprimer les tables ni créer un compte. Les évolutions de structure se font avec un autre compte, réservé aux déploiements. - L'hôte
'10.0.1.%'limite les connexions aux machines du réseau des serveurs d'application. - On peut aller plus loin : retirer
DELETEsur la tableempruntpour que l'historique ne puisse jamais être effacé par l'application.
Avec l'image Docker de MySQL, les variables MYSQL_USER et MYSQL_PASSWORD créent un compte qui a tous les droits sur la base MYSQL_DATABASE : pratique en développement, à remplacer par un compte restreint en production. Le cours SQL détaille les comptes, les rôles et les droits (GRANT et REVOKE).
HTTPS et en-têtes de sécurité
En production, l'application n'est servie qu'en HTTPS : sans chiffrement, le mot de passe et le cookie de session circulent en clair sur le réseau. Un certificat Let's Encrypt est gratuit et se renouvelle automatiquement. L'en-tête HSTS demande ensuite au navigateur de ne plus jamais utiliser HTTP pour ce site :
<?php
header('Strict-Transport-Security: max-age=31536000'); // un an
Les autres en-têtes de sécurité (CSP, X-Content-Type-Options, Referrer-Policy) sont posés par le contrôleur frontal (chapitre 12).
Mettre à jour PHP et les dépendances
Une application sûre aujourd'hui ne l'est plus dans un an si personne ne la met à jour :
- PHP : reste sur une version maintenue. PHP 8.4 reçoit des correctifs de sécurité jusqu'à fin 2028, PHP 8.5 jusqu'à fin 2029 (chapitre 1). Les versions mineures (8.5.x) s'installent sans risque.
- Les dépendances Composer :
composer auditliste celles qui ont une faille connue, etcomposer outdatedcelles qui ont une nouvelle version. Lance la première dans l'intégration continue, à chaque modification (cours Intégration continue). - MySQL et le système : les mises à jour de sécurité de la distribution, régulièrement.
La liste de contrôle de sécurité d'une application PHP
Cette liste rassemble les protections vues dans le cours. Passe ton application de l'E6 à ce crible avant de la présenter : chaque ligne est une question que la commission peut te poser.
| Domaine | À vérifier | Chapitre |
|---|---|---|
| Entrées | Chaque donnée reçue ($_GET, $_POST, $_COOKIE, $_FILES, JSON) est validée : type, format, longueur, liste blanche | 4 |
| Affichage | Chaque donnée affichée passe par htmlspecialchars() (ou e()) ; le contexte JavaScript ou URL a son traitement | 5 |
| Session | Cookie HttpOnly, Secure, SameSite=Lax ; session_regenerate_id(true) à la connexion ; expiration ; déconnexion en POST | 6 |
| Base de données | Requêtes préparées partout ; noms de colonnes par liste blanche ; compte MySQL aux droits minimaux | 7 |
| Erreurs | Messages neutres à l'écran, détail dans le journal ; display_errors = Off | 8 et 15 |
| Mots de passe | password_hash() et password_verify() ; message de connexion unique ; tentatives limitées | 9 |
| Contrôle d'accès | Chaque action vérifie la connexion, le rôle et la propriété de la donnée (pas d'IDOR) | 9 |
| CSRF | Jeton sur chaque formulaire POST, vérifié côté serveur ; aucune modification en GET | 9 |
| Fichiers déposés | Type réel vérifié, nom aléatoire, stockage hors de la racine du site | 13 |
| API | Authentification par jeton, représentation explicite (pas de SELECT * exposé), codes de statut justes, CORS restreint | 14 |
| Configuration | Racine du site sur public/ ; secrets hors du code et hors de Git ; HTTPS et HSTS ; PHP et dépendances à jour | 12 et 15 |
Exercices
Exercice 1 sur 3 Une fuite de secrets Réussi À revoir
En relisant le dépôt GitHub public d'un camarade, tu trouves le fichier public/config.php :
<?php
$pdo = new PDO('mysql:host=51.91.x.x;dbname=pretmat', 'root', 'Sorbier2026!');
ini_set('display_errors', '1');
Relève les problèmes et décris, dans l'ordre, ce qu'il doit faire.
Voir la réponseMasquer la réponse de l'exercice 1
Problèmes : le mot de passe est dans le code et publié sur un dépôt public ; c'est le compte root ; le fichier est dans public/ ; le serveur MySQL semble joignable depuis Internet (une adresse publique) ; les erreurs s'affichent ; il manque charset=utf8mb4 et les options de PDO.
Ce qu'il doit faire, dans l'ordre :
- Changer immédiatement le mot de passe de root : il faut le considérer comme connu de tous. Vérifier les journaux de MySQL et le contenu de la base, pour savoir si quelqu'un s'en est servi.
- Créer un compte applicatif aux droits minimaux, et fermer le port 3306 au public (pare-feu) : MySQL n'écoute que le réseau des serveurs.
- Déplacer la configuration hors de
public/et lire les paramètres avecgetenv(), depuis un.envajouté au.gitignore, avec un.env.examplesans valeurs réelles. - Nettoyer l'historique Git (avec
git filter-repo, par exemple) et forcer la mise à jour du dépôt : sans l'étape 1, ce nettoyage ne sert à rien, des copies existent peut-être déjà. - Régler
display_errors = Offen production, avec un journal.
Tu avais trouvé ?
Exercice 2 sur 3 Relire une configuration Réussi À revoir
La commande php -i sur le serveur de production de Prêtmat affiche notamment : display_errors => On, expose_php => On, session.cookie_httponly => 0, session.use_strict_mode => 0, upload_max_filesize => 64M, log_errors => Off. Pour chaque ligne, dis s'il faut la changer, pourquoi, et la valeur à mettre.
Voir la réponseMasquer la réponse de l'exercice 2
display_errors => On: à passer à Off. Les erreurs affichées révèlent des chemins, des requêtes, des noms de tables.expose_php => On: à passer à Off. L'en-têteX-Powered-Byannonce la version exacte de PHP, ce qui aide à choisir une faille connue.session.cookie_httponly => 0: à passer à 1, sauf si l'application passe déjà l'option àsession_start(). Sans elle, un script injecté peut lire le cookie de session.session.use_strict_mode => 0: à passer à 1, contre la fixation de session.upload_max_filesize => 64M: trop large pour des photos de 5 Mo. 6M (avecpost_max_sizeà 8M) limite le risque de saturation du disque. Le contrôle fin reste dans le code.log_errors => Off: à passer à On, avec unerror_loghors de la racine du site. Sinon, les erreurs ne sont ni affichées ni enregistrées : personne ne les voit.
Tu avais trouvé ?
Exercice 3 sur 3 Les droits du compte applicatif Réussi À revoir
L'application Prêtmat doit : lire et modifier le matériel ; ajouter et clore des emprunts, sans jamais en supprimer ; créer des comptes et changer les mots de passe ; enregistrer les échecs de connexion et purger les anciens. Écris les instructions GRANT du compte 'pretmat_app'@'10.0.1.%', table par table.
Voir la réponseMasquer la réponse de l'exercice 3
GRANT SELECT, INSERT, UPDATE, DELETE ON pretmat.materiel TO 'pretmat_app'@'10.0.1.%';
GRANT SELECT, INSERT, UPDATE ON pretmat.emprunt TO 'pretmat_app'@'10.0.1.%';
GRANT SELECT, INSERT, UPDATE ON pretmat.utilisateur TO 'pretmat_app'@'10.0.1.%';
GRANT SELECT, INSERT, DELETE ON pretmat.echec_connexion TO 'pretmat_app'@'10.0.1.%';
GRANT SELECT, INSERT, DELETE ON pretmat.jeton_api TO 'pretmat_app'@'10.0.1.%';
Pas de DELETE sur emprunt : même une injection SQL réussie ne pourrait pas effacer l'historique des prêts. UPDATE sur emprunt reste nécessaire pour enregistrer la date de retour. Sur utilisateur, pas de DELETE non plus si la règle de gestion est de désactiver un compte plutôt que de le supprimer. On aurait pu donner les droits sur pretmat.*, plus simple, mais chaque droit retiré est une possibilité de moins pour un attaquant (traduire une matrice d'habilitations).
Tu avais trouvé ?