fluidattacks-core-git 0.71.0

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

use crate::clone::ssh::drop_scp_scheme;
use crate::errors::SshUrlError;
use crate::local_address::{outside_addresses, AllowedLocal, Host};

/// Refuses an ssh url whose host resolves to a local address.
pub async fn validate_ssh_host(url: &str, allowed: AllowedLocal) -> Result<(), SshUrlError> {
    let host = stored_host(url).ok_or(SshUrlError::Malformed)?;
    outside_addresses(&host, allowed)
        .await
        .map(|_| ())
        .map_err(|source| SshUrlError::Refused {
            host: host.to_string(),
            source,
        })
}

/// 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;
    use crate::errors::{HostError, SshUrlError};
    use crate::local_address::{AllowedLocal, LocalAddress};

    async fn check(url: &str) -> Result<(), SshUrlError> {
        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(SshUrlError::Refused {
                        source: HostError::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(SshUrlError::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(SshUrlError::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(SshUrlError::Refused {
                source: HostError::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(SshUrlError::Refused {
                source: HostError::Local(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(SshUrlError::Refused {
                source: HostError::Local(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(SshUrlError::Refused {
                source: HostError::Local(LocalAddress::Loopback),
                ..
            })
        ));
    }
}