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/// Reject requests whose `Host` header is not one of your names
172///
173/// Absolute links, password-reset mails and redirects built from the `Host`
174/// header are an injection point when anybody can choose it ("host header
175/// poisoning"). `AllowedHosts` answers `421 Misdirected Request` for any other
176/// name, before your handlers see it. Entries are host names without port;
177/// `*.example.com` matches subdomains (not `example.com` itself); the port in
178/// the header is ignored. IP-address probes of an orchestrator need
179/// [`except_paths`](AllowedHosts::except_paths).
180///
181/// ```
182/// use ullun::secure::AllowedHosts;
183///
184/// let hosts = AllowedHosts::new(["example.com", "*.example.com"]).except_paths(["/livez", "/readyz"]);
185/// # let _ = hosts;
186/// ```
187pub struct AllowedHosts {
188    exact: Vec<String>,
189    suffixes: Vec<String>,
190    except: Vec<String>,
191}
192
193impl AllowedHosts {
194    pub fn new<I, S>(hosts: I) -> Self
195    where
196        I: IntoIterator<Item = S>,
197        S: Into<String>,
198    {
199        let mut exact = Vec::new();
200        let mut suffixes = Vec::new();
201        for host in hosts {
202            let host = host.into().to_ascii_lowercase();
203            match host.strip_prefix("*.") {
204                Some(rest) => suffixes.push(format!(".{rest}")),
205                None => exact.push(host),
206            }
207        }
208        Self {
209            exact,
210            suffixes,
211            except: Vec::new(),
212        }
213    }
214
215    /// Paths that are served whatever the `Host` is (health probes addressed by pod IP)
216    pub fn except_paths<I, S>(mut self, paths: I) -> Self
217    where
218        I: IntoIterator<Item = S>,
219        S: Into<String>,
220    {
221        self.except = paths.into_iter().map(Into::into).collect();
222        self
223    }
224
225    fn allows(&self, host: &str) -> bool {
226        let host = host.trim_matches(['[', ']']);
227        self.exact.iter().any(|h| h == host)
228            || self
229                .suffixes
230                .iter()
231                .any(|s| host.len() > s.len() && host.ends_with(s.as_str()))
232    }
233}
234
235#[async_trait]
236impl Middleware for AllowedHosts {
237    async fn handle(&self, req: Request, next: Next) -> Result<Response> {
238        if self.except.iter().any(|p| p == req.uri.path()) {
239            return next.run(req).await;
240        }
241        let allowed = req
242            .headers
243            .get(http::header::HOST)
244            .and_then(|v| v.to_str().ok())
245            .and_then(parse_host)
246            .is_some_and(|(host, _)| self.allows(&host));
247        if allowed {
248            next.run(req).await
249        } else {
250            Ok(Response::from_error(Error::custom(421, "Unknown host")))
251        }
252    }
253}
254
255/// `Strict-Transport-Security` on responses to secure requests
256///
257/// The header is ignored by browsers on plain HTTP, so it is only sent when the
258/// request was secure.
259pub struct Hsts {
260    max_age: u64,
261    include_subdomains: bool,
262    preload: bool,
263    trust_forwarded_proto: bool,
264}
265
266impl Hsts {
267    /// One year, no subdomains, no preload
268    pub fn new() -> Self {
269        Self {
270            max_age: 31_536_000,
271            include_subdomains: false,
272            preload: false,
273            trust_forwarded_proto: false,
274        }
275    }
276
277    pub fn max_age(mut self, secs: u64) -> Self {
278        self.max_age = secs;
279        self
280    }
281
282    pub fn include_subdomains(mut self) -> Self {
283        self.include_subdomains = true;
284        self
285    }
286
287    /// Opt in to browser preload lists (hard to undo: read hstspreload.org first)
288    pub fn preload(mut self) -> Self {
289        self.preload = true;
290        self
291    }
292
293    pub fn trust_forwarded_proto(mut self) -> Self {
294        self.trust_forwarded_proto = true;
295        self
296    }
297
298    fn value(&self) -> String {
299        let mut v = format!("max-age={}", self.max_age);
300        if self.include_subdomains {
301            v.push_str("; includeSubDomains");
302        }
303        if self.preload {
304            v.push_str("; preload");
305        }
306        v
307    }
308}
309
310impl Default for Hsts {
311    fn default() -> Self {
312        Self::new()
313    }
314}
315
316#[async_trait]
317impl Middleware for Hsts {
318    async fn handle(&self, req: Request, next: Next) -> Result<Response> {
319        let secure = req.is_secure() || (self.trust_forwarded_proto && forwarded_https(&req));
320        let res = match next.run(req).await {
321            Ok(res) => res,
322            Err(err) => Response::from_error(err),
323        };
324        Ok(if secure {
325            res.with_header("strict-transport-security", self.value())
326        } else {
327            res
328        })
329    }
330}
331
332#[cfg(test)]
333mod tests {
334    use super::*;
335
336    #[test]
337    fn host_headers_are_parsed_strictly() {
338        let p = |s: &str| parse_host(s);
339        assert_eq!(p("example.com"), Some(("example.com".into(), None)));
340        assert_eq!(
341            p("Example.COM:8080"),
342            Some(("example.com".into(), Some(8080)))
343        );
344        assert_eq!(p("127.0.0.1:80"), Some(("127.0.0.1".into(), Some(80))));
345        assert_eq!(p("[::1]"), Some(("[::1]".into(), None)));
346        assert_eq!(
347            p("[2001:db8::1]:8443"),
348            Some(("[2001:db8::1]".into(), Some(8443)))
349        );
350        for bad in [
351            "",
352            "evil.com/path",
353            "evil.com\r\nX: y",
354            "user@evil.com",
355            "ev il.com",
356            "evil.com:",
357            "evil.com:99999",
358            "evil.com:80a",
359            ".evil.com",
360            "a..b",
361            "[::1",
362            "[]",
363            "[zz]",
364            "[::1]x",
365            "evil.com:80:80",
366            "http://evil.com",
367            "evil.com\\@good.com",
368            &"a".repeat(256),
369        ] {
370            assert_eq!(p(bad), None, "{bad:?}");
371        }
372    }
373
374    #[test]
375    fn hsts_value() {
376        assert_eq!(Hsts::new().value(), "max-age=31536000");
377        assert_eq!(
378            Hsts::new()
379                .max_age(60)
380                .include_subdomains()
381                .preload()
382                .value(),
383            "max-age=60; includeSubDomains; preload"
384        );
385    }
386}