fluidattacks_core_git/
ssh_host.rs1use crate::clone::ssh::drop_scp_scheme;
2use crate::errors::HostError;
3use crate::local_address::{outside_addresses, read_host, AllowedLocal, Host};
4
5pub async fn validate_ssh_host(url: &str, allowed: AllowedLocal) -> Result<(), HostError> {
7 let host = stored_host(url).ok_or(HostError::Unparsable)?;
8 outside_addresses(&host, allowed).await.map(|_| ())
9}
10
11fn stored_host(url: &str) -> Option<Host<'_>> {
14 let url = drop_scp_scheme(url);
15 let (scp, rest) = url
16 .strip_prefix("ssh://")
17 .map_or((true, url), |rest| (false, rest));
18 let (user, rest) = rest.split_once('@')?;
19 if !is_user(user) || rest.contains('@') {
20 return None;
21 }
22 let (host, rest) = read_host(rest)?;
23 let path_follows = if scp {
24 rest.starts_with(':')
25 } else {
26 drop_port(rest).starts_with('/')
27 };
28 path_follows.then_some(host)
29}
30
31fn is_user(user: &str) -> bool {
32 let mut chars = user.chars();
33 chars
34 .next()
35 .is_some_and(|first| first.is_ascii_alphabetic())
36 && !chars.as_str().is_empty()
37 && chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '-'))
38}
39
40fn drop_port(rest: &str) -> &str {
41 rest.strip_prefix(':').map_or(rest, |port| {
42 port.trim_start_matches(|c: char| c.is_ascii_digit())
43 })
44}
45
46#[cfg(test)]
47mod tests {
48 use super::validate_ssh_host;
49 use crate::errors::{HostError, LocalAddress};
50 use crate::local_address::AllowedLocal;
51
52 async fn check(url: &str) -> Result<(), HostError> {
53 validate_ssh_host(url, AllowedLocal::default()).await
54 }
55
56 #[tokio::test]
57 async fn each_form_integrates_stores_is_checked_on_its_host() {
58 for url in [
59 "ssh://git@10.0.0.1/org/repo",
60 "ssh://git@10.0.0.1:2222/org/repo",
61 "ssh://git@10.0.0.1:v3/org/project/repo",
62 "ssh://git@10.0.0.1:org/repo",
63 "git@10.0.0.1:org/repo.git",
64 "ssh://git@[fc00::1]:22/org/repo",
65 "git@[fc00::1]:org/repo.git",
66 ] {
67 assert!(
68 matches!(check(url).await, Err(HostError::Local(_))),
69 "{url}"
70 );
71 }
72 }
73
74 #[tokio::test]
75 async fn a_url_outside_the_stored_forms_is_unparsable() {
76 for url in [
77 "ssh:///org/repo",
78 "git@10.0.0.1:22@8.8.8.8:22/org/repo",
79 "ssh://git@8.8.8.8:22@8.8.8.8:22/org/repo",
80 "ssh://8.8.8.8/org/repo",
81 "8.8.8.8:org/repo",
82 "g@8.8.8.8:org/repo",
83 "1git@8.8.8.8:org/repo",
84 "git@8.8.8.8/org/repo",
85 "ssh://git@8.8.8.8",
86 "git@[::1:org/repo",
87 "git@[not-an-ip]:org/repo",
88 "git@exa mple.com:org/repo",
89 "ssh://git@%31%30.0.0.1/org/repo",
90 "ssh://git@10.0.0.1%2f.8.8.8.8.nip.io/org/repo",
91 "ssh://git@10.0.0.1%3a6379/org/repo",
92 "https://git@8.8.8.8/org/repo",
93 ] {
94 assert!(
95 matches!(check(url).await, Err(HostError::Unparsable)),
96 "{url}"
97 );
98 }
99 }
100
101 #[tokio::test]
102 async fn a_host_past_the_dns_limits_is_unparsable() {
103 let long_label = format!("git@{}.com:org/repo", "a".repeat(64));
104 let long_name = format!("git@{}abcd:org/repo", "a.".repeat(125));
105 let empty_label = "git@example..com:org/repo".to_owned();
106 for url in [long_label, long_name, empty_label] {
107 assert!(
108 matches!(check(&url).await, Err(HostError::Unparsable)),
109 "{url}"
110 );
111 }
112 }
113
114 #[tokio::test]
115 async fn a_public_address_passes() {
116 check("git@8.8.8.8:org/repo.git").await.unwrap();
117 check("git@[2606:4700::1111]:org/repo.git").await.unwrap();
118 }
119
120 #[tokio::test]
121 async fn a_name_that_does_not_resolve_is_unresolved() {
122 let result = check("git@does-not-exist.invalid:org/repo.git").await;
123
124 assert!(matches!(result, Err(HostError::Unresolved(_))));
125 }
126
127 #[tokio::test]
128 async fn a_name_is_checked_on_the_addresses_it_resolves_to() {
129 let result = check("git@localhost:org/repo.git").await;
130
131 assert!(matches!(
132 result,
133 Err(HostError::Local(LocalAddress::Loopback))
134 ));
135 }
136
137 #[tokio::test]
138 async fn allowing_localhost_lets_loopback_through_and_nothing_else() {
139 let allowed = AllowedLocal {
140 localhost: true,
141 local_network: false,
142 };
143
144 validate_ssh_host("git@127.0.0.1:org/repo", allowed)
145 .await
146 .unwrap();
147 assert!(matches!(
148 validate_ssh_host("git@10.0.0.1:org/repo", allowed).await,
149 Err(HostError::Local(LocalAddress::LocalNetwork))
150 ));
151 }
152
153 #[tokio::test]
154 async fn allowing_the_local_network_lets_it_through_and_not_loopback() {
155 let allowed = AllowedLocal {
156 localhost: false,
157 local_network: true,
158 };
159
160 validate_ssh_host("git@10.0.0.1:org/repo", allowed)
161 .await
162 .unwrap();
163 assert!(matches!(
164 validate_ssh_host("git@127.0.0.1:org/repo", allowed).await,
165 Err(HostError::Local(LocalAddress::Loopback))
166 ));
167 }
168}