mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
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:
2
Cargo.lock
generated
2
Cargo.lock
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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(¶ms.owner, ¶ms.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(¶ms.owner, ¶ms.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());
|
||||
|
||||
Reference in New Issue
Block a user