fluidattacks-core-git 0.74.0

Fluid Attacks Core Git
Documentation
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};

use ipnet::{Ipv4Net, Ipv6Net};
use reqwest::dns::{Addrs, Name, Resolve, Resolving};
use url::Url;

use crate::errors::{HostError, LocalAddress};

const LOOPBACK_V4: Ipv4Net = Ipv4Net::new_assert(Ipv4Addr::new(127, 0, 0, 0), 8);

// IANA's special-purpose ranges, as Python's ipaddress reads them; std's is_global, which covers them, is nightly-only.
const LOCAL_NETWORK_V4: [Ipv4Net; 14] = [
    Ipv4Net::new_assert(Ipv4Addr::UNSPECIFIED, 8),
    Ipv4Net::new_assert(Ipv4Addr::new(10, 0, 0, 0), 8),
    Ipv4Net::new_assert(Ipv4Addr::new(100, 64, 0, 0), 10),
    Ipv4Net::new_assert(Ipv4Addr::new(169, 254, 0, 0), 16),
    Ipv4Net::new_assert(Ipv4Addr::new(172, 16, 0, 0), 12),
    Ipv4Net::new_assert(Ipv4Addr::new(192, 0, 0, 0), 24),
    Ipv4Net::new_assert(Ipv4Addr::new(192, 0, 0, 170), 31),
    Ipv4Net::new_assert(Ipv4Addr::new(192, 0, 2, 0), 24),
    Ipv4Net::new_assert(Ipv4Addr::new(192, 168, 0, 0), 16),
    Ipv4Net::new_assert(Ipv4Addr::new(198, 18, 0, 0), 15),
    Ipv4Net::new_assert(Ipv4Addr::new(198, 51, 100, 0), 24),
    Ipv4Net::new_assert(Ipv4Addr::new(203, 0, 113, 0), 24),
    Ipv4Net::new_assert(Ipv4Addr::new(240, 0, 0, 0), 4),
    Ipv4Net::new_assert(Ipv4Addr::BROADCAST, 32),
];

const GLOBAL_V4: [Ipv4Addr; 2] = [Ipv4Addr::new(192, 0, 0, 9), Ipv4Addr::new(192, 0, 0, 10)];

const LOCAL_NETWORK_V6: [Ipv6Net; 9] = [
    Ipv6Net::new_assert(Ipv6Addr::UNSPECIFIED, 128),
    Ipv6Net::new_assert(Ipv6Addr::new(0x64, 0xff9b, 1, 0, 0, 0, 0, 0), 48),
    Ipv6Net::new_assert(Ipv6Addr::new(0x100, 0, 0, 0, 0, 0, 0, 0), 64),
    Ipv6Net::new_assert(Ipv6Addr::new(0x2001, 0, 0, 0, 0, 0, 0, 0), 23),
    Ipv6Net::new_assert(Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0, 0, 0), 32),
    Ipv6Net::new_assert(Ipv6Addr::new(0x2002, 0, 0, 0, 0, 0, 0, 0), 16),
    Ipv6Net::new_assert(Ipv6Addr::new(0x3fff, 0, 0, 0, 0, 0, 0, 0), 20),
    Ipv6Net::new_assert(Ipv6Addr::new(0xfc00, 0, 0, 0, 0, 0, 0, 0), 7),
    Ipv6Net::new_assert(Ipv6Addr::new(0xfe80, 0, 0, 0, 0, 0, 0, 0), 10),
];

const GLOBAL_V6: [Ipv6Net; 6] = [
    Ipv6Net::new_assert(Ipv6Addr::new(0x2001, 1, 0, 0, 0, 0, 0, 1), 128),
    Ipv6Net::new_assert(Ipv6Addr::new(0x2001, 1, 0, 0, 0, 0, 0, 2), 128),
    Ipv6Net::new_assert(Ipv6Addr::new(0x2001, 3, 0, 0, 0, 0, 0, 0), 32),
    Ipv6Net::new_assert(Ipv6Addr::new(0x2001, 4, 0x112, 0, 0, 0, 0, 0), 48),
    Ipv6Net::new_assert(Ipv6Addr::new(0x2001, 0x20, 0, 0, 0, 0, 0, 0), 28),
    Ipv6Net::new_assert(Ipv6Addr::new(0x2001, 0x30, 0, 0, 0, 0, 0, 0), 28),
];

#[derive(Debug, Clone, Copy, Default)]
pub struct AllowedLocal {
    pub localhost: bool,
    pub local_network: bool,
}

impl AllowedLocal {
    const fn lets_through(self, local: LocalAddress) -> bool {
        match local {
            LocalAddress::Loopback => self.localhost,
            LocalAddress::LocalNetwork => self.local_network,
        }
    }
}

pub enum Host<'a> {
    Ip(IpAddr),
    Name(&'a str),
}

/// The host `rest` starts with, an `[IPv6]`, an IPv4 or a name, and what follows it.
pub(crate) fn read_host(rest: &str) -> Option<(Host<'_>, &str)> {
    if let Some(bracketed) = rest.strip_prefix('[') {
        let (ip, after) = bracketed.split_once(']')?;
        return Some((Host::Ip(IpAddr::V6(ip.parse::<Ipv6Addr>().ok()?)), after));
    }
    let end = rest.find([':', '/']).unwrap_or(rest.len());
    let (host, rest) = rest.split_at(end);
    let host = match host.parse::<Ipv4Addr>() {
        Ok(ip) => Host::Ip(IpAddr::V4(ip)),
        Err(_) => Host::Name(is_name(host).then_some(host)?),
    };
    Some((host, rest))
}

/// A name within DNS limits: labels of 1 to 63 and 253 in all, a trailing dot aside.
fn is_name(host: &str) -> bool {
    let name = host.strip_suffix('.').unwrap_or(host);
    name.len() <= 253
        && name.split('.').all(|label| {
            (1..=63).contains(&label.len())
                // RFC 1123's letters, digits and `-`, plus the `_` some internal names use;
                // a `%` is out, since git and curl decode it and would reach another host.
                && label
                    .chars()
                    .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_'))
        })
}

/// Hands reqwest only the addresses it checked, so a second lookup cannot swap in a local one.
pub(crate) struct ProtectedResolver(pub(crate) AllowedLocal);

impl Resolve for ProtectedResolver {
    fn resolve(&self, name: Name) -> Resolving {
        let allowed = self.0;
        Box::pin(async move {
            let ips = outside_addresses(&Host::Name(name.as_str()), allowed).await?;
            let addrs: Addrs = Box::new(ips.into_iter().map(|ip| SocketAddr::new(ip, 0)));
            Ok(addrs)
        })
    }
}

/// `None` for an address reachable from outside; an IPv4 mapped into IPv6 counts as its IPv4.
#[must_use]
pub fn local_address(ip: IpAddr) -> Option<LocalAddress> {
    match ip {
        IpAddr::V4(ip) => local_v4(ip),
        IpAddr::V6(ip) => ip.to_ipv4_mapped().map_or_else(|| local_v6(ip), local_v4),
    }
}

pub async fn outside_addresses(
    host: &Host<'_>,
    allowed: AllowedLocal,
) -> Result<Vec<IpAddr>, HostError> {
    let ips = match host {
        Host::Ip(ip) => vec![*ip],
        Host::Name(name) => tokio::net::lookup_host((*name, 0))
            .await
            .map_err(HostError::Unresolved)?
            .map(|address| address.ip())
            .collect(),
    };
    if ips.is_empty() {
        return Err(HostError::NoAddresses);
    }
    let refused = ips
        .iter()
        .filter_map(|ip| local_address(*ip))
        .find(|local| !allowed.lets_through(*local));
    refused.map_or(Ok(ips), |local| Err(HostError::Local(local)))
}

/// Refuses the url when it is written with a local IP; a name is left to `ProtectedResolver`.
pub(crate) fn refuse_local_ip(url: &Url, allowed: AllowedLocal) -> Result<(), LocalAddress> {
    let ip = match url.host() {
        Some(url::Host::Ipv4(ip)) => IpAddr::V4(ip),
        Some(url::Host::Ipv6(ip)) => IpAddr::V6(ip),
        _ => return Ok(()),
    };
    local_address(ip)
        .filter(|local| !allowed.lets_through(*local))
        .map_or(Ok(()), Err)
}

fn local_v4(ip: Ipv4Addr) -> Option<LocalAddress> {
    if LOOPBACK_V4.contains(&ip) {
        Some(LocalAddress::Loopback)
    } else if LOCAL_NETWORK_V4.iter().any(|net| net.contains(&ip)) && !GLOBAL_V4.contains(&ip) {
        Some(LocalAddress::LocalNetwork)
    } else {
        None
    }
}

fn local_v6(ip: Ipv6Addr) -> Option<LocalAddress> {
    if ip.is_loopback() {
        Some(LocalAddress::Loopback)
    } else if LOCAL_NETWORK_V6.iter().any(|net| net.contains(&ip))
        && !GLOBAL_V6.iter().any(|net| net.contains(&ip))
    {
        Some(LocalAddress::LocalNetwork)
    } else {
        None
    }
}

#[cfg(test)]
mod tests {
    use std::net::IpAddr;

    use reqwest::dns::Resolve;

    use super::{local_address, AllowedLocal, ProtectedResolver};
    use crate::errors::{HostError, LocalAddress};

    fn kind(ip: &str) -> Option<LocalAddress> {
        local_address(ip.parse::<IpAddr>().unwrap())
    }

    // Each address read by Python 3.13's ipaddress: is_loopback, or is_private,
    // is_link_local, 100.64.0.0/10 or 255.255.255.255.
    #[test]
    fn a_loopback_address_is_loopback() {
        for ip in ["127.0.0.1", "127.255.0.1", "::1", "::ffff:127.0.0.1"] {
            assert_eq!(kind(ip), Some(LocalAddress::Loopback), "{ip}");
        }
    }

    #[test]
    fn a_special_purpose_address_is_local_network() {
        for ip in [
            "0.0.0.0",
            "10.0.0.1",
            "100.64.0.1",
            "169.254.1.1",
            "172.16.0.1",
            "192.0.0.1",
            "192.0.0.171",
            "192.0.2.1",
            "192.168.0.1",
            "198.19.0.1",
            "198.51.100.1",
            "203.0.113.1",
            "240.0.0.1",
            "255.255.255.255",
            "::",
            "::ffff:10.0.0.1",
            "64:ff9b:1::1",
            "100::1",
            "2001:10::1",
            "2001:db8::1",
            "2002::1",
            "3fff::1",
            "fc00::1",
            "fe80::1",
        ] {
            assert_eq!(kind(ip), Some(LocalAddress::LocalNetwork), "{ip}");
        }
    }

    // Python reads 100.64.0.0/10 on the plain IPv4 only, and lets the mapped one through.
    #[test]
    fn a_mapped_ipv4_is_read_as_its_ipv4() {
        assert_eq!(kind("::ffff:100.64.0.1"), Some(LocalAddress::LocalNetwork));
    }

    #[test]
    fn a_global_address_is_not_local() {
        for ip in [
            "8.8.8.8",
            "192.0.0.9",
            "192.0.0.10",
            "224.0.0.1",
            "::ffff:8.8.8.8",
            "::127.0.0.1",
            "64:ff9b::a00:1",
            "2001:1::1",
            "2001:3::1",
            "2001:4:112::1",
            "2001:20::1",
            "2001:30::1",
            "2606:4700::1111",
        ] {
            assert_eq!(kind(ip), None, "{ip}");
        }
    }

    #[tokio::test]
    async fn the_resolver_refuses_a_name_that_resolves_to_a_local_address() {
        let resolved = ProtectedResolver(AllowedLocal::default())
            .resolve("localhost".parse().unwrap())
            .await;

        let error = resolved.err().unwrap();
        assert!(
            matches!(
                error.downcast_ref::<HostError>(),
                Some(HostError::Local(LocalAddress::Loopback))
            ),
            "{error:?}"
        );
    }
}