fluidattacks_core_git/
ssh_host.rs1use 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
7pub 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
19fn 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
68fn 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 && 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}