Skip to main content

fluidattacks_core_git/
ssh_host.rs

1use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
2
3use crate::clone::ssh::drop_scp_scheme;
4use crate::errors::SshHostError;
5use crate::local_address::{local_address, LocalAddress};
6
7/// Which local addresses `validate_ssh_host` lets through; each flag covers its own group.
8#[derive(Debug, Clone, Copy, Default)]
9pub struct AllowedLocal {
10    pub localhost: bool,
11    pub local_network: bool,
12}
13
14enum Host<'a> {
15    Ip(IpAddr),
16    Name(&'a str),
17}
18
19/// Refuses an ssh url whose host resolves to a local address.
20pub async fn validate_ssh_host(url: &str, allowed: AllowedLocal) -> Result<(), SshHostError> {
21    let host = stored_host(url).ok_or(SshHostError::Malformed)?;
22    let (name, ips) = match host {
23        Host::Ip(ip) => (ip.to_string(), vec![ip]),
24        Host::Name(name) => (name.to_owned(), resolve(name).await?),
25    };
26    if ips.is_empty() {
27        return Err(SshHostError::NoAddresses { host: name });
28    }
29    ips.into_iter()
30        .filter_map(local_address)
31        .find(|local| !allowed.lets_through(*local))
32        .map_or(Ok(()), |reason| {
33            Err(SshHostError::Local { host: name, reason })
34        })
35}
36
37impl AllowedLocal {
38    const fn lets_through(self, local: LocalAddress) -> bool {
39        match local {
40            LocalAddress::Loopback => self.localhost,
41            LocalAddress::LocalNetwork => self.local_network,
42        }
43    }
44}
45
46async fn resolve(name: &str) -> Result<Vec<IpAddr>, SshHostError> {
47    let addresses = tokio::net::lookup_host((name, 22))
48        .await
49        .map_err(|source| SshHostError::Unresolved {
50            host: name.to_owned(),
51            source,
52        })?;
53    Ok(addresses.map(|address| address.ip()).collect())
54}
55
56/// The host of `ssh://user@host[:port]/path` or `user@host:path`, the forms integrates stores;
57/// any other url is `None`, since git could reach a host other than the one read here.
58fn stored_host(url: &str) -> Option<Host<'_>> {
59    let url = drop_scp_scheme(url);
60    let (scp, rest) = url
61        .strip_prefix("ssh://")
62        .map_or((true, url), |rest| (false, rest));
63    let (user, rest) = rest.split_once('@')?;
64    if !is_user(user) || rest.contains('@') {
65        return None;
66    }
67    let (host, rest) = split_host(rest)?;
68    let path_follows = if scp {
69        rest.starts_with(':')
70    } else {
71        drop_port(rest).starts_with('/')
72    };
73    path_follows.then_some(host)
74}
75
76fn is_user(user: &str) -> bool {
77    let mut chars = user.chars();
78    chars
79        .next()
80        .is_some_and(|first| first.is_ascii_alphabetic())
81        && !chars.as_str().is_empty()
82        && chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '-'))
83}
84
85fn split_host(rest: &str) -> Option<(Host<'_>, &str)> {
86    if let Some(bracketed) = rest.strip_prefix('[') {
87        let (ip, after) = bracketed.split_once(']')?;
88        return Some((Host::Ip(IpAddr::V6(ip.parse::<Ipv6Addr>().ok()?)), after));
89    }
90    let end = rest.find([':', '/']).unwrap_or(rest.len());
91    let (host, rest) = rest.split_at(end);
92    let host = match host.parse::<Ipv4Addr>() {
93        Ok(ip) => Host::Ip(IpAddr::V4(ip)),
94        Err(_) => Host::Name(is_name(host).then_some(host)?),
95    };
96    Some((host, rest))
97}
98
99fn drop_port(rest: &str) -> &str {
100    rest.strip_prefix(':').map_or(rest, |port| {
101        port.trim_start_matches(|c: char| c.is_ascii_digit())
102    })
103}
104
105/// A name within DNS limits: labels of 1 to 63 and 253 in all, a trailing dot aside.
106fn is_name(host: &str) -> bool {
107    let name = host.strip_suffix('.').unwrap_or(host);
108    name.len() <= 253
109        && name.split('.').all(|label| {
110            (1..=63).contains(&label.len())
111                // RFC 1123's letters, digits and `-`, plus the `_` some internal names use;
112                // a `%` is out, since git decodes it in `ssh://` and would reach another host.
113                && label
114                    .chars()
115                    .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_'))
116        })
117}
118
119#[cfg(test)]
120mod tests {
121    use super::{validate_ssh_host, AllowedLocal};
122    use crate::errors::SshHostError;
123    use crate::local_address::LocalAddress;
124
125    async fn check(url: &str) -> Result<(), SshHostError> {
126        validate_ssh_host(url, AllowedLocal::default()).await
127    }
128
129    #[tokio::test]
130    async fn each_form_integrates_stores_is_checked_on_its_host() {
131        for url in [
132            "ssh://git@10.0.0.1/org/repo",
133            "ssh://git@10.0.0.1:2222/org/repo",
134            "ssh://git@10.0.0.1:v3/org/project/repo",
135            "ssh://git@10.0.0.1:org/repo",
136            "git@10.0.0.1:org/repo.git",
137            "ssh://git@[fc00::1]:22/org/repo",
138            "git@[fc00::1]:org/repo.git",
139        ] {
140            assert!(
141                matches!(check(url).await, Err(SshHostError::Local { .. })),
142                "{url}"
143            );
144        }
145    }
146
147    #[tokio::test]
148    async fn a_url_outside_the_stored_forms_is_malformed() {
149        for url in [
150            "ssh:///org/repo",
151            "git@10.0.0.1:22@8.8.8.8:22/org/repo",
152            "ssh://git@8.8.8.8:22@8.8.8.8:22/org/repo",
153            "ssh://8.8.8.8/org/repo",
154            "8.8.8.8:org/repo",
155            "g@8.8.8.8:org/repo",
156            "1git@8.8.8.8:org/repo",
157            "git@8.8.8.8/org/repo",
158            "ssh://git@8.8.8.8",
159            "git@[::1:org/repo",
160            "git@[not-an-ip]:org/repo",
161            "git@exa mple.com:org/repo",
162            "ssh://git@%31%30.0.0.1/org/repo",
163            "ssh://git@10.0.0.1%2f.8.8.8.8.nip.io/org/repo",
164            "ssh://git@10.0.0.1%3a6379/org/repo",
165            "https://git@8.8.8.8/org/repo",
166        ] {
167            assert!(
168                matches!(check(url).await, Err(SshHostError::Malformed)),
169                "{url}"
170            );
171        }
172    }
173
174    #[tokio::test]
175    async fn a_host_past_the_dns_limits_is_malformed() {
176        let long_label = format!("git@{}.com:org/repo", "a".repeat(64));
177        let long_name = format!("git@{}abcd:org/repo", "a.".repeat(125));
178        let empty_label = "git@example..com:org/repo".to_owned();
179        for url in [long_label, long_name, empty_label] {
180            assert!(
181                matches!(check(&url).await, Err(SshHostError::Malformed)),
182                "{url}"
183            );
184        }
185    }
186
187    #[tokio::test]
188    async fn a_public_address_passes() {
189        check("git@8.8.8.8:org/repo.git").await.unwrap();
190        check("git@[2606:4700::1111]:org/repo.git").await.unwrap();
191    }
192
193    #[tokio::test]
194    async fn a_name_that_does_not_resolve_is_unresolved() {
195        let result = check("git@does-not-exist.invalid:org/repo.git").await;
196
197        assert!(matches!(result, Err(SshHostError::Unresolved { .. })));
198    }
199
200    #[tokio::test]
201    async fn a_name_is_checked_on_the_addresses_it_resolves_to() {
202        let result = check("git@localhost:org/repo.git").await;
203
204        assert!(matches!(
205            result,
206            Err(SshHostError::Local {
207                reason: LocalAddress::Loopback,
208                ..
209            })
210        ));
211    }
212
213    #[tokio::test]
214    async fn allowing_localhost_lets_loopback_through_and_nothing_else() {
215        let allowed = AllowedLocal {
216            localhost: true,
217            local_network: false,
218        };
219
220        validate_ssh_host("git@127.0.0.1:org/repo", allowed)
221            .await
222            .unwrap();
223        assert!(matches!(
224            validate_ssh_host("git@10.0.0.1:org/repo", allowed).await,
225            Err(SshHostError::Local {
226                reason: LocalAddress::LocalNetwork,
227                ..
228            })
229        ));
230    }
231
232    #[tokio::test]
233    async fn allowing_the_local_network_lets_it_through_and_not_loopback() {
234        let allowed = AllowedLocal {
235            localhost: false,
236            local_network: true,
237        };
238
239        validate_ssh_host("git@10.0.0.1:org/repo", allowed)
240            .await
241            .unwrap();
242        assert!(matches!(
243            validate_ssh_host("git@127.0.0.1:org/repo", allowed).await,
244            Err(SshHostError::Local {
245                reason: LocalAddress::Loopback,
246                ..
247            })
248        ));
249    }
250}