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#[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
47pub 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
77fn 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
126fn 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 && 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}