Skip to main content

ullun/
secure.rs

1//! HTTPS policy: redirect plain HTTP, send HSTS
2//!
3//! ```no_run
4//! use ullun::prelude::*;
5//! use ullun::secure::{Hsts, RequireHttps};
6//!
7//! # async fn run() {
8//! // port 80: nothing but a redirect to https
9//! let redirect = App::new().middleware(RequireHttps::new().https_port(8443));
10//! // port 8443 (TLS configured with App::tls): the real application
11//! let app = App::new()
12//!     .middleware(Hsts::new())
13//!     .get("/", |_req: Request| async { Ok(Response::text("secure")) });
14//! # let _ = (redirect, app);
15//! # }
16//! ```
17//!
18//! Behind a reverse proxy that terminates TLS the application itself sees
19//! plain HTTP: tell both middleware to believe `X-Forwarded-Proto` with
20//! `trust_forwarded_proto()`, and only do that when the proxy overwrites the
21//! header (a client could otherwise claim to be secure).
22
23use crate::error::{Error, Result};
24use crate::middleware::{Middleware, Next};
25use crate::request::Request;
26use crate::response::Response;
27use async_trait::async_trait;
28
29fn forwarded_https(req: &Request) -> bool {
30    req.headers
31        .get("x-forwarded-proto")
32        .and_then(|v| v.to_str().ok())
33        .is_some_and(|v| {
34            v.split(',')
35                .next()
36                .unwrap_or("")
37                .trim()
38                .eq_ignore_ascii_case("https")
39        })
40}
41
42/// Split a `Host` header into (host, port), refusing anything that could smuggle
43/// characters into a redirect (`evil.com/\r\n`, userinfo, ...)
44fn parse_host(raw: &str) -> Option<(String, Option<u16>)> {
45    if raw.is_empty() || raw.len() > 255 {
46        return None;
47    }
48    let (host, port) = if let Some(rest) = raw.strip_prefix('[') {
49        let (addr, after) = rest.split_once(']')?;
50        let valid = !addr.is_empty()
51            && addr
52                .bytes()
53                .all(|b| b.is_ascii_hexdigit() || b == b':' || b == b'.');
54        if !valid {
55            return None;
56        }
57        let port = match after {
58            "" => None,
59            p => Some(p.strip_prefix(':')?),
60        };
61        (format!("[{addr}]"), port)
62    } else {
63        let (host, port) = match raw.rsplit_once(':') {
64            Some((h, p)) => (h, Some(p)),
65            None => (raw, None),
66        };
67        let valid = !host.is_empty()
68            && host
69                .bytes()
70                .all(|b| b.is_ascii_alphanumeric() || b == b'.' || b == b'-')
71            && !host.starts_with('.')
72            && !host.contains("..");
73        if !valid {
74            return None;
75        }
76        (host.to_ascii_lowercase(), port)
77    };
78    let port = match port {
79        None => None,
80        Some(p) if !p.is_empty() && p.len() <= 5 && p.bytes().all(|b| b.is_ascii_digit()) => {
81            Some(p.parse::<u16>().ok()?)
82        }
83        Some(_) => return None,
84    };
85    Some((host, port))
86}
87
88/// Redirect (`308`) every plain-HTTP request to the same URL on HTTPS
89pub struct RequireHttps {
90    https_port: Option<u16>,
91    trust_forwarded_proto: bool,
92    allowed_hosts: Vec<String>,
93}
94
95impl RequireHttps {
96    pub fn new() -> Self {
97        Self {
98            https_port: None,
99            trust_forwarded_proto: false,
100            allowed_hosts: Vec::new(),
101        }
102    }
103
104    /// Port of the HTTPS listener when it is not 443
105    pub fn https_port(mut self, port: u16) -> Self {
106        self.https_port = Some(port).filter(|p| *p != 443);
107        self
108    }
109
110    /// Treat requests with `X-Forwarded-Proto: https` as secure (reverse proxy)
111    pub fn trust_forwarded_proto(mut self) -> Self {
112        self.trust_forwarded_proto = true;
113        self
114    }
115
116    /// Only redirect for these host names (any other `Host` gets `400`), so the
117    /// redirect can never point at a host the client made up
118    pub fn allowed_hosts<I, S>(mut self, hosts: I) -> Self
119    where
120        I: IntoIterator<Item = S>,
121        S: Into<String>,
122    {
123        self.allowed_hosts = hosts
124            .into_iter()
125            .map(|h| h.into().to_ascii_lowercase())
126            .collect();
127        self
128    }
129}
130
131impl Default for RequireHttps {
132    fn default() -> Self {
133        Self::new()
134    }
135}
136
137#[async_trait]
138impl Middleware for RequireHttps {
139    async fn handle(&self, req: Request, next: Next) -> Result<Response> {
140        if req.is_secure() || (self.trust_forwarded_proto && forwarded_https(&req)) {
141            return next.run(req).await;
142        }
143        let raw = req
144            .headers
145            .get(http::header::HOST)
146            .and_then(|v| v.to_str().ok())
147            .ok_or_else(|| Error::bad_request("Missing Host header"))?;
148        let (host, _) = parse_host(raw).ok_or_else(|| Error::bad_request("Invalid Host header"))?;
149        if !self.allowed_hosts.is_empty()
150            && !self
151                .allowed_hosts
152                .iter()
153                .any(|h| *h == host.trim_matches(['[', ']']))
154        {
155            return Err(Error::bad_request("Unknown host"));
156        }
157        let port = self.https_port.map(|p| format!(":{p}")).unwrap_or_default();
158        let target = req
159            .uri
160            .path_and_query()
161            .map(|p| p.as_str())
162            .filter(|p| p.starts_with('/'))
163            .unwrap_or("/");
164        // `//evil.example` would be a protocol-relative URL after the host part
165        let target = target.trim_start_matches('/');
166        Ok(Response::new(http::StatusCode::PERMANENT_REDIRECT)
167            .with_header("location", format!("https://{host}{port}/{target}")))
168    }
169}
170
171/// `Strict-Transport-Security` on responses to secure requests
172///
173/// The header is ignored by browsers on plain HTTP, so it is only sent when the
174/// request was secure.
175pub struct Hsts {
176    max_age: u64,
177    include_subdomains: bool,
178    preload: bool,
179    trust_forwarded_proto: bool,
180}
181
182impl Hsts {
183    /// One year, no subdomains, no preload
184    pub fn new() -> Self {
185        Self {
186            max_age: 31_536_000,
187            include_subdomains: false,
188            preload: false,
189            trust_forwarded_proto: false,
190        }
191    }
192
193    pub fn max_age(mut self, secs: u64) -> Self {
194        self.max_age = secs;
195        self
196    }
197
198    pub fn include_subdomains(mut self) -> Self {
199        self.include_subdomains = true;
200        self
201    }
202
203    /// Opt in to browser preload lists (hard to undo: read hstspreload.org first)
204    pub fn preload(mut self) -> Self {
205        self.preload = true;
206        self
207    }
208
209    pub fn trust_forwarded_proto(mut self) -> Self {
210        self.trust_forwarded_proto = true;
211        self
212    }
213
214    fn value(&self) -> String {
215        let mut v = format!("max-age={}", self.max_age);
216        if self.include_subdomains {
217            v.push_str("; includeSubDomains");
218        }
219        if self.preload {
220            v.push_str("; preload");
221        }
222        v
223    }
224}
225
226impl Default for Hsts {
227    fn default() -> Self {
228        Self::new()
229    }
230}
231
232#[async_trait]
233impl Middleware for Hsts {
234    async fn handle(&self, req: Request, next: Next) -> Result<Response> {
235        let secure = req.is_secure() || (self.trust_forwarded_proto && forwarded_https(&req));
236        let res = match next.run(req).await {
237            Ok(res) => res,
238            Err(err) => Response::from_error(err),
239        };
240        Ok(if secure {
241            res.with_header("strict-transport-security", self.value())
242        } else {
243            res
244        })
245    }
246}
247
248#[cfg(test)]
249mod tests {
250    use super::*;
251
252    #[test]
253    fn host_headers_are_parsed_strictly() {
254        let p = |s: &str| parse_host(s);
255        assert_eq!(p("example.com"), Some(("example.com".into(), None)));
256        assert_eq!(
257            p("Example.COM:8080"),
258            Some(("example.com".into(), Some(8080)))
259        );
260        assert_eq!(p("127.0.0.1:80"), Some(("127.0.0.1".into(), Some(80))));
261        assert_eq!(p("[::1]"), Some(("[::1]".into(), None)));
262        assert_eq!(
263            p("[2001:db8::1]:8443"),
264            Some(("[2001:db8::1]".into(), Some(8443)))
265        );
266        for bad in [
267            "",
268            "evil.com/path",
269            "evil.com\r\nX: y",
270            "user@evil.com",
271            "ev il.com",
272            "evil.com:",
273            "evil.com:99999",
274            "evil.com:80a",
275            ".evil.com",
276            "a..b",
277            "[::1",
278            "[]",
279            "[zz]",
280            "[::1]x",
281            "evil.com:80:80",
282            "http://evil.com",
283            "evil.com\\@good.com",
284            &"a".repeat(256),
285        ] {
286            assert_eq!(p(bad), None, "{bad:?}");
287        }
288    }
289
290    #[test]
291    fn hsts_value() {
292        assert_eq!(Hsts::new().value(), "max-age=31536000");
293        assert_eq!(
294            Hsts::new()
295                .max_age(60)
296                .include_subdomains()
297                .preload()
298                .value(),
299            "max-age=60; includeSubDomains; preload"
300        );
301    }
302}