zuka
zuka/src/git/import.rs

worklyn / zukapublic

Agent-first git hosting. One Rust binary: git over HTTP and SSH, a REST API, MCP, CI, and multi-tenant isolation.

Get a copy: git clone https://zuka.worklyn.com/worklyn/zuka.git
zuka/src/git/import.rs
RSimport.rs11.6 KBDownload
1// Importing an existing repository.
2//
3// This is the one place the service makes an outbound request to a URL a caller
4// supplied, which makes it the one place SSRF is possible. `git clone` will happily
5// fetch `http://169.254.169.254/` or `http://127.0.0.1:8790/` on our behalf and, in
6// a cloud environment, hand back instance credentials.
7//
8// So the URL is checked in two stages: the shape (scheme, host, credentials), and
9// then every address the host actually resolves to. Checking the hostname alone is
10// not enough — a name that resolves to a private address defeats it, which is the
11// classic DNS-rebinding-adjacent bypass.
12
13use crate::error::{Error, Result};
14use std::net::{IpAddr, ToSocketAddrs};
15use std::path::Path;
16use std::process::Command;
17use std::time::Duration;
18
19/// Schemes we will fetch. `file://`, `ssh://` and `git://` are all excluded: the
20/// first reads the host filesystem, and the other two carry no useful guarantees
21/// here.
22const ALLOWED_SCHEMES: &[&str] = &["https", "http"];
23
24/// Whether an address is one we refuse to fetch from.
25///
26/// Loopback and link-local matter most: `169.254.169.254` is the cloud metadata
27/// endpoint on every major provider, and loopback reaches this service itself.
28pub fn is_forbidden(ip: IpAddr) -> bool {
29 match ip {
30 IpAddr::V4(v4) => {
31 v4.is_loopback()
32 || v4.is_private()
33 || v4.is_link_local()
34 || v4.is_broadcast()
35 || v4.is_documentation()
36 || v4.is_unspecified()
37 // 100.64.0.0/10, carrier-grade NAT.
38 || (v4.octets()[0] == 100 && (64..128).contains(&v4.octets()[1]))
39 // 0.0.0.0/8
40 || v4.octets()[0] == 0
41 // 240.0.0.0/4, reserved.
42 || v4.octets()[0] >= 240
43 }
44 IpAddr::V6(v6) => {
45 v6.is_loopback()
46 || v6.is_unspecified()
47 // Unique local, fc00::/7.
48 || (v6.segments()[0] & 0xfe00) == 0xfc00
49 // Link-local, fe80::/10.
50 || (v6.segments()[0] & 0xffc0) == 0xfe80
51 // An IPv4-mapped address must be judged as the IPv4 it maps to.
52 || v6.to_ipv4_mapped().is_some_and(|v4| is_forbidden(IpAddr::V4(v4)))
53 }
54 }
55}
56
57/// A URL that passed validation, together with the port it will connect to.
58#[derive(Debug, Clone, PartialEq, Eq)]
59pub struct Source {
60 pub url: String,
61 pub host: String,
62 pub port: u16,
63}
64
65/// Validate the shape of an import URL, without resolving it.
66pub fn parse(raw: &str) -> Result<Source> {
67 let refuse = |why: &str| Error::invalid("invalid-import-url", why.to_string());
68
69 if raw.len() > 2048 {
70 return Err(refuse("import_url is too long"));
71 }
72 if raw.contains(char::is_whitespace) || raw.contains('\0') {
73 return Err(refuse("import_url must not contain whitespace"));
74 }
75 // A leading `-` would be read as a flag by git rather than as a URL.
76 if raw.starts_with('-') {
77 return Err(refuse("import_url must not start with '-'"));
78 }
79
80 let (scheme, rest) = raw
81 .split_once("://")
82 .ok_or_else(|| refuse("import_url must include a scheme"))?;
83
84 let scheme = scheme.to_ascii_lowercase();
85 if !ALLOWED_SCHEMES.contains(&scheme.as_str()) {
86 return Err(refuse(&format!(
87 "import_url scheme must be one of {ALLOWED_SCHEMES:?}"
88 )));
89 }
90
91 // Credentials in the URL would be written into the repository's config by
92 // `clone`, so they are refused rather than silently persisted.
93 let authority = rest.split(['/', '?', '#']).next().unwrap_or("");
94 if authority.contains('@') {
95 return Err(refuse(
96 "import_url must not embed credentials; import a public repository",
97 ));
98 }
99 if authority.is_empty() {
100 return Err(refuse("import_url has no host"));
101 }
102
103 let (host, port) = split_host_port(authority, &scheme)?;
104 if host.is_empty() {
105 return Err(refuse("import_url has no host"));
106 }
107
108 Ok(Source {
109 url: raw.to_string(),
110 host,
111 port,
112 })
113}
114
115fn split_host_port(authority: &str, scheme: &str) -> Result<(String, u16)> {
116 let default = if scheme == "https" { 443 } else { 80 };
117
118 // Bracketed IPv6.
119 if let Some(rest) = authority.strip_prefix('[') {
120 let (host, tail) = rest
121 .split_once(']')
122 .ok_or_else(|| Error::invalid("invalid-import-url", "malformed IPv6 host"))?;
123 let port = match tail.strip_prefix(':') {
124 None | Some("") => default,
125 Some(p) => p
126 .parse()
127 .map_err(|_| Error::invalid("invalid-import-url", "malformed port"))?,
128 };
129 return Ok((host.to_string(), port));
130 }
131
132 match authority.rsplit_once(':') {
133 None => Ok((authority.to_string(), default)),
134 Some((host, port)) => Ok((
135 host.to_string(),
136 port.parse()
137 .map_err(|_| Error::invalid("invalid-import-url", "malformed port"))?,
138 )),
139 }
140}
141
142/// Resolve the host and refuse if any address it maps to is one we will not fetch.
143///
144/// Every address is checked, not just the first: a host that returns one public and
145/// one loopback address would otherwise pass and then connect to whichever the
146/// resolver hands `git`.
147pub fn check_resolves_publicly(source: &Source, allow_private: bool) -> Result<Vec<IpAddr>> {
148 let addresses: Vec<IpAddr> = (source.host.as_str(), source.port)
149 .to_socket_addrs()
150 .map_err(|e| {
151 Error::invalid(
152 "invalid-import-url",
153 format!("could not resolve {}: {e}", source.host),
154 )
155 })?
156 .map(|addr| addr.ip())
157 .collect();
158
159 if addresses.is_empty() {
160 return Err(Error::invalid(
161 "invalid-import-url",
162 format!("{} resolved to no addresses", source.host),
163 ));
164 }
165 if allow_private {
166 return Ok(addresses);
167 }
168 if let Some(bad) = addresses.iter().find(|ip| is_forbidden(**ip)) {
169 return Err(Error::invalid(
170 "invalid-import-url",
171 format!(
172 "{} resolves to {bad}, which is not a public address",
173 source.host
174 ),
175 ));
176 }
177 Ok(addresses)
178}
179
180/// Clone a validated source into a bare repository.
181///
182/// `--mirror` brings refs and history without a working copy. The clone is checked
183/// with `fsck` before it is accepted: an imported repository is untrusted input, and
184/// a malformed object graph should fail the import rather than be discovered later
185/// by whoever clones it.
186pub fn fetch(source: &Source, into: &Path, timeout: Duration, max_bytes: u64) -> Result<()> {
187 let output = Command::new("git")
188 .env_clear()
189 .env(
190 "PATH",
191 std::env::var("PATH").unwrap_or_else(|_| "/usr/bin:/bin".into()),
192 )
193 .env("GIT_CONFIG_NOSYSTEM", "1")
194 .env("GIT_TERMINAL_PROMPT", "0")
195 .env("GIT_ASKPASS", "")
196 .env("GIT_HTTP_LOW_SPEED_LIMIT", "1000")
197 .env("GIT_HTTP_LOW_SPEED_TIME", timeout.as_secs().to_string())
198 .args([
199 "clone",
200 "--mirror",
201 "--quiet",
202 // A submodule would be a second URL we never validated.
203 "--no-recurse-submodules",
204 "-c",
205 "transfer.fsckObjects=true",
206 "-c",
207 &format!("http.maxRequestBuffer={max_bytes}"),
208 "--",
209 &source.url,
210 ])
211 .arg(into)
212 .output()
213 .map_err(|e| Error::Internal(anyhow::Error::from(e).context("spawn git clone")))?;
214
215 if !output.status.success() {
216 let stderr = String::from_utf8_lossy(&output.stderr);
217 return Err(Error::conflict(
218 "import-failed",
219 format!(
220 "could not import: {}",
221 stderr.lines().last().unwrap_or("clone failed").trim()
222 ),
223 ));
224 }
225 Ok(())
226}
227
228#[cfg(test)]
229mod tests {
230 use super::*;
231
232 #[test]
233 fn accepts_an_ordinary_https_url() {
234 let source = parse("https://github.com/rust-lang/rust.git").unwrap();
235 assert_eq!(source.host, "github.com");
236 assert_eq!(source.port, 443);
237 }
238
239 #[test]
240 fn refuses_schemes_that_reach_the_local_machine_or_carry_no_guarantees() {
241 for raw in [
242 "file:///etc/passwd",
243 "file:///data/git/bob/secret.git",
244 "ssh://git@github.com/a/b.git",
245 "git://github.com/a/b.git",
246 "ftp://example.com/x",
247 "/etc/passwd",
248 "github.com/a/b",
249 ] {
250 assert!(parse(raw).is_err(), "{raw:?} must be refused");
251 }
252 }
253
254 #[test]
255 fn refuses_credentials_embedded_in_the_url() {
256 // `clone` writes the remote into the repository config, secrets included.
257 for raw in [
258 "https://user:token@github.com/a/b.git",
259 "https://token@github.com/a/b.git",
260 ] {
261 assert!(parse(raw).is_err(), "{raw:?} must be refused");
262 }
263 }
264
265 #[test]
266 fn refuses_input_that_could_be_read_as_a_git_flag() {
267 assert!(parse("--upload-pack=evil").is_err());
268 assert!(parse("-c").is_err());
269 }
270
271 #[test]
272 fn refuses_malformed_input() {
273 for raw in [
274 "",
275 "https://",
276 "https:// example.com/a",
277 "https://example.com:notaport/a",
278 &format!("https://example.com/{}", "x".repeat(3000)),
279 ] {
280 assert!(parse(raw).is_err(), "{raw:?} must be refused");
281 }
282 }
283
284 #[test]
285 fn parses_a_port_and_an_ipv6_host() {
286 assert_eq!(parse("http://example.com:8080/a").unwrap().port, 8080);
287
288 let v6 = parse("https://[2606:4700::1111]/a.git").unwrap();
289 assert_eq!(v6.host, "2606:4700::1111");
290 assert_eq!(v6.port, 443);
291
292 let v6_port = parse("https://[2606:4700::1111]:8443/a.git").unwrap();
293 assert_eq!(v6_port.port, 8443);
294 }
295
296 #[test]
297 fn the_metadata_endpoint_and_loopback_are_forbidden() {
298 for ip in [
299 "127.0.0.1",
300 "127.1.2.3",
301 "169.254.169.254", // cloud instance metadata
302 "10.0.0.5",
303 "172.16.0.1",
304 "192.168.1.1",
305 "0.0.0.0",
306 "100.64.0.1", // carrier-grade NAT
307 "::1",
308 "fe80::1",
309 "fd00::1",
310 "::ffff:127.0.0.1", // IPv4-mapped loopback
311 ] {
312 assert!(
313 is_forbidden(ip.parse().unwrap()),
314 "{ip} must not be fetchable"
315 );
316 }
317 }
318
319 #[test]
320 fn public_addresses_are_allowed() {
321 for ip in ["1.1.1.1", "140.82.121.4", "2606:4700:4700::1111"] {
322 assert!(!is_forbidden(ip.parse().unwrap()), "{ip} should be allowed");
323 }
324 }
325
326 #[test]
327 fn a_host_resolving_to_loopback_is_refused_even_though_its_name_looks_fine() {
328 // The check that matters: the name is irrelevant, the addresses are not.
329 let source = Source {
330 url: "https://localhost/a.git".into(),
331 host: "localhost".into(),
332 port: 443,
333 };
334 assert!(
335 check_resolves_publicly(&source, false).is_err(),
336 "a name resolving to loopback must be refused"
337 );
338 assert!(
339 check_resolves_publicly(&source, true).is_ok(),
340 "an operator on a private network may opt in"
341 );
342 }
343
344 #[test]
345 fn an_explicit_private_literal_is_refused_at_resolution() {
346 let source = Source {
347 url: "http://169.254.169.254/latest/meta-data/".into(),
348 host: "169.254.169.254".into(),
349 port: 80,
350 };
351 assert!(check_resolves_publicly(&source, false).is_err());
352 }
353}