Skip to main content

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/// Header names not yet on our MSRV floor of `http::header` constants.
46static PERMISSIONS_POLICY: HeaderName = HeaderName::from_static("permissions-policy");
47static CROSS_ORIGIN_RESOURCE_POLICY: HeaderName =
48    HeaderName::from_static("cross-origin-resource-policy");
49
50/// Parse a header value once at configuration time. Invalid tokens become
51/// `None` so a bad override cannot panic on every request.
52fn header_value(v: &str) -> Option<HeaderValue> {
53    HeaderValue::from_str(v).ok()
54}
55
56/// Which security headers to add, and with what values.
57///
58/// `None` for any field disables that header. Defaults:
59///
60/// | Header | Default |
61/// | --- | --- |
62/// | `X-Content-Type-Options` | `nosniff` |
63/// | `X-Frame-Options` | `DENY` |
64/// | `Referrer-Policy` | `no-referrer` |
65/// | `Strict-Transport-Security` | `max-age=31536000`, **only when TLS is configured** |
66/// | `Permissions-Policy` | sensors and high-risk features locked off (see below) |
67/// | `Cross-Origin-Resource-Policy` | `same-origin` |
68/// | `Content-Security-Policy` | off |
69///
70/// There is no default Content-Security-Policy on purpose: a useful one is
71/// application-specific, and a generic one either breaks pages or is so
72/// permissive that it implies protection it does not give.
73///
74/// The default `Permissions-Policy` disables camera, microphone, geolocation,
75/// payment, USB, and interest-cohort (FLoC) access. JSON APIs never need those
76/// browser features, and an HTML app that does can override the header.
77///
78/// `Cross-Origin-Resource-Policy: same-origin` blocks no-cors cross-origin
79/// reads (Spectre-class resource loading). Cross-origin clients that use
80/// CORS still work; only opaque cross-origin embedding is refused.
81///
82/// Values are parsed into [`HeaderValue`]s when the config is built, so the
83/// per-response path is only a contains-key check and an insert — not
84/// re-parsing the same constant strings on every request.
85#[derive(Debug, Clone)]
86pub struct SecurityHeaders {
87    content_type_options: Option<HeaderValue>,
88    frame_options: Option<HeaderValue>,
89    referrer_policy: Option<HeaderValue>,
90    hsts: Option<HeaderValue>,
91    csp: Option<HeaderValue>,
92    permissions_policy: Option<HeaderValue>,
93    cross_origin_resource_policy: Option<HeaderValue>,
94}
95
96impl Default for SecurityHeaders {
97    fn default() -> Self {
98        Self {
99            content_type_options: header_value("nosniff"),
100            frame_options: header_value("DENY"),
101            referrer_policy: header_value("no-referrer"),
102            hsts: header_value("max-age=31536000"),
103            csp: None,
104            permissions_policy: header_value(
105                "camera=(), microphone=(), geolocation=(), payment=(), usb=(), interest-cohort=()",
106            ),
107            cross_origin_resource_policy: header_value("same-origin"),
108        }
109    }
110}
111
112impl SecurityHeaders {
113    /// The default set. Equivalent to [`SecurityHeaders::default`].
114    pub fn new() -> Self {
115        Self::default()
116    }
117
118    /// Set or disable `X-Content-Type-Options`.
119    pub fn content_type_options(mut self, v: Option<&str>) -> Self {
120        self.content_type_options = v.and_then(header_value);
121        self
122    }
123
124    /// Set or disable `X-Frame-Options`.
125    pub fn frame_options(mut self, v: Option<&str>) -> Self {
126        self.frame_options = v.and_then(header_value);
127        self
128    }
129
130    /// Set or disable `Referrer-Policy`.
131    pub fn referrer_policy(mut self, v: Option<&str>) -> Self {
132        self.referrer_policy = v.and_then(header_value);
133        self
134    }
135
136    /// Set or disable `Strict-Transport-Security`.
137    ///
138    /// Only sent when the response is known to be going out over TLS.
139    /// Announcing HSTS over plaintext tells a client nothing it can trust, and
140    /// behind a terminating proxy it can pin a hostname to HTTPS the origin
141    /// does not actually serve.
142    ///
143    /// Two things establish that knowledge. Over TCP it is
144    /// [`AppBuilder::tls`](crate::AppBuilder::tls): this process holding the
145    /// certificate is what rules out a plaintext hop in front of it. Over
146    /// HTTP/3 nothing needs to be configured, because QUIC has no plaintext
147    /// mode — `server_config_from_pem` pins TLS 1.3 — so an h3 response is
148    /// encrypted whether or not the builder was ever told about a certificate.
149    pub fn hsts(mut self, v: Option<&str>) -> Self {
150        self.hsts = v.and_then(header_value);
151        self
152    }
153
154    /// Set a `Content-Security-Policy`. Off by default.
155    pub fn content_security_policy(mut self, v: Option<&str>) -> Self {
156        self.csp = v.and_then(header_value);
157        self
158    }
159
160    /// Set or disable `Permissions-Policy` (formerly Feature-Policy).
161    pub fn permissions_policy(mut self, v: Option<&str>) -> Self {
162        self.permissions_policy = v.and_then(header_value);
163        self
164    }
165
166    /// Set or disable `Cross-Origin-Resource-Policy`.
167    ///
168    /// Default `same-origin`. Use `cross-origin` only when you deliberately
169    /// serve assets for no-cors embedding from other sites.
170    pub fn cross_origin_resource_policy(mut self, v: Option<&str>) -> Self {
171        self.cross_origin_resource_policy = v.and_then(header_value);
172        self
173    }
174
175    /// Fill in whichever of these headers a response does not already carry.
176    ///
177    /// Split out of the middleware so the transports can reuse it verbatim.
178    /// Responses that never reach the pipeline — a body refused on its declared
179    /// length, a request that outran its deadline — used to arrive bare because
180    /// the only implementation of this lived inside a `Middleware::handle` they
181    /// never passed through. One function, called from both places, is what
182    /// keeps the two sets from drifting apart.
183    ///
184    /// `over_tls` answers "is this response definitely encrypted on the way
185    /// out", which is the only question [`hsts`](Self::hsts) depends on.
186    ///
187    /// Idempotent by construction: every header is skipped when already
188    /// present, which is the same rule that lets a handler override a default,
189    /// so calling it twice on one response cannot change the result.
190    pub(crate) fn apply_to(&self, headers: &mut http::HeaderMap, over_tls: bool) {
191        let mut set = |name: &HeaderName, value: &Option<HeaderValue>| {
192            let Some(v) = value else { return };
193            // The application wins. A handler that set this header did so on
194            // purpose, and silently overwriting it would be a trap.
195            if headers.contains_key(name) {
196                return;
197            }
198            headers.insert(name, v.clone());
199        };
200
201        set(&X_CONTENT_TYPE_OPTIONS, &self.content_type_options);
202        set(&X_FRAME_OPTIONS, &self.frame_options);
203        set(&REFERRER_POLICY, &self.referrer_policy);
204        set(&CONTENT_SECURITY_POLICY, &self.csp);
205        set(&PERMISSIONS_POLICY, &self.permissions_policy);
206        set(
207            &CROSS_ORIGIN_RESOURCE_POLICY,
208            &self.cross_origin_resource_policy,
209        );
210        if over_tls {
211            set(&STRICT_TRANSPORT_SECURITY, &self.hsts);
212        }
213    }
214
215    pub(crate) fn into_middleware(self, tls_enabled: bool) -> SecurityHeadersMiddleware {
216        SecurityHeadersMiddleware {
217            cfg: self,
218            tls_enabled,
219        }
220    }
221}
222
223pub(crate) struct SecurityHeadersMiddleware {
224    cfg: SecurityHeaders,
225    tls_enabled: bool,
226}
227
228#[async_trait]
229impl Middleware for SecurityHeadersMiddleware {
230    async fn handle(&self, call: Call, next: Next) -> Response {
231        let mut res = next.run(call).await;
232        // `tls_enabled` is all the pipeline knows: it runs identically on every
233        // transport, so it cannot tell an h3 request from a plaintext one. The
234        // transport fills that in afterwards, which is why HSTS over h3 is
235        // added by `send_response` rather than here.
236        self.cfg.apply_to(&mut res.headers, self.tls_enabled);
237        res
238    }
239}