Expand description
umbral-security — CSRF protection and a configurable security-header bundle.
CSRF protection plus a security-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 (see
docs/decisions/2026-06-10-automatic-csrf.md):
- 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_tokencookie to the response. The cookie is NOT HttpOnly: the page’s JS reads it and copies it into a header on later writes. - Templates get the token for free. The token is scoped into
umbral::templates::CURRENT_CSRFaround every non-exempt request, so any rendered template can write{{ csrf_input }}(the full hidden input) or{{ csrf_token }}(raw value, forX-CSRF-Tokenheaders / htmxhx-headers). View code never touches CSRF. - Every POST / PUT / PATCH / DELETE must include the cookie AND a matching
X-CSRF-Tokenheader (JS path) orcsrf_token/__csrfform 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, 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.
Structs§
- Security
Config - Declarative security configuration. Build from
Default(secure, dev-safe) and override the fields you need — see the crate docs for the rationale behind each default. - Security
Plugin - CSRF + security-headers plugin. Configure via
SecurityConfig.
Functions§
- current_
csrf_ token - Read the current CSRF token from the request’s cookie header. Public so
handlers that render HTML forms can embed it as a hidden
csrf_tokeninput. - generate_
token - Generate a fresh 32-byte token, hex-encoded. Public so tests and downstream code that mints tokens directly (e.g. server-rendered forms) share the same shape. Raw (unsigned) — the signed wrapper is applied by the middleware.
- tokens_
match - Constant-time string equality. Short-circuit
==onStringis a timing side-channel;ct_eqcloses it. Per OWASP’s “Use Constant-Time String Comparison” rule for security tokens. Public so other token consumers (e.g. the admin’s SecurityPlugin-less login fallback) compare the same way.