fluidattacks-core-git 0.68.0

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

use crate::clone::ssh::drop_scp_scheme;
use crate::errors::SshHostError;
use crate::local_address::{local_address, LocalAddress};

/// Which local addresses `validate_ssh_host` lets through; each flag covers its own group.
#[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,
        }
    }
}

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

impl fmt::Display for Host<'_> {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::Ip(ip) => ip.fmt(f),
            Self::Name(name) => f.write_str(name),
        }
    }
}

impl Host<'_> {
    async fn addresses(&self) -> Result<Vec<IpAddr>, SshHostError> {
        match self {
            Self::Ip(ip) => Ok(vec![*ip]),
            Self::Name(name) => resolve(name).await,
        }
    }
}

/// Refuses an ssh url whose host resolves to a local address.
pub async fn validate_ssh_host(url: &str, allowed: AllowedLocal) -> Result<(), SshHostError> {
    let host = stored_host(url).ok_or(SshHostError::Malformed)?;
    let ips = host.addresses().await?;
    if ips.is_empty() {
        return Err(SshHostError::NoAddresses {
            host: host.to_string(),
        });
    }
    ips.into_iter()
        .filter_map(local_address)
        .find(|local| !allowed.lets_through(*local))
        .map_or(Ok(()), |reason| {
            Err(SshHostError::Local {
                host: host.to_string(),
                reason,
            })
        })
}

async fn resolve(name: &str) -> Result<Vec<IpAddr>, SshHostError> {
    let addresses = tokio::net::lookup_host((name, 22))
        .await
        .map_err(|source| SshHostError::Unresolved {
            host: name.to_owned(),
            source,
        })?;
    Ok(addresses.map(|address| address.ip()).collect())
}

/// The host of `ssh://user@host[:port]/path` or `user@host:path`, the forms integrates stores;
/// any other url is `None`, since git could reach a host other than the one read here.
fn stored_host(url: &str) -> Option<Host<'_>> {
    let url = drop_scp_scheme(url);
    let (scp, rest) = url
        .strip_prefix("ssh://")
        .map_or((true, url), |rest| (false, rest));
    let (user, rest) = rest.split_once('@')?;
    if !is_user(user) || rest.contains('@') {
        return None;
    }
    let (host, rest) = split_host(rest)?;
    let path_follows = if scp {
        rest.starts_with(':')
    } else {
        drop_port(rest).starts_with('/')
    };
    path_follows.then_some(host)
}

fn is_user(user: &str) -> bool {
    let mut chars = user.chars();
    chars
        .next()
        .is_some_and(|first| first.is_ascii_alphabetic())
        && !chars.as_str().is_empty()
        && chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '-'))
}

fn split_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))
}

fn drop_port(rest: &str) -> &str {
    rest.strip_prefix(':').map_or(rest, |port| {
        port.trim_start_matches(|c: char| c.is_ascii_digit())
    })
}

/// 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 decodes it in `ssh://` and would reach another host.
                && label
                    .chars()
                    .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_'))
        })
}

#[cfg(test)]
mod tests {
    use super::{validate_ssh_host, AllowedLocal};
    use crate::errors::SshHostError;
    use crate::local_address::LocalAddress;

    async fn check(url: &str) -> Result<(), SshHostError> {
        validate_ssh_host(url, AllowedLocal::default()).await
    }

    #[tokio::test]
    async fn each_form_integrates_stores_is_checked_on_its_host() {
        for url in [
            "ssh://git@10.0.0.1/org/repo",
            "ssh://git@10.0.0.1:2222/org/repo",
            "ssh://git@10.0.0.1:v3/org/project/repo",
            "ssh://git@10.0.0.1:org/repo",
            "git@10.0.0.1:org/repo.git",
            "ssh://git@[fc00::1]:22/org/repo",
            "git@[fc00::1]:org/repo.git",
        ] {
            assert!(
                matches!(check(url).await, Err(SshHostError::Local { .. })),
                "{url}"
            );
        }
    }

    #[tokio::test]
    async fn a_url_outside_the_stored_forms_is_malformed() {
        for url in [
            "ssh:///org/repo",
            "git@10.0.0.1:22@8.8.8.8:22/org/repo",
            "ssh://git@8.8.8.8:22@8.8.8.8:22/org/repo",
            "ssh://8.8.8.8/org/repo",
            "8.8.8.8:org/repo",
            "g@8.8.8.8:org/repo",
            "1git@8.8.8.8:org/repo",
            "git@8.8.8.8/org/repo",
            "ssh://git@8.8.8.8",
            "git@[::1:org/repo",
            "git@[not-an-ip]:org/repo",
            "git@exa mple.com:org/repo",
            "ssh://git@%31%30.0.0.1/org/repo",
            "ssh://git@10.0.0.1%2f.8.8.8.8.nip.io/org/repo",
            "ssh://git@10.0.0.1%3a6379/org/repo",
            "https://git@8.8.8.8/org/repo",
        ] {
            assert!(
                matches!(check(url).await, Err(SshHostError::Malformed)),
                "{url}"
            );
        }
    }

    #[tokio::test]
    async fn a_host_past_the_dns_limits_is_malformed() {
        let long_label = format!("git@{}.com:org/repo", "a".repeat(64));
        let long_name = format!("git@{}abcd:org/repo", "a.".repeat(125));
        let empty_label = "git@example..com:org/repo".to_owned();
        for url in [long_label, long_name, empty_label] {
            assert!(
                matches!(check(&url).await, Err(SshHostError::Malformed)),
                "{url}"
            );
        }
    }

    #[tokio::test]
    async fn a_public_address_passes() {
        check("git@8.8.8.8:org/repo.git").await.unwrap();
        check("git@[2606:4700::1111]:org/repo.git").await.unwrap();
    }

    #[tokio::test]
    async fn a_name_that_does_not_resolve_is_unresolved() {
        let result = check("git@does-not-exist.invalid:org/repo.git").await;

        assert!(matches!(result, Err(SshHostError::Unresolved { .. })));
    }

    #[tokio::test]
    async fn a_name_is_checked_on_the_addresses_it_resolves_to() {
        let result = check("git@localhost:org/repo.git").await;

        assert!(matches!(
            result,
            Err(SshHostError::Local {
                reason: LocalAddress::Loopback,
                ..
            })
        ));
    }

    #[tokio::test]
    async fn allowing_localhost_lets_loopback_through_and_nothing_else() {
        let allowed = AllowedLocal {
            localhost: true,
            local_network: false,
        };

        validate_ssh_host("git@127.0.0.1:org/repo", allowed)
            .await
            .unwrap();
        assert!(matches!(
            validate_ssh_host("git@10.0.0.1:org/repo", allowed).await,
            Err(SshHostError::Local {
                reason: LocalAddress::LocalNetwork,
                ..
            })
        ));
    }

    #[tokio::test]
    async fn allowing_the_local_network_lets_it_through_and_not_loopback() {
        let allowed = AllowedLocal {
            localhost: false,
            local_network: true,
        };

        validate_ssh_host("git@10.0.0.1:org/repo", allowed)
            .await
            .unwrap();
        assert!(matches!(
            validate_ssh_host("git@127.0.0.1:org/repo", allowed).await,
            Err(SshHostError::Local {
                reason: LocalAddress::Loopback,
                ..
            })
        ));
    }
}