Docs
Headers and CSP
Set frame policy, HSTS, and a nonce-based Content Security Policy.
Framework responses get baseline hardening headers automatically, including a DENY frame policy. HSTS and CSP are configured here but off by default, because each deployment needs its own.
use proa_framework_axum::{FrameOptions, SecurityHeadersConfig};
let security = SecurityHeadersConfig::new()
.with_frame_options(FrameOptions::SameOrigin)
.with_hsts_max_age_secs(63_072_000);
let app = FrameworkBuilder::new(manifest)
.with_security_headers(security)
.build();
| Setting | Options |
|---|---|
FrameOptions | Deny (default), SameOrigin, Disabled |
| HSTS | Off by default. Enable only when TLS terminates before the app |
| CSP | Off by default. Each app needs its own script, style, image, and connect policy |
Use a nonce, not a static policy
A static Content-Security-Policy string works only when the response has no inline scripts. RSJS islands, React islands, and out-of-order streaming each need a fresh nonce per response, so reach for the strict policy instead:
let app = FrameworkBuilder::new(manifest)
.with_strict_csp()
.build();
That mints a 128-bit nonce per request and sends:
base-uri 'self'; default-src 'self'; frame-ancestors 'none';
object-src 'none'; script-src 'nonce-…' 'strict-dynamic';
style-src 'self' 'unsafe-inline'
Directives are emitted in alphabetical order, so a captured header reads exactly like this.
Proa applies the same nonce to framework-emitted state, import-map, bootstrap, client-shell, and boundary-swap tags. So script-src needs no 'unsafe-inline' and no host allowlist.
Pairing the nonce with 'strict-dynamic' means a script loaded by an already-trusted script inherits trust. That is what makes bundler-emitted chunks work without enumerating CDN hosts.
Two things the layer cannot do for you. A raw <script> tag you write by hand needs an explicit nonce attribute, read from cx.csp_nonce() in a component or extracted with Extension<CspNonce> in a route handler (the layer stores the nonce in the request extensions). And inline event-handler attributes (onclick="…") never execute under any nonce policy, move them to an RSJS island or an addEventListener call inside a nonced script.
Tuning directives
Customize with CspPolicy:
use proa_framework_axum::CspPolicy;
let app = FrameworkBuilder::new(manifest)
.with_csp_policy(
CspPolicy::strict()
.with_directive("img-src", "'self' data: https://cdn.example")
.with_directive("connect-src", "'self' https://api.example")
.report_only(true),
)
.build();
report_only(true) sends Content-Security-Policy-Report-Only, which is how to roll a strict policy onto a live app. Violations are reported but nothing is blocked. Switch it off once the report stream is clean.
Style policy is separate from script policy. The default keeps style-src 'unsafe-inline' because inline style attributes are common and cannot execute code. Tighten it to 'self' once those styles live in classes.
Conflicts the builder warns about
The layer never overwrites a Content-Security-Policy header a handler already set, so a single route can opt out of the global policy.
Two combinations produce a startup warning:
with_strict_csp()together withwith_security_headers(..with_content_security_policy(..)). The static policy wins and the nonce directives are dropped. Pick one.FrameOptions::SameOriginorFrameOptions::Disabledwhile CSP still sendsframe-ancestors 'none'. RelaxingX-Frame-Optionsfor embedding does nothing ifframe-ancestorsstill blocks framing. Replace the directive with one naming the allowed embedders.
Next steps
- Security
- What Proa hardens by default, and what stays your job.
- Cross-origin and CSRF
- Reject forged cross-origin writes, and add the CSRF defense Proa does not.
- App configuration
- Configure FrameworkBuilder, security defaults, static mounts, external apps, and response behavior.
- Escaping and raw HTML
- How Proa encodes dynamic values, and the two ways to opt out.