Skip to main content

salvo_helmet/
lib.rs

1//! `salvo-helmet` is a security middleware for the Salvo web framework that sets various HTTP headers to help protect your app.
2//!
3//! `salvo_helmet::Helmet` is a handler that automatically sets 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 salvo::prelude::*;
11//! use salvo_helmet::{Helmet, HelmetHandler};
12//!
13//! #[handler]
14//! async fn index() -> &'static str {
15//!     "Hello, world!"
16//! }
17//!
18//! #[tokio::main]
19//! async fn main() {
20//!     let handler: HelmetHandler = Helmet::default().try_into().unwrap();
21//!     let router = Router::with_hoop(handler).get(index);
22//!
23//!     let acceptor = TcpListener::new("0.0.0.0:3000").bind().await;
24//!     Server::new(acceptor).serve(router).await;
25//! }
26//! ```
27//!
28//! By default Helmet will set the following headers:
29//!
30//! ```text
31//! 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
32//! Cross-Origin-Opener-Policy: same-origin
33//! Cross-Origin-Resource-Policy: same-origin
34//! Origin-Agent-Cluster: ?1
35//! Referrer-Policy: no-referrer
36//! Strict-Transport-Security: max-age=15552000; includeSubDomains
37//! X-Content-Type-Options: nosniff
38//! X-DNS-Prefetch-Control: off
39//! X-Download-Options: noopen
40//! X-Frame-Options: sameorigin
41//! X-Permitted-Cross-Domain-Policies: none
42//! X-XSS-Protection: 0
43//! ```
44//!
45//! 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.
46//!
47//! # Configuration
48//!
49//! By default if you construct a new instance of `Helmet` it will not set any headers.
50//!
51//! It is possible to configure `Helmet` to set only the headers you want, by using the `add` method.
52//!
53//! ```no_run
54//! use salvo::prelude::*;
55//! use salvo_helmet::{Helmet, HelmetHandler, ContentSecurityPolicy, CrossOriginOpenerPolicy};
56//!
57//! #[handler]
58//! async fn index() -> &'static str {
59//!     "Hello, world!"
60//! }
61//!
62//! #[tokio::main]
63//! async fn main() {
64//!     let handler: HelmetHandler = Helmet::new()
65//!         .add(
66//!             ContentSecurityPolicy::new()
67//!                 .default_src(vec!["'self'"])
68//!                 .script_src(vec!["'self'", "https://cdn.example.com"]),
69//!         )
70//!         .add(CrossOriginOpenerPolicy::same_origin_allow_popups())
71//!         .try_into()
72//!         .unwrap();
73//!
74//!     let router = Router::with_hoop(handler).get(index);
75//!
76//!     let acceptor = TcpListener::new("0.0.0.0:3000").bind().await;
77//!     Server::new(acceptor).serve(router).await;
78//! }
79//! ```
80use http::header::{HeaderMap, HeaderName, HeaderValue};
81use salvo::handler::Handler;
82use salvo::{async_trait, Depot, FlowCtrl, Request, Response};
83
84use helmet_core::Helmet as HelmetCore;
85
86// re-export helmet_core::*, except for the `Helmet` struct
87pub use helmet_core::*;
88
89/// Helmet header configuration wrapper.
90///
91/// Use `Helmet::default()` for a sensible set of default security headers,
92/// or `Helmet::new()` to start with no headers and add only the ones you need.
93///
94/// Convert to [`HelmetHandler`] via `try_into()` to use as a Salvo hoop.
95///
96/// ```rust
97/// use salvo_helmet::{Helmet, HelmetHandler};
98///
99/// let handler: HelmetHandler = Helmet::default().try_into().unwrap();
100/// ```
101#[derive(Default)]
102pub struct Helmet(HelmetCore);
103
104impl Helmet {
105    /// Create a new instance of `Helmet` with no headers set.
106    pub fn new() -> Self {
107        Self(HelmetCore::new())
108    }
109
110    /// Add a header.
111    #[allow(clippy::should_implement_trait)]
112    pub fn add(self, header: impl Into<helmet_core::Header>) -> Self {
113        Self(self.0.add(header))
114    }
115
116    pub fn into_handler(self) -> Result<HelmetHandler, HelmetError> {
117        self.try_into()
118    }
119}
120
121/// The Salvo handler created by converting a [`Helmet`] configuration.
122pub struct HelmetHandler {
123    headers: HeaderMap,
124}
125
126impl TryFrom<Helmet> for HelmetHandler {
127    type Error = HelmetError;
128
129    fn try_from(helmet: Helmet) -> Result<Self, Self::Error> {
130        let mut headers = HeaderMap::new();
131        for header in helmet.0.headers.iter() {
132            let name = HeaderName::try_from(header.0)
133                .map_err(|_| HelmetError::InvalidHeaderName(header.0.to_string()))?;
134            let value = HeaderValue::from_str(&header.1)
135                .map_err(|_| HelmetError::InvalidHeaderValue(header.1.clone()))?;
136            headers.insert(name, value);
137        }
138        Ok(Self { headers })
139    }
140}
141
142#[async_trait]
143impl Handler for HelmetHandler {
144    async fn handle(
145        &self,
146        req: &mut Request,
147        depot: &mut Depot,
148        res: &mut Response,
149        ctrl: &mut FlowCtrl,
150    ) {
151        ctrl.call_next(req, depot, res).await;
152        for (name, value) in self.headers.iter() {
153            res.headers_mut().insert(name.clone(), value.clone());
154        }
155    }
156}
157
158#[cfg(test)]
159mod tests {
160    use super::*;
161    use salvo::prelude::*;
162    use salvo::test::TestClient;
163
164    #[handler]
165    async fn index() -> &'static str {
166        "Hello, world!"
167    }
168
169    #[tokio::test]
170    async fn test_helmet() {
171        let router = Router::with_hoop(
172            Helmet::new()
173                .add(helmet_core::XContentTypeOptions::nosniff())
174                .add(helmet_core::XFrameOptions::same_origin())
175                .add(helmet_core::XXSSProtection::on().mode_block())
176                .into_handler()
177                .unwrap(),
178        )
179        .get(index);
180        let service = Service::new(router);
181
182        let res = TestClient::get("http://localhost/").send(&service).await;
183
184        assert_eq!(res.status_code, Some(StatusCode::OK));
185        assert_eq!(
186            res.headers()
187                .get("X-Content-Type-Options")
188                .map(|v| v.to_str().unwrap()),
189            Some("nosniff")
190        );
191        assert_eq!(
192            res.headers()
193                .get("X-Frame-Options")
194                .map(|v| v.to_str().unwrap()),
195            Some("SAMEORIGIN")
196        );
197        assert_eq!(
198            res.headers()
199                .get("X-XSS-Protection")
200                .map(|v| v.to_str().unwrap()),
201            Some("1; mode=block")
202        );
203    }
204
205    #[tokio::test]
206    async fn test_helmet_default() {
207        let handler: HelmetHandler = Helmet::default().try_into().unwrap();
208        let router = Router::with_hoop(handler).get(index);
209        let service = Service::new(router);
210
211        let res = TestClient::get("http://localhost/").send(&service).await;
212
213        assert_eq!(res.status_code, Some(StatusCode::OK));
214        assert_eq!(
215            res.headers()
216                .get("X-Frame-Options")
217                .map(|v| v.to_str().unwrap()),
218            Some("SAMEORIGIN")
219        );
220        assert_eq!(
221            res.headers()
222                .get("X-XSS-Protection")
223                .map(|v| v.to_str().unwrap()),
224            Some("0")
225        );
226        assert_eq!(
227            res.headers()
228                .get("Referrer-Policy")
229                .map(|v| v.to_str().unwrap()),
230            Some("no-referrer")
231        );
232    }
233}