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
| 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 | |
| 13 | use crate::error::{Error, Result}; |
| 14 | use std::net::{IpAddr, ToSocketAddrs}; |
| 15 | use std::path::Path; |
| 16 | use std::process::Command; |
| 17 | use 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. |
| 22 | const 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. |
| 28 | pub 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)] |
| 59 | pub 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. |
| 66 | pub 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 | |
| 115 | fn 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`. |
| 147 | pub 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. |
| 186 | pub 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)] |
| 229 | mod 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 | } |