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}