Security

Security

CSRF, CORS, rate limiting and security headers — applied by default, not something to switch on manually on every route.

CSRF

Niang\Core\Csrf generates one token per session (32 random bytes) and compares it in constant time (hash_equals()):

php
<form method="POST" action="/contact">
    <?= csrf_field() ?>
    <!-- ... -->
</form>

csrf_field() prints <input type="hidden" name="_token" value="...">. App\Middleware\VerifyCsrfToken checks _token (form) or the X-CSRF-Token header (JS request) on POST/PUT/PATCH/DELETE — never on GET, which must not change anything:

php
$router->post('/contact', [ContactController::class, 'store'], [VerifyCsrfToken::class]);

A missing or invalid token responds with 419 (not 403 — a dedicated code, to tell an expired session apart from an authorization refusal) with a clear message rather than a generic error page.

CORS

config/cors.php defines the policy; Niang\Core\Cors computes the headers (pure logic) and App\Middleware\HandleCors applies them, short-circuiting the preflight:

config/cors.php
return [
    'allowed_origins' => ['*'],   // in production: ['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 only with explicit origins, never '*'
    '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)); // required: see Routing#head-options
});

Every API route needs its own explicit OPTIONS route: the Router does not generate any automatically (see Routing), and without one the preflight gets a 405 before it even reaches HandleCors.

supports_credentials: true forces the exact origin of the request to be reflected rather than * — browsers reject the Access-Control-Allow-Origin: * + credentials combination. Niang\Core\Cors goes further: with supports_credentials: true, '*' in allowed_origins no longer allows the request at all — only explicitly listed origins get through. Reflecting any origin while keeping the active user's credentials would amount to disabling CORS for every site.

APP_KEY

The secret that signs URLs (password reset, email verification), hashes API tokens and encrypts cookies. composer create-project creates .env from .env.example with an APP_KEY unique to the project, and so does niang new; otherwise, ./bin/niang key:generate.

Without APP_KEY, these features throw a ConfigurationException rather than using a default key: a key known to everyone would let anyone forge a valid reset link for any account. Changing the key invalidates every outstanding signed URL, API token and cookie.

Encrypted cookies

php
Cookie::set('cart', json_encode($lines), 60 * 24 * 7); // minutes
Cookie::get('cart');          // the value, or null (missing, tampered with, expired)
Cookie::forget('cart');

The value is encrypted with AES-256-GCM (Niang\Core\Crypt, key derived from APP_KEY): the browser can neither read nor modify it. The ciphertext is bound to the cookie's name — a value valid for cart is rejected under another name — and carries its own expiry date, checked server-side. Crypt::encrypt($text, $context) / Crypt::decrypt($ciphertext, $context) can be used directly; decrypt() returns null for any value that was modified or encrypted for another context.

PHP's session cookie only holds a random identifier: it is not encrypted, and does not need to be.

Rate limiting

Niang\Core\RateLimiter counts on disk (storage/framework/ratelimits/, no Redis required). App\Middleware\ThrottleRequests applies it per IP and per route, 10 requests/minute by default:

php
$router->post('/login', [AuthController::class, 'login'], [VerifyCsrfToken::class, ThrottleRequests::class]);

Beyond the limit: 429 with a Retry-After header (seconds until the next allowed attempt). Need a different limit elsewhere? Duplicate the class with your own values — $maxAttempts/$decaySeconds are deliberately hard-coded in the class, not in a generic configuration system.

Security headers and CSP

Applied to every response by Application::applySecurityHeaders() (see request lifecycle) — secure by default rather than relying on each 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',
    ],
];

The default CSP forbids any inline script (no script-src 'unsafe-inline') — which is why the shared design system (niang.css/niang.js, and this documentation itself) loads everything from external .js/.css files rather than writing inline JavaScript in the HTML. style-src 'unsafe-inline' remains allowed, for the occasional style="" attributes of the demo themes. The list is an ordinary array in a config file: adjust, add or remove an entry freely, nothing to re-register anywhere else.

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