Skip to main content

warp_helmet/
lib.rs

1//! `warp-helmet` is a security middleware for the Warp web framework that sets various HTTP headers to help protect your app.
2//!
3//! `warp_helmet::Helmet` wraps a Warp filter to automatically set security headers on all responses.
4//!
5//! It is based on the [Helmet](https://helmetjs.github.io/) library for Node.js and is highly configurable.
6//!
7//! # Usage
8//!
9//! ```no_run
10//! use warp::Filter;
11//! use warp_helmet::{Helmet, HelmetFilter};
12//!
13//! #[tokio::main]
14//! async fn main() {
15//!     let helmet: HelmetFilter = Helmet::default().try_into().unwrap();
16//!
17//!     let route = helmet.wrap(
18//!         warp::path::end().map(|| "Hello, world!")
19//!     );
20//!
21//!     warp::serve(route).run(([127, 0, 0, 1], 3000)).await;
22//! }
23//! ```
24//!
25//! By default Helmet will set the following headers:
26//!
27//! ```text
28//! Content-Security-Policy: default-src 'self'; base-uri 'self'; font-src 'self' https: data:; form-action 'self'; frame-ancestors 'self'; img-src 'self' data:; object-src 'none'; script-src 'self'; script-src-attr 'none'; style-src 'self' https: 'unsafe-inline'; upgrade-insecure-requests
29//! Cross-Origin-Opener-Policy: same-origin
30//! Cross-Origin-Resource-Policy: same-origin
31//! Origin-Agent-Cluster: ?1
32//! Referrer-Policy: no-referrer
33//! Strict-Transport-Security: max-age=15552000; includeSubDomains
34//! X-Content-Type-Options: nosniff
35//! X-DNS-Prefetch-Control: off
36//! X-Download-Options: noopen
37//! X-Frame-Options: sameorigin
38//! X-Permitted-Cross-Domain-Policies: none
39//! X-XSS-Protection: 0
40//! ```
41//!
42//! This might be a good starting point for most users, but it is highly recommended to spend some time with the documentation for each header, and adjust them to your needs.
43//!
44//! # Configuration
45//!
46//! By default if you construct a new instance of `Helmet` it will not set any headers.
47//!
48//! It is possible to configure `Helmet` to set only the headers you want, by using the `add` method.
49//!
50//! ```no_run
51//! use warp::Filter;
52//! use warp_helmet::{Helmet, HelmetFilter, ContentSecurityPolicy, CrossOriginOpenerPolicy};
53//!
54//! #[tokio::main]
55//! async fn main() {
56//!     let helmet: HelmetFilter = Helmet::new()
57//!         .add(
58//!             ContentSecurityPolicy::new()
59//!                 .default_src(vec!["'self'"])
60//!                 .script_src(vec!["'self'", "https://cdn.example.com"]),
61//!         )
62//!         .add(CrossOriginOpenerPolicy::same_origin_allow_popups())
63//!         .try_into()
64//!         .unwrap();
65//!
66//!     let route = helmet.wrap(
67//!         warp::path::end().map(|| "Hello, world!")
68//!     );
69//!
70//!     warp::serve(route).run(([127, 0, 0, 1], 3000)).await;
71//! }
72//! ```
73use http::header::{HeaderMap, HeaderName, HeaderValue};
74use warp::reply::{Reply, Response};
75use warp::Filter;
76
77use helmet_core::Helmet as HelmetCore;
78
79// re-export helmet_core::*, except for the `Helmet` struct
80pub use helmet_core::*;
81
82/// Helmet header configuration wrapper.
83///
84/// Use `Helmet::default()` for a sensible set of default security headers,
85/// or `Helmet::new()` to start with no headers and add only the ones you need.
86///
87/// Convert to [`HelmetFilter`] via `try_into()` to use with Warp.
88///
89/// ```rust
90/// use warp_helmet::{Helmet, HelmetFilter};
91///
92/// let filter: HelmetFilter = Helmet::default().try_into().unwrap();
93/// ```
94#[derive(Default)]
95pub struct Helmet(HelmetCore);
96
97impl Helmet {
98    /// Create a new instance of `Helmet` with no headers set.
99    pub fn new() -> Self {
100        Self(HelmetCore::new())
101    }
102
103    /// Add a header.
104    #[allow(clippy::should_implement_trait)]
105    pub fn add(self, header: impl Into<helmet_core::Header>) -> Self {
106        Self(self.0.add(header))
107    }
108
109    pub fn into_filter(self) -> Result<HelmetFilter, HelmetError> {
110        self.try_into()
111    }
112}
113
114/// The Warp filter wrapper created by converting a [`Helmet`] configuration.
115#[derive(Clone)]
116pub struct HelmetFilter {
117    headers: HeaderMap,
118}
119
120impl HelmetFilter {
121    /// Wrap a filter to add security headers to all its responses.
122    pub fn wrap<F, R>(
123        self,
124        filter: F,
125    ) -> impl Filter<Extract = (Response,), Error = F::Error> + Clone
126    where
127        F: Filter<Extract = (R,), Error: Send> + Clone + Send,
128        R: Reply + Send,
129    {
130        let headers = self.headers;
131        filter.map(move |reply: R| {
132            let mut resp = reply.into_response();
133            for (name, value) in headers.iter() {
134                resp.headers_mut().insert(name, value.clone());
135            }
136            resp
137        })
138    }
139}
140
141impl TryFrom<Helmet> for HelmetFilter {
142    type Error = HelmetError;
143
144    fn try_from(helmet: Helmet) -> Result<Self, Self::Error> {
145        let mut headers = HeaderMap::new();
146        for header in helmet.0.headers.iter() {
147            let name = HeaderName::try_from(header.0)
148                .map_err(|_| HelmetError::InvalidHeaderName(header.0.to_string()))?;
149            let value = HeaderValue::from_str(&header.1)
150                .map_err(|_| HelmetError::InvalidHeaderValue(header.1.clone()))?;
151            headers.insert(name, value);
152        }
153        Ok(Self { headers })
154    }
155}
156
157#[cfg(test)]
158mod tests {
159    use super::*;
160
161    #[tokio::test]
162    async fn test_helmet() {
163        let route = Helmet::new()
164            .add(helmet_core::XContentTypeOptions::nosniff())
165            .add(helmet_core::XFrameOptions::same_origin())
166            .add(helmet_core::XXSSProtection::on().mode_block())
167            .into_filter()
168            .unwrap()
169            .wrap(warp::path::end().map(|| "Hello, world!"));
170
171        let res = warp::test::request().path("/").reply(&route).await;
172
173        assert_eq!(res.status(), 200);
174        assert_eq!(
175            res.headers()
176                .get("X-Content-Type-Options")
177                .map(|v| v.to_str().unwrap()),
178            Some("nosniff")
179        );
180        assert_eq!(
181            res.headers()
182                .get("X-Frame-Options")
183                .map(|v| v.to_str().unwrap()),
184            Some("SAMEORIGIN")
185        );
186        assert_eq!(
187            res.headers()
188                .get("X-XSS-Protection")
189                .map(|v| v.to_str().unwrap()),
190            Some("1; mode=block")
191        );
192    }
193
194    #[tokio::test]
195    async fn test_helmet_default() {
196        let helmet: HelmetFilter = Helmet::default().try_into().unwrap();
197        let route = helmet.wrap(warp::path::end().map(|| "Hello, world!"));
198
199        let res = warp::test::request().path("/").reply(&route).await;
200
201        assert_eq!(res.status(), 200);
202        assert_eq!(
203            res.headers()
204                .get("X-Frame-Options")
205                .map(|v| v.to_str().unwrap()),
206            Some("SAMEORIGIN")
207        );
208        assert_eq!(
209            res.headers()
210                .get("X-XSS-Protection")
211                .map(|v| v.to_str().unwrap()),
212            Some("0")
213        );
214        assert_eq!(
215            res.headers()
216                .get("Referrer-Policy")
217                .map(|v| v.to_str().unwrap()),
218            Some("no-referrer")
219        );
220    }
221}