use std::net::IpAddr;
use std::str::FromStr;
use crate::ip_gate::{IpGateError, canonical, parse_network_entry};
pub const HTTPS_ENFORCEMENT_CHECK_NAME: &str = "https_enforcement";
pub const HTTPS_REDIRECT_STATUS: u16 = 301;
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct HttpsEnforcementConfig {
pub enforce_https: bool,
pub trust_x_forwarded_proto: bool,
trusted_proxies_raw: Vec<String>,
}
impl HttpsEnforcementConfig {
pub fn new<I>(
enforce_https: bool,
trust_x_forwarded_proto: bool,
trusted_proxies: I,
) -> Result<Self, IpGateError>
where
I: IntoIterator,
I::Item: AsRef<str>,
{
let mut proxies = Vec::new();
for entry in trusted_proxies {
let entry = entry.as_ref();
if parse_network_entry(entry).is_none() {
return Err(IpGateError {
list: "trusted_proxies",
entry: entry.to_owned(),
});
}
proxies.push(entry.to_owned());
}
Ok(Self {
enforce_https,
trust_x_forwarded_proto,
trusted_proxies_raw: proxies,
})
}
fn is_trusted_proxy(&self, connecting: &str) -> bool {
for entry in &self.trusted_proxies_raw {
if !entry.contains('/') {
if connecting == entry {
return true;
}
} else if let Ok(addr) = IpAddr::from_str(connecting) {
if let Some(network) = parse_network_entry(entry)
&& (network.contains(canonical(addr)) || network.contains(addr))
{
return true;
}
}
}
false
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct HttpsRequest<'a> {
pub url_scheme: &'a str,
pub client_host: Option<&'a str>,
pub x_forwarded_proto: Option<&'a str>,
pub route_require_https: Option<bool>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HttpsVerdict {
Allowed,
Redirect {
route_scoped: bool,
},
}
fn is_request_https(request: &HttpsRequest<'_>, config: &HttpsEnforcementConfig) -> bool {
let mut is_https = request.url_scheme == "https";
if !is_https
&& config.trust_x_forwarded_proto
&& !config.trusted_proxies_raw.is_empty()
&& let Some(host) = request.client_host
&& config.is_trusted_proxy(host)
&& let Some(forwarded) = request.x_forwarded_proto
{
is_https = forwarded.to_lowercase() == "https";
}
is_https
}
#[must_use]
pub fn decide(request: &HttpsRequest<'_>, config: &HttpsEnforcementConfig) -> HttpsVerdict {
let https_required = request.route_require_https.unwrap_or(config.enforce_https);
if !https_required || is_request_https(request, config) {
return HttpsVerdict::Allowed;
}
HttpsVerdict::Redirect {
route_scoped: request.route_require_https.is_some(),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn request<'a>(
scheme: &'a str,
client_host: Option<&'a str>,
xfp: Option<&'a str>,
route: Option<bool>,
) -> HttpsRequest<'a> {
HttpsRequest {
url_scheme: scheme,
client_host,
x_forwarded_proto: xfp,
route_require_https: route,
}
}
#[test]
fn global_enforcement_redirects_plain_http() {
let config = HttpsEnforcementConfig::new(true, false, Vec::<String>::new()).unwrap();
assert_eq!(
decide(&request("http", None, None, None), &config),
HttpsVerdict::Redirect {
route_scoped: false
}
);
assert_eq!(
decide(&request("https", None, None, None), &config),
HttpsVerdict::Allowed
);
let off = HttpsEnforcementConfig::new(false, false, Vec::<String>::new()).unwrap();
assert_eq!(
decide(&request("http", None, None, None), &off),
HttpsVerdict::Allowed
);
}
#[test]
fn route_require_https_wins_both_ways() {
let config = HttpsEnforcementConfig::new(true, false, Vec::<String>::new()).unwrap();
assert_eq!(
decide(&request("http", None, None, Some(false)), &config),
HttpsVerdict::Allowed
);
let off = HttpsEnforcementConfig::new(false, false, Vec::<String>::new()).unwrap();
assert_eq!(
decide(&request("http", None, None, Some(true)), &off),
HttpsVerdict::Redirect { route_scoped: true }
);
}
#[test]
fn forwarded_proto_never_decides_without_trust() {
let config = HttpsEnforcementConfig::new(true, false, ["10.0.0.1"]).unwrap();
assert_eq!(
decide(
&request("http", Some("10.0.0.1"), Some("https"), None),
&config
),
HttpsVerdict::Redirect {
route_scoped: false
},
"trust_x_forwarded_proto is off: the header is ignored"
);
}
#[test]
fn forwarded_proto_never_decides_from_an_untrusted_client() {
let config = HttpsEnforcementConfig::new(true, true, ["10.0.0.0/8"]).unwrap();
assert_eq!(
decide(
&request("http", Some("203.0.113.9"), Some("https"), None),
&config
),
HttpsVerdict::Redirect {
route_scoped: false
},
"a client outside the trusted proxies cannot upgrade itself"
);
assert_eq!(
decide(&request("http", None, Some("https"), None), &config),
HttpsVerdict::Redirect {
route_scoped: false
},
"no connecting address: no upgrade"
);
}
#[test]
fn forwarded_proto_upgrades_from_a_trusted_proxy() {
let config = HttpsEnforcementConfig::new(true, true, ["10.0.0.0/8", "192.0.2.40"]).unwrap();
assert_eq!(
decide(
&request("http", Some("10.1.2.3"), Some("HTTPS"), None),
&config
),
HttpsVerdict::Allowed,
"the value comparison is case-insensitive"
);
assert_eq!(
decide(
&request("http", Some("192.0.2.40"), Some("https"), None),
&config
),
HttpsVerdict::Allowed,
"a bare-IP proxy entry matches exactly"
);
assert_eq!(
decide(
&request("http", Some("10.1.2.3"), Some("http"), None),
&config
),
HttpsVerdict::Redirect {
route_scoped: false
}
);
}
#[test]
fn config_fails_closed_on_a_bad_proxy_entry() {
let error = HttpsEnforcementConfig::new(true, true, ["not-an-ip"]).unwrap_err();
assert_eq!(error.list, "trusted_proxies");
assert_eq!(error.entry, "not-an-ip");
}
#[test]
fn families_never_cross_on_the_proxy_match() {
let config = HttpsEnforcementConfig::new(true, true, ["10.0.0.0/8"]).unwrap();
assert_eq!(
decide(
&request("http", Some("::ffff:10.1.2.3"), Some("https"), None),
&config
),
HttpsVerdict::Allowed,
"a v4-mapped connecting address matches its IPv4 proxy entry"
);
assert_eq!(
decide(
&request("http", Some("2001:db8::1"), Some("https"), None),
&config
),
HttpsVerdict::Redirect {
route_scoped: false
}
);
}
#[test]
fn a_mismatched_bare_entry_falls_through_and_junk_addresses_never_match() {
let config = HttpsEnforcementConfig::new(true, true, ["10.0.0.1", "10.0.0.0/8"]).unwrap();
assert_eq!(
decide(
&request("http", Some("10.1.2.3"), Some("https"), None),
&config
),
HttpsVerdict::Allowed,
"the CIDR entry answers after the bare entry missed"
);
let config = HttpsEnforcementConfig::new(true, true, ["10.0.0.0/8"]).unwrap();
assert_eq!(
decide(&request("http", Some("junk"), Some("https"), None), &config),
HttpsVerdict::Redirect {
route_scoped: false
},
"junk cannot upgrade itself"
);
}
}