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