umbral-security 0.0.1

Security headers + CSRF middleware plugin for umbral.
Documentation

umbral-security — CSRF protection and a configurable security-header bundle.

Django's CsrfViewMiddleware plus the SecurityMiddleware header bundle, widened to the modern header set. Plug it into the app and every non-safe request must carry a matching CSRF token; every response gets the hardening headers you've enabled.

App::builder()
    .plugin(AuthPlugin::new())
    .plugin(SecurityPlugin::new())   // secure-but-dev-safe defaults
    .build()
    .await?;

Configuration is a struct, not a builder chain

Construct a [SecurityConfig] (every field has a secure, dev-safe default) and flip exactly what you need — no long .with_x().with_y() chain:

SecurityPlugin::with_config(SecurityConfig {
    hsts: true,
    content_security_policy: Some("default-src 'self'".into()),
    server_header: Some("umbral".into()),
    request_body_limit: Some(2 * 1024 * 1024),
    ..Default::default()
})

SecurityPlugin::new() keeps the defaults; SecurityPlugin::with_hsts(true) stays as a one-flag convenience.

CSRF

Signed double-submit cookie pattern, fully automatic (the Django CsrfViewMiddleware + {% csrf_token %} split — see docs/decisions/2026-06-10-automatic-csrf.md):

  1. The middleware is the only mint. On GET / HEAD / OPTIONS it mints a token before the handler runs (first visit covered) and appends the umbral_csrf_token cookie to the response. The cookie is NOT HttpOnly: the page's JS reads it and copies it into a header on later writes.
  2. Templates get the token for free. The token is scoped into umbral::templates::CURRENT_CSRF around every non-exempt request, so any rendered template can write {{ csrf_input }} (the full hidden input) or {{ csrf_token }} (raw value, for X-CSRF-Token headers / htmx hx-headers). View code never touches CSRF.
  3. Every POST / PUT / PATCH / DELETE must include the cookie AND a matching X-CSRF-Token header (JS path) or csrf_token / __csrf form field (HTML-form path). A mismatch returns 403. On success the token stays in scope so a validation-error re-render still carries it into the form.

The token is a 32-byte CSPRNG value, hex-encoded. The CSRF cookie gains Secure automatically under Environment::Prod (or force it with [SecurityConfig::csrf_cookie_secure]).

Signed / session-bound CSRF ([SecurityConfig::signed_csrf])

Naive double-submit trusts the cookie: an attacker who can plant a cookie on a sibling subdomain can forge a matching token. signed_csrf (default on) makes the token <random>.<HMAC-SHA256(secret_key, random[.session])> — a forged cookie can't carry a valid signature without the app secret_key. Set [SecurityConfig::session_bind_cookie] to also fold the session cookie's value into the signature so a token minted under one session can't be replayed under another.

The flip to default-on is deploy-safe because the middleware rotates any cookie token that can't pass signed-mode validation on the next safe request (browsers holding pre-upgrade unsigned cookies converge instead of 403ing), and because no other mint exists: the admin prefers the ambient middleware token and only self-mints when this plugin isn't mounted. With no resolvable secret_key (tests, pre-App::build() renders) minting and validation degrade to plain double-submit instead of locking writes out. Opt back into plain double-submit with signed_csrf: false.

Headers

Enabled by default: X-Content-Type-Options: nosniff, X-Frame-Options: DENY, Referrer-Policy: strict-origin-when-cross-origin, X-XSS-Protection: 0 (modern guidance disables the legacy auditor), Cross-Origin-Opener-Policy: same-origin (matches Django 4.0+), and a Server: umbral header. Opt-in (default off, each a field on [SecurityConfig]): Strict-Transport-Security, Content-Security-Policy, Permissions-Policy, Cross-Origin-Resource-Policy, Cross-Origin-Embedder-Policy. CSP and HSTS are off by default because a wrong value breaks apps (HSTS bricks http:// dev; a strict CSP breaks the CDN-using admin).

Server identity & tower-http knobs

[SecurityConfig::server_header] sets the Server header (prefer a bare product name — a version is an information-disclosure tradeoff); [SecurityConfig::hide_server_header] strips whatever the stack added. [SecurityConfig::request_body_limit] caps the request body via tower-http's RequestBodyLimitLayer (DoS hardening); [SecurityConfig::redact_sensitive_headers] (default on) marks authorization / cookie / set-cookie sensitive so they're redacted in tracing output.

Why this lives in Plugin::wrap_router

Layering middleware needs a tower::Layer value; the Plugin trait's wrap_router(Router) -> Router lets each plugin layer its middleware with the full axum / tower API. The app builder calls it in topological order so security wraps everything declared before it.