Skip to main content

fluidattacks_core_git/
ssh_host.rs

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