Skip to main content

rama/cli/service/
http_security.rs

1//! Shared defence-in-depth HTTP response-header layer used by every
2//! HTML-emitting service that ships with rama (fingerprint service, http
3//! test service, public IP page).
4//!
5//! The defaults are intentionally strict so an XSS that slips past the
6//! escaping pipeline cannot, on its own, exfiltrate data or be reframed
7//! by a third party. Each call-site can widen the policy with
8//! [`ContentSecurityPolicy::with`] before passing it to
9//! [`defence_in_depth_layer`].
10
11use crate::http::{
12    HeaderValue,
13    headers::{
14        ContentSecurityPolicy, HostSource, ReferrerPolicy, SourceList, XContentTypeOptions,
15        XFrameOptions,
16    },
17    layer::set_header::{
18        SetResponseHeaderLayer,
19        response::{MakeHeaderValueDefault, TypedHeaderAsMaker},
20    },
21};
22use crate::net::{Protocol, address::Domain};
23
24/// Convenience: build the strict-self CSP, widened only with the image
25/// hosts every rama-shipped HTML page needs (the inline favicon SVG via
26/// `data:` and the GitHub-hosted banner image). Frame ancestry is denied
27/// upstream by [`XFrameOptions::Deny`] and the policy's own
28/// `frame-ancestors 'none'`.
29///
30/// The fingerprint service further extends this with `connect-src 'self'`
31/// for the same-origin WebSocket on `/api/ws`; that addition is
32/// scheme-aware (`'self'` covers `ws:`/`wss:` to the same origin per
33/// CSP3) and is therefore *not* a separate `ws:`/`wss:` scheme
34/// allow-list.
35#[must_use]
36pub fn rama_html_csp() -> ContentSecurityPolicy {
37    ContentSecurityPolicy::strict_self().with_img_src(
38        SourceList::self_origin().with_data().with_host(
39            HostSource::new(Domain::from_static("raw.githubusercontent.com"))
40                .with_scheme(Protocol::HTTPS),
41        ),
42    )
43}
44
45/// Build the standard defence-in-depth response-header layer stack.
46///
47/// Sets `Content-Security-Policy`, `X-Content-Type-Options`,
48/// `Referrer-Policy`, and `X-Frame-Options` — each `if-not-present`, so
49/// upstream services can override them per-response when needed (e.g.
50/// for a `/healthz` JSON endpoint that has its own posture).
51///
52/// The returned value is itself a tuple of layers and is composed into
53/// the surrounding middleware stack the same way as any other layer.
54pub fn defence_in_depth_layer(
55    csp: ContentSecurityPolicy,
56) -> (
57    SetResponseHeaderLayer<Option<HeaderValue>>,
58    SetResponseHeaderLayer<MakeHeaderValueDefault<TypedHeaderAsMaker<XContentTypeOptions>>>,
59    SetResponseHeaderLayer<Option<HeaderValue>>,
60    SetResponseHeaderLayer<Option<HeaderValue>>,
61) {
62    (
63        // CSP carries per-page state, so we go through `if_not_present_typed`
64        // with a built value rather than `_default_typed`.
65        SetResponseHeaderLayer::if_not_present_typed(csp),
66        SetResponseHeaderLayer::<XContentTypeOptions>::if_not_present_default_typed(),
67        SetResponseHeaderLayer::if_not_present_typed(ReferrerPolicy::NO_REFERRER),
68        SetResponseHeaderLayer::if_not_present_typed(XFrameOptions::Deny),
69    )
70}
71
72#[cfg(test)]
73mod tests {
74    //! Layer-level regression tests for the defence-in-depth response
75    //! headers. Each test wires the layer onto a tiny inner service that
76    //! returns an empty 200 response, then asserts what the user agent
77    //! ends up seeing.
78    //!
79    //! The wrapping uses rama's own [`Layer`] / [`Service`] traits, so
80    //! these tests fail if either:
81    //!  * the helper's directive choices change in a way that surprises
82    //!    a downstream caller, or
83    //!  * any of the four typed-header impls regress in their encoded
84    //!    form.
85
86    use super::*;
87    use crate::{
88        Layer, Service,
89        http::{
90            Body, Request, Response,
91            headers::HeaderMapExt as _,
92            service::web::{IntoEndpointService, response::IntoResponse},
93        },
94        service::service_fn,
95    };
96    use std::convert::Infallible;
97
98    async fn invoke(csp: ContentSecurityPolicy) -> crate::http::HeaderMap {
99        let svc = defence_in_depth_layer(csp).into_layer(
100            service_fn(async || Ok::<_, Infallible>(Response::new(Body::empty())))
101                .into_endpoint_service(),
102        );
103        let resp = svc
104            .serve(Request::new(Body::empty()))
105            .await
106            .expect("infallible service");
107        resp.headers().clone()
108    }
109
110    /// All four hardening headers must land on a baseline response,
111    /// using their typed-header canonical encodings.
112    #[tokio::test]
113    async fn baseline_strict_self_emits_all_four_headers() {
114        let headers = invoke(rama_html_csp()).await;
115
116        let csp: ContentSecurityPolicy = headers.typed_get().expect("CSP set");
117        let rendered = csp.to_string();
118        // The strict-self baseline is preserved end-to-end; only `img-src`
119        // is widened, and the other directives remain `'self'`-only.
120        assert!(rendered.contains("default-src 'self'"), "{rendered}");
121        assert!(rendered.contains("script-src 'self'"), "{rendered}");
122        assert!(rendered.contains("frame-ancestors 'none'"), "{rendered}");
123        assert!(
124            rendered.contains("img-src 'self' data: https://raw.githubusercontent.com"),
125            "{rendered}",
126        );
127        // Crucially: no `ws:` / `wss:` scheme widening — `connect-src`
128        // is *not* in the baseline at all, and any caller that needs WS
129        // adds it explicitly as same-origin `'self'` (which is
130        // scheme-aware in CSP3 and covers same-origin WebSocket).
131        assert!(!rendered.contains("ws:"), "{rendered}");
132        assert!(!rendered.contains("wss:"), "{rendered}");
133
134        let xcto: XContentTypeOptions = headers.typed_get().expect("X-Content-Type-Options set");
135        assert_eq!(xcto, XContentTypeOptions::nosniff());
136
137        let rp: ReferrerPolicy = headers.typed_get().expect("Referrer-Policy set");
138        assert_eq!(rp, ReferrerPolicy::NO_REFERRER);
139
140        let xfo: XFrameOptions = headers.typed_get().expect("X-Frame-Options set");
141        assert_eq!(xfo, XFrameOptions::Deny);
142    }
143
144    /// `if-not-present` semantics: a downstream service that explicitly
145    /// sets one of the security headers takes precedence (so a future
146    /// endpoint can opt in to e.g. a relaxed CSP for itself without us
147    /// having to widen the global default).
148    #[tokio::test]
149    async fn does_not_overwrite_existing_headers() {
150        let inner_csp = ContentSecurityPolicy::empty().with_default_src(SourceList::none());
151        let inner_csp_for_layer = inner_csp.clone();
152
153        let svc = defence_in_depth_layer(rama_html_csp()).into_layer(
154            service_fn(move |_req: Request| {
155                let csp = inner_csp_for_layer.clone();
156                async move {
157                    let mut resp = Response::new(Body::empty());
158                    resp.headers_mut().typed_insert(csp);
159                    Ok::<_, Infallible>(resp.into_response())
160                }
161            })
162            .into_endpoint_service(),
163        );
164
165        let resp = svc.serve(Request::new(Body::empty())).await.unwrap();
166        let got: ContentSecurityPolicy = resp.headers().typed_get().expect("CSP set");
167        // The inner handler's policy is preserved verbatim — the layer
168        // did NOT replace it with the helper's strict-self baseline.
169        assert_eq!(got, inner_csp);
170    }
171
172    /// Caller-supplied per-directive widening flows end-to-end through
173    /// the encode/HTTP roundtrip. This is the FP server's pattern (it
174    /// adds same-origin WebSocket support).
175    #[tokio::test]
176    async fn caller_widening_with_connect_src_is_preserved() {
177        let csp = rama_html_csp().with_connect_src(SourceList::self_origin());
178        let headers = invoke(csp).await;
179        let got: ContentSecurityPolicy = headers.typed_get().expect("CSP set");
180        assert!(got.to_string().contains("connect-src 'self'"));
181    }
182}