Sécurité
Sécurité
CSRF, CORS, limitation de débit et en-têtes de sécurité — appliqués par défaut, pas à activer manuellement sur chaque route.
CSRF
Niang\Core\Csrf génère un jeton par session (32 octets aléatoires) et le compare en
temps constant (hash_equals()) :
<form method="POST" action="/contact">
<?= csrf_field() ?>
<!-- ... -->
</form>
csrf_field() imprime <input type="hidden" name="_token"
value="...">. App\Middleware\VerifyCsrfToken vérifie
_token (formulaire) ou l'en-tête X-CSRF-Token (requête JS) sur
POST/PUT/PATCH/DELETE — jamais sur
GET, qui ne doit rien modifier :
$router->post('/contact', [ContactController::class, 'store'], [VerifyCsrfToken::class]);
Un jeton absent ou invalide répond 419 (pas 403 — un code dédié, pour distinguer une session expirée d'un refus d'autorisation) avec un message clair plutôt qu'une page d'erreur générique.
CORS
config/cors.php définit la politique ; Niang\Core\Cors calcule les
en-têtes (logique pure) et App\Middleware\HandleCors les applique en court-circuitant
le préflight :
return [
'allowed_origins' => ['*'], // en prod : ['https://app.example.com']
'allowed_methods' => ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
'allowed_headers' => ['Content-Type', 'Authorization', 'X-Requested-With'],
'exposed_headers' => [],
'supports_credentials' => false, // true seulement avec des origines explicites, jamais '*'
'max_age' => 0,
];
$router->group(['prefix' => '/api', 'middleware' => [HandleCors::class]], function ($router) {
$router->get('/posts', [PostController::class, 'apiIndex']);
$router->options('/posts', fn () => Response::html('', 204)); // requis : voir Routing#head-options
});
Chaque route API a besoin de sa propre route OPTIONS explicite : le Router
n'en génère aucune automatiquement (voir Routing), et
sans elle le préflight reçoit un 405 avant même d'atteindre
HandleCors.
supports_credentials: true force à refléter l'origine exacte de la requête plutôt
que * — les navigateurs rejettent la combinaison
Access-Control-Allow-Origin: * + credentials. Niang\Core\Cors va plus
loin : avec supports_credentials: true, '*' dans
allowed_origins n'autorise même plus la requête du tout — seules les origines
listées explicitement passent. Reflèter n'importe quelle origine tout en gardant les
identifiants de l'utilisateur actif reviendrait à désactiver CORS pour n'importe quel site.
APP_KEY
Le secret qui signe les URLs (réinitialisation de mot
de passe, vérification d'email), hache les jetons API et
chiffre les cookies. composer create-project crée
.env depuis .env.example avec une APP_KEY propre au projet,
niang new aussi ; sinon, ./bin/niang key:generate.
Sans APP_KEY, ces fonctions lèvent une ConfigurationException plutôt que
d'utiliser une clé par défaut : une clé connue de tous permettrait de fabriquer un lien de
réinitialisation valide pour n'importe quel compte. Changer la clé invalide toutes les URLs signées,
tous les jetons API et tous les cookies en cours.
Cookies chiffrés
Cookie::set('panier', json_encode($lignes), 60 * 24 * 7); // minutes
Cookie::get('panier'); // la valeur, ou null (absent, modifié, expiré)
Cookie::forget('panier');
La valeur est chiffrée en AES-256-GCM (Niang\Core\Crypt, clé dérivée
d'APP_KEY) : le navigateur ne peut ni la lire ni la modifier. Le chiffré est lié au nom du
cookie — une valeur valide pour panier est refusée sous un autre nom — et porte sa propre
date d'expiration, vérifiée côté serveur. Crypt::encrypt($texte, $contexte) /
Crypt::decrypt($chiffre, $contexte) sont utilisables directement ; decrypt()
retourne null pour toute valeur modifiée ou chiffrée pour un autre contexte.
Le cookie de session PHP, lui, ne contient qu'un identifiant aléatoire : il n'est pas chiffré, et n'a pas besoin de l'être.
Limitation de débit
Niang\Core\RateLimiter compte sur fichier (storage/framework/ratelimits/,
pas de Redis requis). App\Middleware\ThrottleRequests l'applique par IP et par
route, 10 requêtes/minute par défaut :
$router->post('/login', [AuthController::class, 'login'], [VerifyCsrfToken::class, ThrottleRequests::class]);
Au-delà de la limite : 429 avec un en-tête Retry-After (secondes
avant la prochaine tentative autorisée). Une autre limite ailleurs ? Dupliquez la classe avec
vos propres valeurs — $maxAttempts/$decaySeconds sont volontairement
codées en dur dans la classe, pas dans un système de configuration générique.
En-têtes de sécurité et CSP
Appliqués à toutes les réponses par Application::applySecurityHeaders()
(voir cycle de vie d'une requête) — sûr par défaut plutôt
que de compter sur chaque route :
return [
'headers' => [
'X-Frame-Options' => 'DENY',
'X-Content-Type-Options' => 'nosniff',
'Referrer-Policy' => 'strict-origin-when-cross-origin',
'Content-Security-Policy' => "default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:",
'Strict-Transport-Security' => 'max-age=31536000; includeSubDomains',
],
];
La CSP par défaut interdit tout script inline (pas de script-src 'unsafe-inline') —
c'est pourquoi le design system partagé (niang.css/niang.js, et cette
documentation elle-même) charge tout depuis des fichiers .js/.css
externes plutôt que d'écrire du JavaScript inline dans le HTML. style-src
'unsafe-inline' reste autorisé, pour les attributs style="" ponctuels des
thèmes de démonstration. La liste est un tableau ordinaire dans un fichier de config : ajustez,
ajoutez ou retirez une entrée librement, rien à réenregistrer ailleurs.