| 1 | //! Read-only smart-HTTP clones through `git http-backend`. |
| 2 | //! |
| 3 | //! Only `info/refs?service=git-upload-pack` and `POST git-upload-pack` are |
| 4 | //! served; anything that would push is refused before git runs. The CGI |
| 5 | //! environment is built from the index (never from the raw URL), and both |
| 6 | //! bodies are streamed. |
| 7 | |
| 8 | use std::process::Stdio; |
| 9 | use std::sync::Arc; |
| 10 | |
| 11 | use axum::body::Body; |
| 12 | use axum::extract::Request; |
| 13 | use axum::http::{HeaderName, HeaderValue, Method, StatusCode, header}; |
| 14 | use axum::response::{IntoResponse, Response}; |
| 15 | use futures_util::{StreamExt, TryStreamExt}; |
| 16 | use tokio::io::AsyncReadExt; |
| 17 | use tokio::process::Command; |
| 18 | use tokio_util::io::{ReaderStream, StreamReader}; |
| 19 | |
| 20 | use crate::handlers::error; |
| 21 | use crate::index::Repo; |
| 22 | use crate::state::AppState; |
| 23 | |
| 24 | /// CGI response headers larger than this are an error. |
| 25 | const MAX_HEADER_BYTES: usize = 64 * 1024; |
| 26 | /// Fetch negotiations larger than this are refused (about a million |
| 27 | /// `have` lines; a normal clone sends a few kilobytes). |
| 28 | const MAX_REQUEST_BYTES: u64 = 64 * 1024 * 1024; |
| 29 | |
| 30 | /// Whether `rest` (the path after the repository) is a clone endpoint. |
| 31 | pub fn is_clone_path(rest: &[Vec<u8>]) -> bool { |
| 32 | matches!( |
| 33 | rest, |
| 34 | [a, b] if a == b"info" && b == b"refs" |
| 35 | ) || matches!(rest, [a] if a == b"git-upload-pack" || a == b"git-receive-pack") |
| 36 | } |
| 37 | |
| 38 | pub async fn serve( |
| 39 | state: Arc<AppState>, |
| 40 | repo: Arc<Repo>, |
| 41 | rest: Vec<Vec<u8>>, |
| 42 | req: Request, |
| 43 | ) -> Response { |
| 44 | let config = state.config.load_full(); |
| 45 | if !config.repos.https_clone { |
| 46 | return error::page(&state, StatusCode::NOT_FOUND, "PAGE NOT FOUND"); |
| 47 | } |
| 48 | let query = req.uri().query().unwrap_or_default().to_string(); |
| 49 | let service = query |
| 50 | .split('&') |
| 51 | .find_map(|kv| kv.strip_prefix("service=")) |
| 52 | .unwrap_or_default(); |
| 53 | let endpoint = match (req.method(), rest.last().map(Vec::as_slice)) { |
| 54 | (&Method::GET | &Method::HEAD, Some(b"refs")) => match service { |
| 55 | "git-upload-pack" => "info/refs", |
| 56 | "git-receive-pack" => return forbidden(), |
| 57 | _ => { |
| 58 | return ( |
| 59 | StatusCode::FORBIDDEN, |
| 60 | "Only the smart HTTP protocol is supported.\n", |
| 61 | ) |
| 62 | .into_response(); |
| 63 | } |
| 64 | }, |
| 65 | (&Method::POST, Some(b"git-upload-pack")) => "git-upload-pack", |
| 66 | (_, Some(b"git-receive-pack")) => return forbidden(), |
| 67 | _ => return StatusCode::METHOD_NOT_ALLOWED.into_response(), |
| 68 | }; |
| 69 | |
| 70 | let declared = req |
| 71 | .headers() |
| 72 | .get(header::CONTENT_LENGTH) |
| 73 | .and_then(|v| v.to_str().ok()) |
| 74 | .and_then(|v| v.parse::<u64>().ok()); |
| 75 | if declared.is_some_and(|n| n > MAX_REQUEST_BYTES) { |
| 76 | return (StatusCode::PAYLOAD_TOO_LARGE, "Request too large.\n").into_response(); |
| 77 | } |
| 78 | let Some(permit) = state.heavy_permit().await else { |
| 79 | return ( |
| 80 | StatusCode::SERVICE_UNAVAILABLE, |
| 81 | [(header::RETRY_AFTER, "10")], |
| 82 | "The server is busy; try again shortly.\n", |
| 83 | ) |
| 84 | .into_response(); |
| 85 | }; |
| 86 | let (Some(root), Some(dir)) = (repo.git_dir.parent(), repo.git_dir.file_name()) else { |
| 87 | return StatusCode::NOT_FOUND.into_response(); |
| 88 | }; |
| 89 | let path_info = format!("/{}/{endpoint}", dir.to_string_lossy()); |
| 90 | |
| 91 | let headers = req.headers(); |
| 92 | let header = |name: HeaderName| { |
| 93 | headers |
| 94 | .get(name) |
| 95 | .and_then(|v| v.to_str().ok()) |
| 96 | .map(str::to_string) |
| 97 | }; |
| 98 | let mut cmd = Command::new("git"); |
| 99 | cmd.arg("http-backend") |
| 100 | .env_clear() |
| 101 | .env("PATH", std::env::var_os("PATH").unwrap_or_default()) |
| 102 | .env("GIT_PROJECT_ROOT", root) |
| 103 | .env("PATH_INFO", path_info) |
| 104 | .env("REQUEST_METHOD", req.method().as_str()) |
| 105 | .env("QUERY_STRING", &query) |
| 106 | .env("GIT_HTTP_EXPORT_ALL", "1") |
| 107 | // Only the repository's own config, plus ours. |
| 108 | .env("GIT_CONFIG_NOSYSTEM", "1") |
| 109 | .env("GIT_CONFIG_GLOBAL", "/dev/null") |
| 110 | .env("HOME", "/nonexistent") |
| 111 | .env("GIT_CONFIG_COUNT", "2") |
| 112 | .env("GIT_CONFIG_KEY_0", "http.receivepack") |
| 113 | .env("GIT_CONFIG_VALUE_0", "false") |
| 114 | // Visibility is decided by the index; ownership is not our concern. |
| 115 | .env("GIT_CONFIG_KEY_1", "safe.directory") |
| 116 | .env("GIT_CONFIG_VALUE_1", "*") |
| 117 | .stdin(Stdio::piped()) |
| 118 | .stdout(Stdio::piped()) |
| 119 | .stderr(Stdio::piped()) |
| 120 | .kill_on_drop(true); |
| 121 | for (var, name) in [ |
| 122 | ("CONTENT_TYPE", header::CONTENT_TYPE), |
| 123 | ("CONTENT_LENGTH", header::CONTENT_LENGTH), |
| 124 | ("HTTP_CONTENT_ENCODING", header::CONTENT_ENCODING), |
| 125 | ] { |
| 126 | if let Some(value) = header(name) { |
| 127 | cmd.env(var, value); |
| 128 | } |
| 129 | } |
| 130 | if let Some(protocol) = header(HeaderName::from_static("git-protocol")) { |
| 131 | cmd.env("HTTP_GIT_PROTOCOL", protocol); |
| 132 | } |
| 133 | |
| 134 | let mut child = match cmd.spawn() { |
| 135 | Ok(child) => child, |
| 136 | Err(e) => { |
| 137 | tracing::error!("cannot run git http-backend: {e}"); |
| 138 | return StatusCode::INTERNAL_SERVER_ERROR.into_response(); |
| 139 | } |
| 140 | }; |
| 141 | let (Some(mut stdin), Some(mut stdout), Some(mut stderr)) = |
| 142 | (child.stdin.take(), child.stdout.take(), child.stderr.take()) |
| 143 | else { |
| 144 | return StatusCode::INTERNAL_SERVER_ERROR.into_response(); |
| 145 | }; |
| 146 | |
| 147 | // Request body → stdin, then EOF. A body past the cap is cut off, and |
| 148 | // git rejects the truncated request. |
| 149 | let body = req |
| 150 | .into_body() |
| 151 | .into_data_stream() |
| 152 | .map_err(std::io::Error::other); |
| 153 | tokio::spawn(async move { |
| 154 | let mut reader = StreamReader::new(body).take(MAX_REQUEST_BYTES); |
| 155 | if let Err(e) = tokio::io::copy(&mut reader, &mut stdin).await { |
| 156 | tracing::debug!("clone request body: {e}"); |
| 157 | } |
| 158 | }); |
| 159 | let name = repo.name.clone(); |
| 160 | tokio::spawn(async move { |
| 161 | let mut text = String::new(); |
| 162 | let _ = stderr.read_to_string(&mut text).await; |
| 163 | for line in text.lines().filter(|l| !l.trim().is_empty()) { |
| 164 | tracing::warn!("{name}: git http-backend: {line}"); |
| 165 | } |
| 166 | }); |
| 167 | |
| 168 | // CGI headers, then the rest of stdout as the body. |
| 169 | let mut buf = Vec::with_capacity(4096); |
| 170 | let (status, cgi_headers, body_start) = loop { |
| 171 | if let Some(parsed) = parse_cgi_headers(&buf) { |
| 172 | break parsed; |
| 173 | } |
| 174 | if buf.len() > MAX_HEADER_BYTES { |
| 175 | tracing::error!("git http-backend sent oversized headers"); |
| 176 | return StatusCode::BAD_GATEWAY.into_response(); |
| 177 | } |
| 178 | match stdout.read_buf(&mut buf).await { |
| 179 | Ok(0) | Err(_) => { |
| 180 | tracing::error!("git http-backend ended before its headers"); |
| 181 | return StatusCode::BAD_GATEWAY.into_response(); |
| 182 | } |
| 183 | Ok(_) => {} |
| 184 | } |
| 185 | }; |
| 186 | let head = buf.split_off(body_start); |
| 187 | let stream = futures_util::stream::once(async move { Ok(bytes::Bytes::from(head)) }) |
| 188 | .chain(ReaderStream::new(stdout)); |
| 189 | // Reap git and release the permit when it exits. |
| 190 | tokio::spawn(async move { |
| 191 | let _permit = permit; |
| 192 | let _ = child.wait().await; |
| 193 | }); |
| 194 | |
| 195 | let mut res = Body::from_stream(stream).into_response(); |
| 196 | *res.status_mut() = status; |
| 197 | for (name, value) in cgi_headers { |
| 198 | if let (Ok(name), Ok(value)) = ( |
| 199 | HeaderName::from_bytes(name.as_bytes()), |
| 200 | HeaderValue::from_str(&value), |
| 201 | ) { |
| 202 | res.headers_mut().insert(name, value); |
| 203 | } |
| 204 | } |
| 205 | res |
| 206 | } |
| 207 | |
| 208 | fn forbidden() -> Response { |
| 209 | (StatusCode::FORBIDDEN, "This server is read-only.\n").into_response() |
| 210 | } |
| 211 | |
| 212 | /// A parsed CGI response head: status, headers, and where the body starts. |
| 213 | pub type CgiHead = (StatusCode, Vec<(String, String)>, usize); |
| 214 | |
| 215 | /// Parse a CGI response head, or `None` while the blank line hasn't |
| 216 | /// arrived yet. |
| 217 | pub fn parse_cgi_headers(buf: &[u8]) -> Option<CgiHead> { |
| 218 | let (end, sep) = [&b"\r\n\r\n"[..], b"\n\n"] |
| 219 | .iter() |
| 220 | .filter_map(|sep| { |
| 221 | buf.windows(sep.len()) |
| 222 | .position(|w| w == *sep) |
| 223 | .map(|i| (i, sep.len())) |
| 224 | }) |
| 225 | .min()?; |
| 226 | let head = String::from_utf8_lossy(&buf[..end]); |
| 227 | let mut status = StatusCode::OK; |
| 228 | let mut headers = Vec::new(); |
| 229 | for line in head.lines() { |
| 230 | let Some((name, value)) = line.split_once(':') else { |
| 231 | continue; |
| 232 | }; |
| 233 | let value = value.trim().to_string(); |
| 234 | if name.eq_ignore_ascii_case("status") { |
| 235 | if let Some(code) = value |
| 236 | .split_whitespace() |
| 237 | .next() |
| 238 | .and_then(|c| c.parse::<u16>().ok()) |
| 239 | .and_then(|c| StatusCode::from_u16(c).ok()) |
| 240 | { |
| 241 | status = code; |
| 242 | } |
| 243 | } else { |
| 244 | headers.push((name.trim().to_string(), value)); |
| 245 | } |
| 246 | } |
| 247 | Some((status, headers, end + sep)) |
| 248 | } |
| 249 | |
| 250 | #[cfg(test)] |
| 251 | mod tests { |
| 252 | use super::*; |
| 253 | |
| 254 | #[test] |
| 255 | fn cgi_headers() { |
| 256 | let raw = b"Expires: Fri, 01 Jan 1980 00:00:00 GMT\r\nContent-Type: application/x-git-upload-pack-advertisement\r\n\r\n001e# service"; |
| 257 | let (status, headers, start) = parse_cgi_headers(raw).unwrap(); |
| 258 | assert_eq!(status, StatusCode::OK); |
| 259 | assert_eq!(headers.len(), 2); |
| 260 | assert_eq!(headers[1].1, "application/x-git-upload-pack-advertisement"); |
| 261 | assert_eq!(&raw[start..], b"001e# service"); |
| 262 | |
| 263 | let raw = b"Status: 403 Forbidden\nContent-Type: text/plain\n\nno"; |
| 264 | let (status, headers, start) = parse_cgi_headers(raw).unwrap(); |
| 265 | assert_eq!(status, StatusCode::FORBIDDEN); |
| 266 | assert_eq!(headers, vec![("Content-Type".into(), "text/plain".into())]); |
| 267 | assert_eq!(&raw[start..], b"no"); |
| 268 | |
| 269 | assert!(parse_cgi_headers(b"Content-Type: x\r\n").is_none()); |
| 270 | } |
| 271 | |
| 272 | #[test] |
| 273 | fn clone_paths() { |
| 274 | let p = |s: &[&str]| s.iter().map(|x| x.as_bytes().to_vec()).collect::<Vec<_>>(); |
| 275 | assert!(is_clone_path(&p(&["info", "refs"]))); |
| 276 | assert!(is_clone_path(&p(&["git-upload-pack"]))); |
| 277 | assert!(is_clone_path(&p(&["git-receive-pack"]))); |
| 278 | assert!(!is_clone_path(&p(&["info"]))); |
| 279 | assert!(!is_clone_path(&p(&["tree", "main"]))); |
| 280 | } |
| 281 | } |