redevplugin-target-classifier 0.7.6

Network target classification for the ReDevPlugin platform
Documentation
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};

pub const TARGET_CLASSIFIER_VERSION: &str = "target-classifier-v2";
pub const BLOCKED_IP_RANGES: &[&str] = &[
    "0.0.0.0/8",
    "10.0.0.0/8",
    "100.64.0.0/10",
    "127.0.0.0/8",
    "169.254.0.0/16",
    "172.16.0.0/12",
    "192.0.0.0/24",
    "192.0.2.0/24",
    "192.31.196.0/24",
    "192.52.193.0/24",
    "192.88.99.0/24",
    "192.168.0.0/16",
    "192.175.48.0/24",
    "198.18.0.0/15",
    "198.51.100.0/24",
    "203.0.113.0/24",
    "224.0.0.0/4",
    "240.0.0.0/4",
    "::/96",
    "::1/128",
    "64:ff9b::/96",
    "64:ff9b:1::/48",
    "100::/64",
    "2001::/23",
    "2001:db8::/32",
    "2002::/16",
    "3fff::/20",
    "5f00::/16",
    "2620:4f:8000::/48",
    "fc00::/7",
    "fe80::/10",
    "fec0::/10",
    "ff00::/8",
];
pub const SPECIAL_HOSTS: &[&str] = &[
    "localhost",
    "metadata.google.internal",
    "metadata.goog",
    "instance-data",
    "instance-data.ec2.internal",
    "metadata.azure.internal",
    "169.254.169.254",
];

pub fn is_special_host(host: &str) -> bool {
    let normalized = normalize_host(host);
    SPECIAL_HOSTS.contains(&normalized.as_str())
}

pub fn is_blocked_host_literal(host: &str) -> bool {
    let normalized = normalize_host(host);
    normalized
        .trim_start_matches('[')
        .trim_end_matches(']')
        .parse::<IpAddr>()
        .is_ok_and(is_blocked_address)
}

pub fn is_blocked_address(addr: IpAddr) -> bool {
    match unmap_ipv4_mapped(addr) {
        IpAddr::V4(addr) => is_blocked_ipv4(addr),
        IpAddr::V6(addr) => is_blocked_ipv6(addr),
    }
}

fn normalize_host(host: &str) -> String {
    host.trim().trim_end_matches('.').to_ascii_lowercase()
}

fn unmap_ipv4_mapped(addr: IpAddr) -> IpAddr {
    match addr {
        IpAddr::V6(addr) => addr
            .to_ipv4_mapped()
            .map(IpAddr::V4)
            .unwrap_or(IpAddr::V6(addr)),
        IpAddr::V4(addr) => IpAddr::V4(addr),
    }
}

fn is_blocked_ipv4(addr: Ipv4Addr) -> bool {
    let octets = addr.octets();
    match octets {
        [0, _, _, _] => true,
        [10, _, _, _] => true,
        [100, second, _, _] if (64..=127).contains(&second) => true,
        [127, _, _, _] => true,
        [169, 254, _, _] => true,
        [172, second, _, _] if (16..=31).contains(&second) => true,
        [192, 0, 0, _] => true,
        [192, 0, 2, _] => true,
        [192, 31, 196, _] => true,
        [192, 52, 193, _] => true,
        [192, 88, 99, _] => true,
        [192, 168, _, _] => true,
        [192, 175, 48, _] => true,
        [198, second, _, _] if (18..=19).contains(&second) => true,
        [198, 51, 100, _] => true,
        [203, 0, 113, _] => true,
        [first, _, _, _] if first >= 224 => true,
        _ => false,
    }
}

fn is_blocked_ipv6(addr: Ipv6Addr) -> bool {
    let segments = addr.segments();
    let ipv4_compatible = segments[..6].iter().all(|segment| *segment == 0);
    let nat64_well_known = segments[0] == 0x0064
        && segments[1] == 0xff9b
        && segments[2..6].iter().all(|segment| *segment == 0);
    let nat64_local = segments[0] == 0x0064 && segments[1] == 0xff9b && segments[2] == 1;
    let discard_only = segments[0] == 0x0100 && segments[1..4].iter().all(|segment| *segment == 0);
    ipv4_compatible
        || addr.is_loopback()
        || nat64_well_known
        || nat64_local
        || discard_only
        || (segments[0] == 0x2001 && segments[1] <= 0x01ff)
        || (segments[0] == 0x2001 && segments[1] == 0x0db8)
        || segments[0] == 0x2002
        || (segments[0] == 0x3fff && (segments[1] & 0xf000) == 0)
        || segments[0] == 0x5f00
        || (segments[0] == 0x2620 && segments[1] == 0x004f && segments[2] == 0x8000)
        || (segments[0] & 0xfe00) == 0xfc00
        || (segments[0] & 0xffc0) == 0xfe80
        || (segments[0] & 0xffc0) == 0xfec0
        || addr.is_multicast()
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde::Deserialize;

    fn contract() -> &'static str {
        std::str::from_utf8(
            redevplugin_contracts::get(
                redevplugin_contracts::ContractId::TARGET_CLASSIFIER_FIXTURE,
            )
            .bytes(),
        )
        .expect("target classifier contract is valid UTF-8")
    }

    #[derive(Deserialize)]
    struct TargetClassifierContract {
        version: String,
        blocked_ip_ranges: Vec<String>,
        special_hosts: Vec<String>,
        fixtures: Vec<TargetClassifierFixture>,
    }

    #[derive(Deserialize)]
    struct TargetClassifierFixture {
        name: String,
        destination: String,
        resolved_address: Option<String>,
        decision: String,
    }

    #[test]
    fn constants_match_target_classifier_contract() {
        let contract = read_contract();
        assert_eq!(contract.version, TARGET_CLASSIFIER_VERSION);
        let ranges = contract
            .blocked_ip_ranges
            .iter()
            .map(String::as_str)
            .collect::<Vec<_>>();
        let hosts = contract
            .special_hosts
            .iter()
            .map(String::as_str)
            .collect::<Vec<_>>();
        assert_eq!(ranges, BLOCKED_IP_RANGES);
        assert_eq!(hosts, SPECIAL_HOSTS);
    }

    #[test]
    fn classifier_matches_target_classifier_fixtures() {
        let contract = read_contract();
        assert!(!contract.fixtures.is_empty());
        for fixture in contract.fixtures {
            let host = host_from_destination(&fixture.destination);
            let mut denied = is_special_host(host) || is_blocked_host_literal(host);
            if let Some(resolved_address) = fixture.resolved_address.as_deref() {
                let addr = resolved_address.parse::<IpAddr>().unwrap_or_else(|err| {
                    panic!("{} resolved address parse error: {err}", fixture.name)
                });
                denied = denied || is_blocked_address(addr);
            }
            match fixture.decision.as_str() {
                "allow" => assert!(!denied, "{} should be allowed", fixture.name),
                "deny" => assert!(denied, "{} should be denied", fixture.name),
                other => panic!("{} has unsupported decision {other}", fixture.name),
            }
        }
    }

    #[test]
    fn classifier_matches_3fff_documentation_prefix_boundaries() {
        for denied in ["3fff::", "3fff:0fff:ffff::"] {
            assert!(
                is_blocked_address(denied.parse().expect("blocked IPv6 address must parse")),
                "{denied} must be blocked by 3fff::/20"
            );
        }
        for allowed in ["3ffe:ffff::", "3fff:1000::", "3ff0::"] {
            assert!(
                !is_blocked_address(allowed.parse().expect("allowed IPv6 address must parse")),
                "{allowed} must remain outside 3fff::/20"
            );
        }
    }

    fn read_contract() -> TargetClassifierContract {
        serde_json::from_str(contract()).expect("target classifier contract must decode")
    }

    fn host_from_destination(destination: &str) -> &str {
        let authority = destination
            .split_once("://")
            .map(|(_, rest)| rest)
            .unwrap_or(destination);
        if let Some(without_bracket) = authority.strip_prefix('[') {
            return without_bracket
                .split_once(']')
                .map(|(host, _)| host)
                .unwrap_or(without_bracket);
        }
        authority
            .split_once(':')
            .map(|(host, _)| host)
            .unwrap_or(authority)
    }
}