Skip to main content

fluidattacks_core_git/
ssh_host.rs

1use crate::clone::ssh::drop_scp_scheme;
2use crate::errors::HostError;
3use crate::local_address::{outside_addresses, read_host, AllowedLocal, Host};
4
5/// Refuses an ssh url whose host resolves to a local address.
6pub async fn validate_ssh_host(url: &str, allowed: AllowedLocal) -> Result<(), HostError> {
7    let host = stored_host(url).ok_or(HostError::Unparsable)?;
8    outside_addresses(&host, allowed).await.map(|_| ())
9}
10
11/// The host of `ssh://user@host[:port]/path` or `user@host:path`, the forms integrates stores;
12/// any other url is `None`, since git could reach a host other than the one read here.
13fn stored_host(url: &str) -> Option<Host<'_>> {
14    let url = drop_scp_scheme(url);
15    let (scp, rest) = url
16        .strip_prefix("ssh://")
17        .map_or((true, url), |rest| (false, rest));
18    let (user, rest) = rest.split_once('@')?;
19    if !is_user(user) || rest.contains('@') {
20        return None;
21    }
22    let (host, rest) = read_host(rest)?;
23    let path_follows = if scp {
24        rest.starts_with(':')
25    } else {
26        drop_port(rest).starts_with('/')
27    };
28    path_follows.then_some(host)
29}
30
31fn is_user(user: &str) -> bool {
32    let mut chars = user.chars();
33    chars
34        .next()
35        .is_some_and(|first| first.is_ascii_alphabetic())
36        && !chars.as_str().is_empty()
37        && chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '-'))
38}
39
40fn drop_port(rest: &str) -> &str {
41    rest.strip_prefix(':').map_or(rest, |port| {
42        port.trim_start_matches(|c: char| c.is_ascii_digit())
43    })
44}
45
46#[cfg(test)]
47mod tests {
48    use super::validate_ssh_host;
49    use crate::errors::{HostError, LocalAddress};
50    use crate::local_address::AllowedLocal;
51
52    async fn check(url: &str) -> Result<(), HostError> {
53        validate_ssh_host(url, AllowedLocal::default()).await
54    }
55
56    #[tokio::test]
57    async fn each_form_integrates_stores_is_checked_on_its_host() {
58        for url in [
59            "ssh://git@10.0.0.1/org/repo",
60            "ssh://git@10.0.0.1:2222/org/repo",
61            "ssh://git@10.0.0.1:v3/org/project/repo",
62            "ssh://git@10.0.0.1:org/repo",
63            "git@10.0.0.1:org/repo.git",
64            "ssh://git@[fc00::1]:22/org/repo",
65            "git@[fc00::1]:org/repo.git",
66        ] {
67            assert!(
68                matches!(check(url).await, Err(HostError::Local(_))),
69                "{url}"
70            );
71        }
72    }
73
74    #[tokio::test]
75    async fn a_url_outside_the_stored_forms_is_unparsable() {
76        for url in [
77            "ssh:///org/repo",
78            "git@10.0.0.1:22@8.8.8.8:22/org/repo",
79            "ssh://git@8.8.8.8:22@8.8.8.8:22/org/repo",
80            "ssh://8.8.8.8/org/repo",
81            "8.8.8.8:org/repo",
82            "g@8.8.8.8:org/repo",
83            "1git@8.8.8.8:org/repo",
84            "git@8.8.8.8/org/repo",
85            "ssh://git@8.8.8.8",
86            "git@[::1:org/repo",
87            "git@[not-an-ip]:org/repo",
88            "git@exa mple.com:org/repo",
89            "ssh://git@%31%30.0.0.1/org/repo",
90            "ssh://git@10.0.0.1%2f.8.8.8.8.nip.io/org/repo",
91            "ssh://git@10.0.0.1%3a6379/org/repo",
92            "https://git@8.8.8.8/org/repo",
93        ] {
94            assert!(
95                matches!(check(url).await, Err(HostError::Unparsable)),
96                "{url}"
97            );
98        }
99    }
100
101    #[tokio::test]
102    async fn a_host_past_the_dns_limits_is_unparsable() {
103        let long_label = format!("git@{}.com:org/repo", "a".repeat(64));
104        let long_name = format!("git@{}abcd:org/repo", "a.".repeat(125));
105        let empty_label = "git@example..com:org/repo".to_owned();
106        for url in [long_label, long_name, empty_label] {
107            assert!(
108                matches!(check(&url).await, Err(HostError::Unparsable)),
109                "{url}"
110            );
111        }
112    }
113
114    #[tokio::test]
115    async fn a_public_address_passes() {
116        check("git@8.8.8.8:org/repo.git").await.unwrap();
117        check("git@[2606:4700::1111]:org/repo.git").await.unwrap();
118    }
119
120    #[tokio::test]
121    async fn a_name_that_does_not_resolve_is_unresolved() {
122        let result = check("git@does-not-exist.invalid:org/repo.git").await;
123
124        assert!(matches!(result, Err(HostError::Unresolved(_))));
125    }
126
127    #[tokio::test]
128    async fn a_name_is_checked_on_the_addresses_it_resolves_to() {
129        let result = check("git@localhost:org/repo.git").await;
130
131        assert!(matches!(
132            result,
133            Err(HostError::Local(LocalAddress::Loopback))
134        ));
135    }
136
137    #[tokio::test]
138    async fn allowing_localhost_lets_loopback_through_and_nothing_else() {
139        let allowed = AllowedLocal {
140            localhost: true,
141            local_network: false,
142        };
143
144        validate_ssh_host("git@127.0.0.1:org/repo", allowed)
145            .await
146            .unwrap();
147        assert!(matches!(
148            validate_ssh_host("git@10.0.0.1:org/repo", allowed).await,
149            Err(HostError::Local(LocalAddress::LocalNetwork))
150        ));
151    }
152
153    #[tokio::test]
154    async fn allowing_the_local_network_lets_it_through_and_not_loopback() {
155        let allowed = AllowedLocal {
156            localhost: false,
157            local_network: true,
158        };
159
160        validate_ssh_host("git@10.0.0.1:org/repo", allowed)
161            .await
162            .unwrap();
163        assert!(matches!(
164            validate_ssh_host("git@127.0.0.1:org/repo", allowed).await,
165            Err(HostError::Local(LocalAddress::Loopback))
166        ));
167    }
168}