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()) :

php
<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 :

php
$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 :

config/cors.php
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,
];
php
$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

php
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 :

php
$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 :

config/security.php
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.

⏱ 7.74 ms 🗄 0 requête(s) SQL 🧠 4.00 MB ↩ 200