rullst_core/security/
headers.rs1use axum::{
4 extract::Request,
5 http::{HeaderValue, header},
6 middleware::Next,
7 response::Response,
8};
9use base64::{Engine, engine::general_purpose::STANDARD};
10use rand::Rng;
11
12pub use crate::config::DEFAULT_CSP_TEMPLATE;
13
14const DEFAULT_STATIC_CSP: &str = "default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; form-action 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; font-src 'self'; worker-src 'self' blob:";
15
16#[derive(Clone, Debug, Eq, PartialEq)]
21pub struct CspNonce(String);
22
23impl CspNonce {
24 pub fn generate() -> Self {
26 let mut bytes = [0_u8; 16];
27 rand::rng().fill_bytes(&mut bytes);
28 Self(STANDARD.encode(bytes))
29 }
30
31 pub fn as_str(&self) -> &str {
33 &self.0
34 }
35
36 pub fn get_or_insert(extensions: &mut axum::http::Extensions) -> Self {
41 if let Some(nonce) = extensions.get::<Self>() {
42 return nonce.clone();
43 }
44
45 let nonce = Self::generate();
46 extensions.insert(nonce.clone());
47 nonce
48 }
49}
50
51impl std::fmt::Display for CspNonce {
52 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
53 formatter.write_str(self.as_str())
54 }
55}
56
57pub fn render_csp_policy(template: Option<&str>, nonce: Option<&CspNonce>) -> String {
62 let template = template.filter(|value| !value.trim().is_empty());
63 match (template, nonce) {
64 (Some(template), Some(nonce)) => template.replace("{NONCE}", nonce.as_str()),
65 (Some(template), None) if !template.contains("{NONCE}") => template.to_owned(),
66 (None, Some(nonce)) => DEFAULT_CSP_TEMPLATE.replace("{NONCE}", nonce.as_str()),
67 _ => DEFAULT_STATIC_CSP.to_owned(),
68 }
69}
70
71pub async fn headers_middleware(mut req: Request, next: Next) -> Response {
73 let configured_csp = req
74 .extensions()
75 .get::<crate::config::SecurityConfig>()
76 .map(|config| config.csp.clone())
77 .unwrap_or_else(|| crate::config::RullstConfig::global().security.csp.clone());
78 let configured_coep = req
79 .extensions()
80 .get::<crate::config::SecurityConfig>()
81 .map(|config| config.coep.clone())
82 .unwrap_or_else(|| crate::config::RullstConfig::global().security.coep.clone());
83 let nonce = CspNonce::get_or_insert(req.extensions_mut());
87
88 let mut response = next.run(req).await;
89 let headers = response.headers_mut();
90
91 headers
94 .entry(header::CACHE_CONTROL)
95 .or_insert(HeaderValue::from_static("no-store"));
96 headers.insert(header::X_FRAME_OPTIONS, HeaderValue::from_static("DENY"));
97 headers.insert(
98 header::X_CONTENT_TYPE_OPTIONS,
99 HeaderValue::from_static("nosniff"),
100 );
101 headers.insert("x-xss-protection", HeaderValue::from_static("0"));
103 headers.insert(
104 header::REFERRER_POLICY,
105 HeaderValue::from_static("strict-origin-when-cross-origin"),
106 );
107 headers.insert(
108 header::STRICT_TRANSPORT_SECURITY,
109 HeaderValue::from_static("max-age=63072000; includeSubDomains; preload"),
110 );
111 headers.insert(
112 "permissions-policy",
113 HeaderValue::from_static("camera=(), microphone=(), geolocation=(), payment=(), usb=()"),
114 );
115 headers.insert(
116 "cross-origin-opener-policy",
117 HeaderValue::from_static("same-origin"),
118 );
119 headers.insert(
120 "cross-origin-resource-policy",
121 HeaderValue::from_static("same-origin"),
122 );
123 headers.insert(
124 "cross-origin-embedder-policy",
125 HeaderValue::from_str(&configured_coep)
126 .unwrap_or_else(|_| HeaderValue::from_static("require-corp")),
127 );
128
129 let csp = render_csp_policy(Some(&configured_csp), Some(&nonce));
130 let csp_value = HeaderValue::from_str(&csp).unwrap_or_else(|_| {
131 HeaderValue::from_str(&render_csp_policy(None, Some(&nonce)))
132 .unwrap_or_else(|_| HeaderValue::from_static("default-src 'none'"))
133 });
134 headers.insert(header::CONTENT_SECURITY_POLICY, csp_value);
135
136 response
137}