Aller au contenu
Réussir mon BTS SIO

Tape une notion, une commande ou le nom d'une épreuve.

Par exemple

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 secretVerdict
É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 GitOui, pour le développement et les petits déploiements
Dans les variables d'environnement du serveur ou du conteneurOui
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 :

ini
# 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
<?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 :

text
# .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

DirectiveValeur en productionPourquoi
display_errorsOffUn message d'erreur révèle des chemins, des noms de tables, parfois des requêtes
log_errorsOnLes erreurs vont dans un journal, lu par l'équipe
error_log/var/log/php/pretmat.logUn fichier hors de la racine du site
error_reportingE_ALLOn veut tout savoir, mais dans le journal
expose_phpOffSupprime 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_mode1Si les options ne sont pas passées à session_start() (chapitre 6)
session.cookie_samesiteLaxIdem
upload_max_filesize, post_max_sizeSelon le besoinchapitre 13
date.timezoneEurope/ParisDes dates justes, sans dépendre du serveur
opcache.enable1Le 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
<?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énementExemple de ligne
Échec de connexion2026-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
<?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.

sql
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, CREATE ni GRANT : 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 DELETE sur la table emprunt pour 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
<?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 audit liste celles qui ont une faille connue, et composer outdated celles 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érifierChapitre
EntréesChaque donnée reçue ($_GET, $_POST, $_COOKIE, $_FILES, JSON) est validée : type, format, longueur, liste blanche4
AffichageChaque donnée affichée passe par htmlspecialchars() (ou e()) ; le contexte JavaScript ou URL a son traitement5
SessionCookie HttpOnly, Secure, SameSite=Lax ; session_regenerate_id(true) à la connexion ; expiration ; déconnexion en POST6
Base de donnéesRequêtes préparées partout ; noms de colonnes par liste blanche ; compte MySQL aux droits minimaux7
ErreursMessages neutres à l'écran, détail dans le journal ; display_errors = Off8 et 15
Mots de passepassword_hash() et password_verify() ; message de connexion unique ; tentatives limitées9
Contrôle d'accèsChaque action vérifie la connexion, le rôle et la propriété de la donnée (pas d'IDOR)9
CSRFJeton sur chaque formulaire POST, vérifié côté serveur ; aucune modification en GET9
Fichiers déposésType réel vérifié, nom aléatoire, stockage hors de la racine du site13
APIAuthentification par jeton, représentation explicite (pas de SELECT * exposé), codes de statut justes, CORS restreint14
ConfigurationRacine du site sur public/ ; secrets hors du code et hors de Git ; HTTPS et HSTS ; PHP et dépendances à jour12 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
<?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 :

  1. 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.
  2. 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.
  3. Déplacer la configuration hors de public/ et lire les paramètres avec getenv(), depuis un .env ajouté au .gitignore, avec un .env.example sans valeurs réelles.
  4. 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à.
  5. Régler display_errors = Off en 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ête X-Powered-By annonce 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 (avec post_max_size à 8M) limite le risque de saturation du disque. Le contrôle fin reste dans le code.
  • log_errors => Off : à passer à On, avec un error_log hors 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
sql
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é ?

Chapitre terminé !

Fais les 3 exercices avant de passer à la suite : c'est en t'entraînant que tout se fixe.

chapitres
–
de série
–
du cours
–
Chapitre suivant : Exercices PHP corrigés type E6 et E7Revoir le sommaire du cours