crates/gunnar-cli/tests/http_body_is_bounded.rs
#![cfg(feature = "http")]
//! **GUN-005: the gzip branch of smart HTTP has a ceiling on the step that
//! allocates.**
//!
//! `POST /<repo>/git-upload-pack` chose its gzip branch on the
//! `Content-Encoding: gzip` header alone and then ran `incoming.collect()`,
//! which accumulates the whole body with no bound, and only afterwards called
//! `body::gunzip`. Gunzip's ceiling is on the **inflated** size, so the step
//! that actually allocated had none. The comment above the collect claimed a
//! bound that belonged to the next line.
//!
//! The bytes do not even have to be gzip: the exhaustion happens before
//! anything inflates them. The cost to the attacker is 1:1 in bandwidth against
//! 1:1 in the server's heap, from an **anonymous** caller against a public
//! repository, on a process meant to run as PID 1 — so the OOM kill takes the
//! box, not one request — and the admission gate admits 256 transfers at once.
//!
//! The request here is written on a raw `TcpStream` rather than through a
//! client library, because the attack IS the raw request: a header, a
//! `Content-Length` bigger than the ceiling, and bytes. No dependency is added
//! to do it.
mod common;
use std::io::{Read as _, Write as _};
use std::net::TcpStream;
use common::{Harness, HarnessOptions};
use gunnar_core::{HashKind, Visibility};
const NS: &str = "nordisk";
const REPO: &str = "gunnar";
/// The ceiling the service applies to the COMPRESSED bytes.
///
/// Read out of the crate rather than spelled again, so a change to the
/// constant moves this test with it instead of leaving it asserting about a
/// number nobody uses.
const CAP: usize = gunnar_http::body::MAX_GZIPPED_REQUEST_INPUT_BYTES;
/// POST `body` to `git-upload-pack` with `Content-Encoding: gzip`, and return
/// the response's status line plus as much of the body as arrives.
///
/// Write errors are swallowed on purpose: the whole point of a bounded read is
/// that the server answers and stops reading before the client has finished
/// sending, which on loopback shows up as a broken pipe. That is the success
/// condition, not a test failure.
fn post_gzip(harness: &Harness, body: &[u8]) -> String {
let addr = harness
.http_addr
.expect("the harness was started with the HTTP listener");
let mut socket = TcpStream::connect(addr).expect("connect to the listener");
let head = format!(
"POST /{NS}/{REPO}.git/git-upload-pack HTTP/1.1\r\n\
Host: {addr}\r\n\
Content-Type: application/x-git-upload-pack-request\r\n\
Content-Encoding: gzip\r\n\
Content-Length: {}\r\n\
Connection: close\r\n\
\r\n",
body.len()
);
socket.write_all(head.as_bytes()).expect("write the head");
// A megabyte at a time, so a refusal mid-body is a write error here rather
// than a test that blocks forever on a socket nobody is draining.
for chunk in body.chunks(1024 * 1024) {
if socket.write_all(chunk).is_err() {
break;
}
}
let _ = socket.flush();
// No half-close: hyper drops a connection whose read half has gone away
// before it has written the response, and the answer is the whole point.
let mut answer = Vec::new();
let _ = socket.read_to_end(&mut answer);
String::from_utf8_lossy(&answer).into_owned()
}
/// **The finding.** A body past the compressed ceiling is refused, and refused
/// in the words that say it was not read.
///
/// Mutation-proven 2026-09-11: put `incoming.collect()` back and the server
/// answers `400` with *the gzipped request body could not be read* — which it
/// can only say after having held every byte of it in memory, which is the
/// finding.
#[test]
fn a_gzip_body_past_the_ceiling_is_refused_without_being_read() {
let mut harness = Harness::start_with_options(
NS,
REPO,
HashKind::Sha1,
HarnessOptions {
http: true,
// Public and anonymous: the attacker in the review needs no
// credential at all, and this is that caller.
visibility: Visibility::Public,
allow_anonymous: true,
..HarnessOptions::default()
},
);
// **The other direction, with the LENGTH as the only variable.** The same
// bytes, under the ceiling: read, handed to `gunzip`, and refused there
// for not being gzip — `400`, not `413`. A service that refused every
// gzipped request would pass the assertions above and break
// `git -c http.postBuffer` for everyone; this is what says it does not.
let small = vec![b'A'; 4 * 1024];
let answer = post_gzip(&harness, &small);
assert!(
!answer.contains("413"),
"a {} byte body was refused as too large:\n{}",
small.len(),
answer.lines().take(4).collect::<Vec<_>>().join("\n")
);
assert!(
answer.contains("could not be read"),
"a body under the ceiling must reach gunzip and be refused THERE; it said:\n{}",
answer.lines().take(8).collect::<Vec<_>>().join("\n")
);
// Not valid gzip, and that is the point: nothing inflates it, so before
// the fix every one of these bytes was in the server's heap before
// anything looked at them.
let oversized = vec![b'A'; CAP + 4 * 1024 * 1024];
let answer = post_gzip(&harness, &oversized);
assert!(
answer.contains("413"),
"a {} byte body was not refused as too large:\n{}",
oversized.len(),
answer.lines().take(4).collect::<Vec<_>>().join("\n")
);
assert!(
answer.contains("refused without being read"),
"the refusal must say the body was not read; it said:\n{}",
answer.lines().take(8).collect::<Vec<_>>().join("\n")
);
harness.stop();
}