use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
use url::Url;
pub(crate) const METADATA_IPV4: Ipv4Addr = Ipv4Addr::new(169, 254, 169, 254);
pub(crate) const METADATA_IPV6: Ipv6Addr = Ipv6Addr::new(0xfd00, 0x0ec2, 0, 0, 0, 0, 0, 0x0254);
pub(crate) fn embedded_ipv4(ip: Ipv6Addr) -> Option<Ipv4Addr> {
if let Some(mapped) = ip.to_ipv4_mapped() {
return Some(mapped);
}
let segments = ip.segments();
if segments[..6] == [0, 0, 0, 0, 0, 0] {
return Some(Ipv4Addr::new(
(segments[6] >> 8) as u8,
segments[6] as u8,
(segments[7] >> 8) as u8,
segments[7] as u8,
));
}
if segments[0] == 0x2002 {
return Some(Ipv4Addr::new(
(segments[1] >> 8) as u8,
segments[1] as u8,
(segments[2] >> 8) as u8,
segments[2] as u8,
));
}
None
}
pub(crate) fn is_cloud_metadata_ip(ip: IpAddr) -> bool {
match ip {
IpAddr::V4(ip) => ip == METADATA_IPV4,
IpAddr::V6(ip) => ip == METADATA_IPV6 || embedded_ipv4(ip) == Some(METADATA_IPV4),
}
}
pub(crate) fn is_cloud_metadata_host(url: &Url) -> bool {
let host = url
.host_str()
.map(|host| host.strip_suffix('.').unwrap_or(host));
if matches!(host, Some("169.254.169.254" | "metadata.google.internal")) {
return true;
}
match url.host() {
Some(url::Host::Ipv6(addr)) => is_cloud_metadata_ip(IpAddr::V6(addr)),
_ => false,
}
}
pub(crate) fn is_success_status(status: u16) -> bool {
(200..=299).contains(&status)
}
pub(crate) fn is_redirect_status(status: u16) -> bool {
matches!(status, 301 | 302 | 303 | 307 | 308)
}
pub(crate) fn unreadable_content_coding(header_value: &str) -> Option<&str> {
header_value
.split(',')
.map(str::trim)
.filter(|value| !value.is_empty())
.find(|value| {
!value.eq_ignore_ascii_case("gzip") && !value.eq_ignore_ascii_case("identity")
})
}
#[cfg(test)]
mod tests {
use super::*;
use rstest::rstest;
#[rstest]
#[case(199, false)]
#[case(200, true)]
#[case(204, true)]
#[case(299, true)]
#[case(300, false)]
#[case(302, false)]
#[case(404, false)]
#[case(500, false)]
fn only_a_2xx_carries_the_resource(#[case] status: u16, #[case] expected: bool) {
assert_eq!(is_success_status(status), expected, "{status}");
}
#[rstest]
#[case(301)]
#[case(302)]
#[case(303)]
#[case(307)]
#[case(308)]
fn a_followed_redirect_is_not_a_success(#[case] status: u16) {
assert!(is_redirect_status(status), "{status} carries a Location");
assert!(!is_success_status(status), "{status} is not the resource");
}
#[rstest]
#[case(300)]
#[case(305)]
#[case(306)]
#[case(309)]
fn a_3xx_that_is_not_followed_is_not_a_success_either(#[case] status: u16) {
assert!(!is_redirect_status(status), "{status} names no single URL");
assert!(!is_success_status(status), "{status} is not the resource");
}
#[rstest]
#[case("gzip")]
#[case("GZIP")]
#[case("identity")]
#[case(" gzip , identity ")]
#[case("")]
fn a_readable_coding_is_not_reported(#[case] value: &str) {
assert_eq!(unreadable_content_coding(value), None, "{value}");
}
#[rstest]
#[case("br", "br")]
#[case("BR", "BR")]
#[case("gzip, br", "br")]
#[case("deflate", "deflate")]
#[case("zstd, gzip", "zstd")]
fn an_unreadable_coding_is_named(#[case] value: &str, #[case] expected: &str) {
assert_eq!(unreadable_content_coding(value), Some(expected), "{value}");
}
#[test]
fn cloud_metadata_aws_with_various_paths_is_blocked() {
let url = Url::parse("http://169.254.169.254/latest/api/token").unwrap();
assert!(is_cloud_metadata_host(&url));
let url = Url::parse("http://169.254.169.254/latest/user-data").unwrap();
assert!(is_cloud_metadata_host(&url));
}
#[test]
fn cloud_metadata_non_metadata_ip_is_allowed() {
let url = Url::parse("http://169.254.169.253/something").unwrap();
assert!(!is_cloud_metadata_host(&url));
}
#[rstest]
#[case("http://169.254.169.254/latest/meta-data")]
#[case("http://169.254.169.254./latest/meta-data")]
#[case("http://2852039166/latest/meta-data")]
#[case("http://0xa9fea9fe/latest/meta-data")]
#[case("http://0251.0376.0251.0376/latest/meta-data")]
#[case("http://metadata.google.internal/computeMetadata/v1/")]
#[case("http://metadata.google.internal./computeMetadata/v1/")]
#[case("http://[::ffff:169.254.169.254]/latest/meta-data")]
#[case("http://[::169.254.169.254]/latest/meta-data")]
#[case("http://[2002:a9fe:a9fe::]/latest/meta-data")]
#[case("http://[fd00:ec2::254]/latest/meta-data")]
#[case("http://[fd00:0ec2:0:0:0:0:0:0254]/latest/meta-data")]
fn cloud_metadata_spellings_are_all_blocked(#[case] value: &str) {
let url = Url::parse(value).expect("parse metadata URL");
assert!(
is_cloud_metadata_host(&url),
"{value} reaches a metadata endpoint and must be refused"
);
}
#[rstest]
#[case("http://metadata.google.internal.example.com/")]
#[case("http://notmetadata.google.internal/")]
#[case("http://169.254.169.253/")]
#[case("http://[::ffff:169.254.169.253]/")]
#[case("http://[2002:a9fe:a9fd::]/")]
#[case("http://[fd00:ec2::255]/")]
fn cloud_metadata_lookalikes_are_not_blocked(#[case] value: &str) {
let url = Url::parse(value).expect("parse lookalike URL");
assert!(
!is_cloud_metadata_host(&url),
"{value} is not a metadata endpoint"
);
}
#[test]
fn cloud_metadata_gcp_with_path_is_blocked() {
let url =
Url::parse("http://metadata.google.internal/computeMetadata/v1/project/project-id")
.unwrap();
assert!(is_cloud_metadata_host(&url));
}
#[rstest]
#[case("169.254.169.254", true)]
#[case("fd00:ec2::254", true)]
#[case("::ffff:169.254.169.254", true)]
#[case("2002:a9fe:a9fe::", true)]
#[case("127.0.0.1", false)]
#[case("10.0.0.1", false)]
#[case("::1", false)]
fn an_address_is_a_metadata_endpoint_or_is_not(#[case] raw: &str, #[case] expected: bool) {
let ip: IpAddr = raw.parse().expect("parse address");
assert_eq!(is_cloud_metadata_ip(ip), expected, "{raw}");
}
}