use url::{Host, Origin, Url};
#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash, Default)]
pub enum MixedContentPolicy {
Allow,
Upgrade,
#[default]
Block,
}
#[derive(Debug, Clone, Eq, PartialEq)]
pub enum MixedContentAction {
Allow,
Upgrade(Url),
Block,
}
fn is_loopback_host<S: AsRef<str>>(host: &Host<S>) -> bool {
match host {
Host::Domain(d) => {
let d = d.as_ref();
d == "localhost" || d.ends_with(".localhost")
}
Host::Ipv4(ip) => ip.octets()[0] == 127,
Host::Ipv6(ip) => ip.is_loopback(),
}
}
fn is_trustworthy_scheme(scheme: &str) -> bool {
matches!(scheme, "https" | "wss" | "file")
}
pub fn is_potentially_trustworthy(url: &Url) -> bool {
is_trustworthy_scheme(url.scheme()) || url.host().is_some_and(|h| is_loopback_host(&h))
}
pub fn is_origin_potentially_trustworthy(origin: &Origin) -> bool {
match origin {
Origin::Opaque(_) => false,
Origin::Tuple(scheme, host, _) => is_trustworthy_scheme(scheme) || is_loopback_host(host),
}
}
fn upgrade(url: &Url) -> Option<Url> {
let mut upgraded = url.clone();
upgraded.set_scheme("https").ok()?;
Some(upgraded)
}
pub fn evaluate(
policy: MixedContentPolicy,
origin: Option<&Origin>,
url: &Url,
) -> MixedContentAction {
let Some(origin) = origin else {
return MixedContentAction::Allow;
};
if !is_origin_potentially_trustworthy(origin) || is_potentially_trustworthy(url) {
return MixedContentAction::Allow;
}
match policy {
MixedContentPolicy::Allow => MixedContentAction::Allow,
MixedContentPolicy::Upgrade => upgrade(url).map_or(MixedContentAction::Block, |u| {
MixedContentAction::Upgrade(u)
}),
MixedContentPolicy::Block => MixedContentAction::Block,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn url(s: &str) -> Url {
Url::parse(s).unwrap()
}
fn origin(s: &str) -> Origin {
url(s).origin()
}
#[test]
fn https_origin_blocks_http_subresource() {
assert_eq!(
evaluate(
MixedContentPolicy::Block,
Some(&origin("https://example.com")),
&url("http://cdn.example.com/a.js"),
),
MixedContentAction::Block
);
}
#[test]
fn https_origin_upgrades_http_subresource() {
assert_eq!(
evaluate(
MixedContentPolicy::Upgrade,
Some(&origin("https://example.com")),
&url("http://cdn.example.com:8080/a.js"),
),
MixedContentAction::Upgrade(url("https://cdn.example.com:8080/a.js"))
);
}
#[test]
fn upgrade_drops_default_http_port() {
assert_eq!(
evaluate(
MixedContentPolicy::Upgrade,
Some(&origin("https://example.com")),
&url("http://cdn.example.com:80/a.js"),
),
MixedContentAction::Upgrade(url("https://cdn.example.com/a.js"))
);
}
#[test]
fn allow_policy_permits_mixed_content() {
assert_eq!(
evaluate(
MixedContentPolicy::Allow,
Some(&origin("https://example.com")),
&url("http://cdn.example.com/cat.png"),
),
MixedContentAction::Allow
);
}
#[test]
fn https_subresource_is_never_mixed_content() {
assert_eq!(
evaluate(
MixedContentPolicy::Block,
Some(&origin("https://example.com")),
&url("https://cdn.example.com/a.js"),
),
MixedContentAction::Allow
);
}
#[test]
fn insecure_origin_is_not_protected() {
assert_eq!(
evaluate(
MixedContentPolicy::Block,
Some(&origin("http://example.com")),
&url("http://cdn.example.com/a.js"),
),
MixedContentAction::Allow
);
}
#[test]
fn absent_origin_is_not_protected() {
assert_eq!(
evaluate(MixedContentPolicy::Block, None, &url("http://example.com/")),
MixedContentAction::Allow
);
}
#[test]
fn loopback_subresource_is_trustworthy() {
for target in [
"http://localhost:3000/a.js",
"http://dev.localhost/a.js",
"http://127.0.0.1/a.js",
"http://127.9.9.9/a.js",
"http://[::1]:8080/a.js",
] {
assert_eq!(
evaluate(
MixedContentPolicy::Block,
Some(&origin("https://example.com")),
&url(target),
),
MixedContentAction::Allow,
"{target} should be potentially trustworthy"
);
}
}
#[test]
fn loopback_origin_is_trustworthy() {
assert_eq!(
evaluate(
MixedContentPolicy::Block,
Some(&origin("http://localhost:3000")),
&url("http://cdn.example.com/a.js"),
),
MixedContentAction::Block,
"a loopback document is a secure context and must still block mixed content"
);
}
#[test]
fn opaque_origin_is_not_trustworthy() {
assert!(!is_origin_potentially_trustworthy(&origin("data:,hello")));
}
#[test]
fn near_miss_hosts_are_not_loopback() {
for target in [
"http://notlocalhost/a.js",
"http://localhost.evil.com/a.js",
"http://128.0.0.1/a.js",
"http://[::2]/a.js",
] {
assert!(
!is_potentially_trustworthy(&url(target)),
"{target} must not be treated as loopback"
);
}
}
}