use crate::error::{Error, Result};
use crate::middleware::{Middleware, Next};
use crate::request::Request;
use crate::response::Response;
use async_trait::async_trait;
fn forwarded_https(req: &Request) -> bool {
req.headers
.get("x-forwarded-proto")
.and_then(|v| v.to_str().ok())
.is_some_and(|v| {
v.split(',')
.next()
.unwrap_or("")
.trim()
.eq_ignore_ascii_case("https")
})
}
fn parse_host(raw: &str) -> Option<(String, Option<u16>)> {
if raw.is_empty() || raw.len() > 255 {
return None;
}
let (host, port) = if let Some(rest) = raw.strip_prefix('[') {
let (addr, after) = rest.split_once(']')?;
let valid = !addr.is_empty()
&& addr
.bytes()
.all(|b| b.is_ascii_hexdigit() || b == b':' || b == b'.');
if !valid {
return None;
}
let port = match after {
"" => None,
p => Some(p.strip_prefix(':')?),
};
(format!("[{addr}]"), port)
} else {
let (host, port) = match raw.rsplit_once(':') {
Some((h, p)) => (h, Some(p)),
None => (raw, None),
};
let valid = !host.is_empty()
&& host
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'.' || b == b'-')
&& !host.starts_with('.')
&& !host.contains("..");
if !valid {
return None;
}
(host.to_ascii_lowercase(), port)
};
let port = match port {
None => None,
Some(p) if !p.is_empty() && p.len() <= 5 && p.bytes().all(|b| b.is_ascii_digit()) => {
Some(p.parse::<u16>().ok()?)
}
Some(_) => return None,
};
Some((host, port))
}
pub struct RequireHttps {
https_port: Option<u16>,
trust_forwarded_proto: bool,
allowed_hosts: Vec<String>,
}
impl RequireHttps {
pub fn new() -> Self {
Self {
https_port: None,
trust_forwarded_proto: false,
allowed_hosts: Vec::new(),
}
}
pub fn https_port(mut self, port: u16) -> Self {
self.https_port = Some(port).filter(|p| *p != 443);
self
}
pub fn trust_forwarded_proto(mut self) -> Self {
self.trust_forwarded_proto = true;
self
}
pub fn allowed_hosts<I, S>(mut self, hosts: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.allowed_hosts = hosts
.into_iter()
.map(|h| h.into().to_ascii_lowercase())
.collect();
self
}
}
impl Default for RequireHttps {
fn default() -> Self {
Self::new()
}
}
#[async_trait]
impl Middleware for RequireHttps {
async fn handle(&self, req: Request, next: Next) -> Result<Response> {
if req.is_secure() || (self.trust_forwarded_proto && forwarded_https(&req)) {
return next.run(req).await;
}
let raw = req
.headers
.get(http::header::HOST)
.and_then(|v| v.to_str().ok())
.ok_or_else(|| Error::bad_request("Missing Host header"))?;
let (host, _) = parse_host(raw).ok_or_else(|| Error::bad_request("Invalid Host header"))?;
if !self.allowed_hosts.is_empty()
&& !self
.allowed_hosts
.iter()
.any(|h| *h == host.trim_matches(['[', ']']))
{
return Err(Error::bad_request("Unknown host"));
}
let port = self.https_port.map(|p| format!(":{p}")).unwrap_or_default();
let target = req
.uri
.path_and_query()
.map(|p| p.as_str())
.filter(|p| p.starts_with('/'))
.unwrap_or("/");
let target = target.trim_start_matches('/');
Ok(Response::new(http::StatusCode::PERMANENT_REDIRECT)
.with_header("location", format!("https://{host}{port}/{target}")))
}
}
pub struct Hsts {
max_age: u64,
include_subdomains: bool,
preload: bool,
trust_forwarded_proto: bool,
}
impl Hsts {
pub fn new() -> Self {
Self {
max_age: 31_536_000,
include_subdomains: false,
preload: false,
trust_forwarded_proto: false,
}
}
pub fn max_age(mut self, secs: u64) -> Self {
self.max_age = secs;
self
}
pub fn include_subdomains(mut self) -> Self {
self.include_subdomains = true;
self
}
pub fn preload(mut self) -> Self {
self.preload = true;
self
}
pub fn trust_forwarded_proto(mut self) -> Self {
self.trust_forwarded_proto = true;
self
}
fn value(&self) -> String {
let mut v = format!("max-age={}", self.max_age);
if self.include_subdomains {
v.push_str("; includeSubDomains");
}
if self.preload {
v.push_str("; preload");
}
v
}
}
impl Default for Hsts {
fn default() -> Self {
Self::new()
}
}
#[async_trait]
impl Middleware for Hsts {
async fn handle(&self, req: Request, next: Next) -> Result<Response> {
let secure = req.is_secure() || (self.trust_forwarded_proto && forwarded_https(&req));
let res = match next.run(req).await {
Ok(res) => res,
Err(err) => Response::from_error(err),
};
Ok(if secure {
res.with_header("strict-transport-security", self.value())
} else {
res
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn host_headers_are_parsed_strictly() {
let p = |s: &str| parse_host(s);
assert_eq!(p("example.com"), Some(("example.com".into(), None)));
assert_eq!(
p("Example.COM:8080"),
Some(("example.com".into(), Some(8080)))
);
assert_eq!(p("127.0.0.1:80"), Some(("127.0.0.1".into(), Some(80))));
assert_eq!(p("[::1]"), Some(("[::1]".into(), None)));
assert_eq!(
p("[2001:db8::1]:8443"),
Some(("[2001:db8::1]".into(), Some(8443)))
);
for bad in [
"",
"evil.com/path",
"evil.com\r\nX: y",
"user@evil.com",
"ev il.com",
"evil.com:",
"evil.com:99999",
"evil.com:80a",
".evil.com",
"a..b",
"[::1",
"[]",
"[zz]",
"[::1]x",
"evil.com:80:80",
"http://evil.com",
"evil.com\\@good.com",
&"a".repeat(256),
] {
assert_eq!(p(bad), None, "{bad:?}");
}
}
#[test]
fn hsts_value() {
assert_eq!(Hsts::new().value(), "max-age=31536000");
assert_eq!(
Hsts::new()
.max_age(60)
.include_subdomains()
.preload()
.value(),
"max-age=60; includeSubDomains; preload"
);
}
}