Krolyxon
Commit ce0f374

ferreniumsrcserverclone.rs

281 lines · 10.0 KB · Rust

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
8use std::process::Stdio;
9use std::sync::Arc;
10
11use axum::body::Body;
12use axum::extract::Request;
13use axum::http::{HeaderName, HeaderValue, Method, StatusCode, header};
14use axum::response::{IntoResponse, Response};
15use futures_util::{StreamExt, TryStreamExt};
16use tokio::io::AsyncReadExt;
17use tokio::process::Command;
18use tokio_util::io::{ReaderStream, StreamReader};
19
20use crate::handlers::error;
21use crate::index::Repo;
22use crate::state::AppState;
23
24/// CGI response headers larger than this are an error.
25const 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).
28const MAX_REQUEST_BYTES: u64 = 64 * 1024 * 1024;
29
30/// Whether `rest` (the path after the repository) is a clone endpoint.
31pub 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
38pub 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
208fn 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.
213pub 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.
217pub 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)]
251mod 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}