churust_core/security.rs
1//! Security response headers, applied to every response by default.
2//!
3//! Most frameworks leave these to the application, which means most
4//! applications ship without them. Churust sends a conservative set unless told
5//! otherwise, and always yields to a handler that set the header itself.
6//!
7//! "Every response" is meant literally, and that takes two mechanisms rather
8//! than one. Most responses come out of the pipeline, where
9//! `SecurityHeadersMiddleware` decorates them. A few do not: a body refused on
10//! its declared `Content-Length` before dispatch, the RFC 9112 §6.3 refusal, a
11//! request that outran `request_timeout_ms` so the pipeline never produced
12//! anything at all. Those are answered by the transport, and each transport
13//! applies the same set to whatever it is about to write — see
14//! `App::apply_security_headers`. Applying twice is deliberately harmless,
15//! because a header already present is left alone.
16//!
17//! What is genuinely out of reach is a response Churust never composed: hyper
18//! answering `431` for a header block over `max_headers`, or a malformed
19//! request line that never became a request. Nothing in this crate sees those.
20//!
21//! ```
22//! use churust_core::{Churust, SecurityHeaders};
23//!
24//! # fn build() {
25//! // Customise:
26//! Churust::server().security_headers(
27//! SecurityHeaders::new().referrer_policy(Some("strict-origin-when-cross-origin")),
28//! );
29//!
30//! // Or opt out entirely:
31//! Churust::server().without_security_headers();
32//! # }
33//! ```
34
35use crate::call::Call;
36use crate::pipeline::{Middleware, Next};
37use crate::response::Response;
38use async_trait::async_trait;
39use http::header::{
40 HeaderName, CONTENT_SECURITY_POLICY, REFERRER_POLICY, STRICT_TRANSPORT_SECURITY,
41 X_CONTENT_TYPE_OPTIONS, X_FRAME_OPTIONS,
42};
43use http::HeaderValue;
44
45/// Which security headers to add, and with what values.
46///
47/// `None` for any field disables that header. Defaults:
48///
49/// | Header | Default |
50/// | --- | --- |
51/// | `X-Content-Type-Options` | `nosniff` |
52/// | `X-Frame-Options` | `DENY` |
53/// | `Referrer-Policy` | `no-referrer` |
54/// | `Strict-Transport-Security` | `max-age=31536000`, **only when TLS is configured** |
55/// | `Permissions-Policy` | sensors and high-risk features locked off (see below) |
56/// | `Cross-Origin-Resource-Policy` | `same-origin` |
57/// | `Content-Security-Policy` | off |
58///
59/// There is no default Content-Security-Policy on purpose: a useful one is
60/// application-specific, and a generic one either breaks pages or is so
61/// permissive that it implies protection it does not give.
62///
63/// The default `Permissions-Policy` disables camera, microphone, geolocation,
64/// payment, USB, and interest-cohort (FLoC) access. JSON APIs never need those
65/// browser features, and an HTML app that does can override the header.
66///
67/// `Cross-Origin-Resource-Policy: same-origin` blocks no-cors cross-origin
68/// reads (Spectre-class resource loading). Cross-origin clients that use
69/// CORS still work; only opaque cross-origin embedding is refused.
70#[derive(Debug, Clone)]
71pub struct SecurityHeaders {
72 content_type_options: Option<String>,
73 frame_options: Option<String>,
74 referrer_policy: Option<String>,
75 hsts: Option<String>,
76 csp: Option<String>,
77 permissions_policy: Option<String>,
78 cross_origin_resource_policy: Option<String>,
79}
80
81impl Default for SecurityHeaders {
82 fn default() -> Self {
83 Self {
84 content_type_options: Some("nosniff".into()),
85 frame_options: Some("DENY".into()),
86 referrer_policy: Some("no-referrer".into()),
87 hsts: Some("max-age=31536000".into()),
88 csp: None,
89 permissions_policy: Some(
90 "camera=(), microphone=(), geolocation=(), payment=(), usb=(), interest-cohort=()"
91 .into(),
92 ),
93 cross_origin_resource_policy: Some("same-origin".into()),
94 }
95 }
96}
97
98impl SecurityHeaders {
99 /// The default set. Equivalent to [`SecurityHeaders::default`].
100 pub fn new() -> Self {
101 Self::default()
102 }
103
104 /// Set or disable `X-Content-Type-Options`.
105 pub fn content_type_options(mut self, v: Option<&str>) -> Self {
106 self.content_type_options = v.map(Into::into);
107 self
108 }
109
110 /// Set or disable `X-Frame-Options`.
111 pub fn frame_options(mut self, v: Option<&str>) -> Self {
112 self.frame_options = v.map(Into::into);
113 self
114 }
115
116 /// Set or disable `Referrer-Policy`.
117 pub fn referrer_policy(mut self, v: Option<&str>) -> Self {
118 self.referrer_policy = v.map(Into::into);
119 self
120 }
121
122 /// Set or disable `Strict-Transport-Security`.
123 ///
124 /// Only sent when the response is known to be going out over TLS.
125 /// Announcing HSTS over plaintext tells a client nothing it can trust, and
126 /// behind a terminating proxy it can pin a hostname to HTTPS the origin
127 /// does not actually serve.
128 ///
129 /// Two things establish that knowledge. Over TCP it is
130 /// [`AppBuilder::tls`](crate::AppBuilder::tls): this process holding the
131 /// certificate is what rules out a plaintext hop in front of it. Over
132 /// HTTP/3 nothing needs to be configured, because QUIC has no plaintext
133 /// mode — `server_config_from_pem` pins TLS 1.3 — so an h3 response is
134 /// encrypted whether or not the builder was ever told about a certificate.
135 pub fn hsts(mut self, v: Option<&str>) -> Self {
136 self.hsts = v.map(Into::into);
137 self
138 }
139
140 /// Set a `Content-Security-Policy`. Off by default.
141 pub fn content_security_policy(mut self, v: Option<&str>) -> Self {
142 self.csp = v.map(Into::into);
143 self
144 }
145
146 /// Set or disable `Permissions-Policy` (formerly Feature-Policy).
147 pub fn permissions_policy(mut self, v: Option<&str>) -> Self {
148 self.permissions_policy = v.map(Into::into);
149 self
150 }
151
152 /// Set or disable `Cross-Origin-Resource-Policy`.
153 ///
154 /// Default `same-origin`. Use `cross-origin` only when you deliberately
155 /// serve assets for no-cors embedding from other sites.
156 pub fn cross_origin_resource_policy(mut self, v: Option<&str>) -> Self {
157 self.cross_origin_resource_policy = v.map(Into::into);
158 self
159 }
160
161 /// Fill in whichever of these headers a response does not already carry.
162 ///
163 /// Split out of the middleware so the transports can reuse it verbatim.
164 /// Responses that never reach the pipeline — a body refused on its declared
165 /// length, a request that outran its deadline — used to arrive bare because
166 /// the only implementation of this lived inside a `Middleware::handle` they
167 /// never passed through. One function, called from both places, is what
168 /// keeps the two sets from drifting apart.
169 ///
170 /// `over_tls` answers "is this response definitely encrypted on the way
171 /// out", which is the only question [`hsts`](Self::hsts) depends on.
172 ///
173 /// Idempotent by construction: every header is skipped when already
174 /// present, which is the same rule that lets a handler override a default,
175 /// so calling it twice on one response cannot change the result.
176 pub(crate) fn apply_to(&self, headers: &mut http::HeaderMap, over_tls: bool) {
177 let mut set = |name: HeaderName, value: &Option<String>| {
178 let Some(v) = value else { return };
179 // The application wins. A handler that set this header did so on
180 // purpose, and silently overwriting it would be a trap.
181 if headers.contains_key(&name) {
182 return;
183 }
184 if let Ok(hv) = HeaderValue::from_str(v) {
185 headers.insert(name, hv);
186 }
187 };
188
189 set(X_CONTENT_TYPE_OPTIONS, &self.content_type_options);
190 set(X_FRAME_OPTIONS, &self.frame_options);
191 set(REFERRER_POLICY, &self.referrer_policy);
192 set(CONTENT_SECURITY_POLICY, &self.csp);
193 // Not in `http::header` constants yet on our MSRV floor; static names
194 // are stable header tokens.
195 set(
196 HeaderName::from_static("permissions-policy"),
197 &self.permissions_policy,
198 );
199 set(
200 HeaderName::from_static("cross-origin-resource-policy"),
201 &self.cross_origin_resource_policy,
202 );
203 if over_tls {
204 set(STRICT_TRANSPORT_SECURITY, &self.hsts);
205 }
206 }
207
208 pub(crate) fn into_middleware(self, tls_enabled: bool) -> SecurityHeadersMiddleware {
209 SecurityHeadersMiddleware {
210 cfg: self,
211 tls_enabled,
212 }
213 }
214}
215
216pub(crate) struct SecurityHeadersMiddleware {
217 cfg: SecurityHeaders,
218 tls_enabled: bool,
219}
220
221#[async_trait]
222impl Middleware for SecurityHeadersMiddleware {
223 async fn handle(&self, call: Call, next: Next) -> Response {
224 let mut res = next.run(call).await;
225 // `tls_enabled` is all the pipeline knows: it runs identically on every
226 // transport, so it cannot tell an h3 request from a plaintext one. The
227 // transport fills that in afterwards, which is why HSTS over h3 is
228 // added by `send_response` rather than here.
229 self.cfg.apply_to(&mut res.headers, self.tls_enabled);
230 res
231 }
232}