fix(relay): decompress gzip-encoded git smart-HTTP request bodies (#2670)

Signed-off-by: Kaal <kaal@shib.io>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
This commit is contained in:
Kaal
2026-07-24 01:37:55 -04:00
committed by GitHub
parent 76aeae7036
commit 5ca36e7b91
3 changed files with 198 additions and 2 deletions

2
Cargo.lock generated
View File

@@ -1122,6 +1122,7 @@ name = "buzz-relay"
version = "0.2.0"
dependencies = [
"anyhow",
"async-compression",
"async-trait",
"axum",
"base64",
@@ -1140,6 +1141,7 @@ dependencies = [
"chrono",
"dashmap",
"deadpool-redis",
"flate2",
"futures",
"futures-util",
"hex",

View File

@@ -78,6 +78,7 @@ metrics = { workspace = true }
metrics-exporter-prometheus = { workspace = true }
metrics-util = { workspace = true }
pulldown-cmark = { version = "0.13.4", default-features = false, features = ["html"] }
async-compression = { version = "0.4.42", features = ["tokio", "gzip"] }
[features]
dev = ["buzz-auth/dev"]
@@ -90,3 +91,4 @@ buzz-auth = { workspace = true, features = ["dev"] }
reqwest = { workspace = true }
tokio-tungstenite = { workspace = true }
futures = "0.3"
flate2 = "1.1.9"

View File

@@ -50,6 +50,13 @@ const PACK_OPS_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(300
const RECEIVE_PACK_MAX_OUTPUT_BYTES: u64 = 1024 * 1024;
/// Maximum ref advertisement output after manifest ref-count validation.
const INFO_REFS_MAX_OUTPUT_BYTES: u64 = 4 * 1024 * 1024;
/// Maximum *decoded* upload-pack request body (want/have negotiation).
///
/// Bounds the output of [`decode_git_request_body`] so a small gzip bomb
/// cannot bypass the compressed-body `RequestBodyLimitLayer`. Negotiation
/// bodies are pkt-lines of wants/haves (~50 bytes per ref); even a repo
/// with a million refs stays far under this.
const UPLOAD_PACK_MAX_DECODED_BYTES: u64 = 64 * 1024 * 1024;
/// NIP-98 auth extractor for git routes.
///
@@ -714,6 +721,62 @@ async fn info_refs_subprocess(
.unwrap())
}
/// Decode a smart-HTTP git request body according to its `Content-Encoding`.
///
/// Git's smart-HTTP client gzip-compresses the `git-upload-pack` /
/// `git-receive-pack` request body once it exceeds an internal size
/// threshold (`http.postBuffer`-independent; triggered by the number of
/// want/have lines, so it fires reliably on many-ref clones). The relay
/// pipes the request body straight into the git subprocess's stdin, so a
/// still-compressed body reaches `git upload-pack` as raw gzip and fails
/// with `fatal: protocol error: bad line length character`. Transparently
/// inflate here so the subprocess always sees plain pkt-lines.
///
/// Only `gzip` is decoded (the sole encoding git emits). An unknown
/// non-identity encoding is passed through unchanged rather than rejected;
/// the subprocess surfaces any real mismatch as an in-band protocol error.
///
/// `max_decoded_bytes` bounds the *inflated* size: the router's
/// `RequestBodyLimitLayer` only caps compressed bytes, so without this a
/// small gzip bomb (ratios up to ~1000:1) could feed an effectively
/// unbounded stream to the subprocess — for receive-pack that means
/// unbounded scratch-disk writes. Exceeding the cap errors the stream,
/// which the stdin pumps surface as a logged early-EOF to git.
fn decode_git_request_body(
headers: &axum::http::HeaderMap,
body: Body,
max_decoded_bytes: u64,
) -> Body {
let is_gzip = headers
.get(header::CONTENT_ENCODING)
.and_then(|v| v.to_str().ok())
.map(|v| v.eq_ignore_ascii_case("gzip") || v.eq_ignore_ascii_case("x-gzip"))
.unwrap_or(false);
if !is_gzip {
return body;
}
use futures_util::StreamExt;
use tokio_util::io::{ReaderStream, StreamReader};
let byte_stream = body
.into_data_stream()
.map(|res| res.map_err(std::io::Error::other))
.boxed();
let decoder =
async_compression::tokio::bufread::GzipDecoder::new(StreamReader::new(byte_stream));
let mut decoded: u64 = 0;
let capped = ReaderStream::new(decoder).map(move |chunk| {
let chunk = chunk?;
decoded = decoded.saturating_add(chunk.len() as u64);
if decoded > max_decoded_bytes {
return Err(std::io::Error::other(format!(
"gzip git request body exceeded {max_decoded_bytes} decoded bytes"
)));
}
Ok(chunk)
});
Body::from_stream(capped)
}
/// `POST /git/{owner}/{repo}/git-upload-pack`
///
/// Handles clone/fetch — client sends wants/haves, server sends pack data.
@@ -723,10 +786,12 @@ async fn info_refs_subprocess(
pub async fn upload_pack(
State(state): State<Arc<AppState>>,
auth: GitAuth,
headers: axum::http::HeaderMap,
AxumPath(params): AxumPath<GitRepoParams>,
body: Body,
) -> Result<Response, Response> {
let _ = validate_repo_id(&params.owner, &params.repo)?;
let body = decode_git_request_body(&headers, body, UPLOAD_PACK_MAX_DECODED_BYTES);
let permit = acquire_git_permit(&state, "upload_pack")?;
let repo = match hydrate_for_read(
@@ -793,10 +858,12 @@ pub async fn upload_pack(
pub async fn receive_pack(
State(state): State<Arc<AppState>>,
auth: GitAuth,
headers: axum::http::HeaderMap,
AxumPath(params): AxumPath<GitRepoParams>,
body: Body,
) -> Result<Response, Response> {
let repo_name = validate_repo_id(&params.owner, &params.repo)?;
let body = decode_git_request_body(&headers, body, state.config.git_max_pack_bytes);
let pusher_hex = hex::encode(auth.pubkey.to_bytes());
let _permit = acquire_git_permit(&state, "receive_pack")?;
@@ -968,6 +1035,7 @@ async fn run_git_at(
// Stream request body to git stdin.
let mut stdin = child.stdin.take().unwrap();
let pump_service = service.to_string();
let body_task = tokio::spawn(async move {
use futures_util::StreamExt;
let mut stream = body.into_data_stream();
@@ -981,7 +1049,14 @@ async fn run_git_at(
break;
}
}
Err(_) => break,
Err(e) => {
// Body/decode errors (client abort, malformed gzip,
// decoded-size cap) surface to git as early EOF; log so
// there is a server-side signal, not just an opaque
// client-side hangup.
warn!(error = %e, service = %pump_service, "git request body stream failed");
break;
}
}
}
drop(stdin); // close stdin → EOF for git
@@ -1378,7 +1453,14 @@ fn stream_git_read(
break;
}
}
Err(_) => break,
Err(e) => {
// Body/decode errors (client abort, malformed gzip,
// decoded-size cap) surface to git as early EOF; log so
// there is a server-side signal, not just an opaque
// client-side hangup.
warn!(error = %e, service = %service, "git request body stream failed");
break;
}
}
}
drop(stdin); // close stdin → EOF for git
@@ -1694,6 +1776,116 @@ mod track_c_tests {
"cb09a769da1c01f458fa6959d4e8eded38fac8d3".to_string()
}
/// A gzip-encoded request body is transparently inflated before it
/// reaches the git subprocess. Git's smart-HTTP client gzips the
/// upload-pack/receive-pack request body past a size threshold (fires
/// on many-ref clones); without this the subprocess sees raw gzip and
/// dies with `fatal: protocol error: bad line length character`.
#[tokio::test]
async fn gzip_request_body_is_inflated() {
use axum::http::HeaderMap;
use std::io::Write;
let plaintext = b"0032want cb09a769da1c01f458fa6959d4e8eded38fac8d3\n0000";
let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
encoder.write_all(plaintext).unwrap();
let gzipped = encoder.finish().unwrap();
assert_ne!(
gzipped, plaintext,
"precondition: body is actually compressed"
);
let mut headers = HeaderMap::new();
headers.insert(header::CONTENT_ENCODING, "gzip".parse().unwrap());
let decoded = decode_git_request_body(&headers, Body::from(gzipped), u64::MAX);
let bytes = axum::body::to_bytes(decoded, usize::MAX).await.unwrap();
assert_eq!(bytes.as_ref(), plaintext);
}
/// Without a gzip `Content-Encoding`, the body is passed through byte
/// for byte (the common small-clone / already-inflated case).
#[tokio::test]
async fn identity_request_body_is_passthrough() {
use axum::http::HeaderMap;
let plaintext = b"0032want cb09a769da1c01f458fa6959d4e8eded38fac8d3\n0000";
let decoded =
decode_git_request_body(&HeaderMap::new(), Body::from(plaintext.to_vec()), u64::MAX);
let bytes = axum::body::to_bytes(decoded, usize::MAX).await.unwrap();
assert_eq!(bytes.as_ref(), plaintext);
}
/// A gzip bomb is cut off at the decoded-byte cap: the router's
/// `RequestBodyLimitLayer` only bounds *compressed* bytes, so the
/// decode seam must enforce the inflated bound itself. Highly
/// compressible input (1 MiB of zeros → ~1 KiB gzip) must error once
/// the decoded stream crosses the cap.
#[tokio::test]
async fn gzip_request_body_over_decoded_cap_errors() {
use axum::http::HeaderMap;
use std::io::Write;
let plaintext = vec![0u8; 1024 * 1024]; // inflates 1 MiB from ~1 KiB wire
let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
encoder.write_all(&plaintext).unwrap();
let gzipped = encoder.finish().unwrap();
assert!(
gzipped.len() < 64 * 1024,
"precondition: bomb is small on the wire (got {} bytes)",
gzipped.len()
);
let mut headers = HeaderMap::new();
headers.insert(header::CONTENT_ENCODING, "gzip".parse().unwrap());
let cap: u64 = 64 * 1024;
let decoded = decode_git_request_body(&headers, Body::from(gzipped), cap);
let err = axum::body::to_bytes(decoded, usize::MAX)
.await
.expect_err("decoded stream must error past the cap");
assert!(
err.to_string().contains("decoded bytes"),
"error should name the decoded-size cap, got: {err}"
);
}
/// The decoded cap does not truncate bodies at or under the limit —
/// exactly-at-cap input passes through complete.
#[tokio::test]
async fn gzip_request_body_at_decoded_cap_passes() {
use axum::http::HeaderMap;
use std::io::Write;
let plaintext = vec![7u8; 8 * 1024];
let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
encoder.write_all(&plaintext).unwrap();
let gzipped = encoder.finish().unwrap();
let mut headers = HeaderMap::new();
headers.insert(header::CONTENT_ENCODING, "gzip".parse().unwrap());
let decoded =
decode_git_request_body(&headers, Body::from(gzipped), plaintext.len() as u64);
let bytes = axum::body::to_bytes(decoded, usize::MAX).await.unwrap();
assert_eq!(bytes.as_ref(), plaintext);
}
/// Malformed gzip (valid header, corrupt deflate stream) surfaces as a
/// stream error rather than silently yielding garbage — the stdin pump
/// logs it and closes the subprocess's stdin early.
#[tokio::test]
async fn malformed_gzip_request_body_errors() {
use axum::http::HeaderMap;
// Valid 10-byte gzip header followed by garbage.
let mut bogus = vec![0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff];
bogus.extend_from_slice(b"this is not deflate data at all");
let mut headers = HeaderMap::new();
headers.insert(header::CONTENT_ENCODING, "gzip".parse().unwrap());
let decoded = decode_git_request_body(&headers, Body::from(bogus), u64::MAX);
axum::body::to_bytes(decoded, usize::MAX)
.await
.expect_err("corrupt gzip must error the decoded stream");
}
fn branches_only_manifest() -> Manifest {
let mut refs = BTreeMap::new();
refs.insert("refs/heads/feature".to_string(), oid_sha1());