From c46b8cab15f0dd9498a968be922216277a060b44 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 15 Sep 2026 16:55:56 +0000 Subject: [PATCH 1/2] fix(vote): cap zlib decompression of untrusted vote staples VoteStaple::verify inflated attacker-supplied compressed staple bytes with miniz_oxide::decompress_to_vec_zlib, which has no output-size limit, before any signature or content validation. A malicious representative could return a small compressed payload that inflates to an enormous buffer, exhausting client/process memory (a zlib decompression bomb; measured ~1029x amplification). Use decompress_to_vec_zlib_with_limit with a fixed cap sized to the largest legitimate staple bundle, mapping overflow to VoteError::MalformedStaple. Co-authored-by: Ty Schenk --- keetanetwork-vote/src/staple.rs | 34 ++++++++++++++++++++++++++++++--- 1 file changed, 31 insertions(+), 3 deletions(-) diff --git a/keetanetwork-vote/src/staple.rs b/keetanetwork-vote/src/staple.rs index 03d3fdd..a313543 100644 --- a/keetanetwork-vote/src/staple.rs +++ b/keetanetwork-vote/src/staple.rs @@ -28,7 +28,7 @@ use alloc::vec::Vec; use miniz_oxide::deflate::compress_to_vec_zlib; -use miniz_oxide::inflate::decompress_to_vec_zlib; +use miniz_oxide::inflate::decompress_to_vec_zlib_with_limit; use keetanetwork_account::AccountPublicKey; use keetanetwork_asn1::vote as transport; @@ -349,14 +349,23 @@ fn staple_decode_error(error: keetanetwork_asn1::Asn1Error) -> VoteError { // `compress_to_vec_zlib`'s level argument follows the zlib convention (0-10) const ZLIB_DEFAULT_LEVEL: u8 = 6; +/// Upper bound on the *uncompressed* size of a staple bundle accepted from the +/// wire. A staple carries a small set of blocks and their endorsing votes, so +/// its canonical form is comfortably within a few megabytes; anything larger is +/// treated as malformed. This cap prevents a hostile peer from sending a tiny +/// zlib stream that inflates into an enormous allocation (a decompression bomb) +/// before any signature or content validation runs. +const MAX_STAPLE_UNCOMPRESSED_BYTES: usize = 8 * 1024 * 1024; + fn deflate(input: &[u8]) -> Result, VoteError> { Ok(compress_to_vec_zlib(input, ZLIB_DEFAULT_LEVEL)) } fn inflate(input: &[u8]) -> Result, VoteError> { // Reference treats failed zlib inflation of a staple as a malformed - // staple (with a fallback to raw bytes). - decompress_to_vec_zlib(input).map_err(|_| VoteError::MalformedStaple) + // staple (with a fallback to raw bytes). Decompress under a fixed output + // cap so untrusted input cannot force an unbounded allocation. + decompress_to_vec_zlib_with_limit(input, MAX_STAPLE_UNCOMPRESSED_BYTES).map_err(|_| VoteError::MalformedStaple) } #[cfg(test)] @@ -392,6 +401,25 @@ mod tests { assert!(result.is_err()); } + #[test] + fn test_inflate_rejects_decompression_bomb() { + // A tiny compressed input that would inflate past the cap must be + // rejected as malformed rather than allocated. + let oversized = alloc::vec![0u8; MAX_STAPLE_UNCOMPRESSED_BYTES + 1]; + let compressed = deflate(&oversized).expect("deflate"); + assert!(compressed.len() < MAX_STAPLE_UNCOMPRESSED_BYTES); + let result = inflate(&compressed); + assert!(matches!(result, Err(VoteError::MalformedStaple))); + } + + #[test] + fn test_inflate_accepts_within_cap() { + let payload = b"within-cap staple payload"; + let compressed = deflate(payload).expect("deflate"); + let inflated = inflate(&compressed).expect("inflate"); + assert_eq!(inflated, payload); + } + #[test] fn test_verifiable_matches_inherent_verify() -> Result<(), VoteError> { let config = ValidationConfig::default(); From 928070a1def563a199711c5e5f82bcfb1bd5e338 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 15 Sep 2026 17:15:07 +0000 Subject: [PATCH 2/2] docs(vote): justify the 8 MiB staple decompression cap Document why the MAX_STAPLE_UNCOMPRESSED_BYTES cap is safe: a staple carries one confirmed block set plus at most one vote per representative (votes de-duplicated by issuer), blocks/votes are individually small (block text fields are length-capped), so a realistic staple is low single-digit MB. 8 MiB leaves headroom above any legitimate staple while bounding attacker-forced allocation. No functional change; cap unchanged. Co-authored-by: Ty Schenk --- keetanetwork-vote/src/staple.rs | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/keetanetwork-vote/src/staple.rs b/keetanetwork-vote/src/staple.rs index a313543..9712395 100644 --- a/keetanetwork-vote/src/staple.rs +++ b/keetanetwork-vote/src/staple.rs @@ -350,11 +350,25 @@ fn staple_decode_error(error: keetanetwork_asn1::Asn1Error) -> VoteError { const ZLIB_DEFAULT_LEVEL: u8 = 6; /// Upper bound on the *uncompressed* size of a staple bundle accepted from the -/// wire. A staple carries a small set of blocks and their endorsing votes, so -/// its canonical form is comfortably within a few megabytes; anything larger is -/// treated as malformed. This cap prevents a hostile peer from sending a tiny -/// zlib stream that inflates into an enormous allocation (a decompression bomb) -/// before any signature or content validation runs. +/// wire. It exists only to stop a decompression bomb: a hostile peer sending a +/// tiny zlib stream that inflates into an enormous allocation before any +/// signature or content validation runs. +/// +/// Why 8 MiB is safe (never rejects a legitimate staple): +/// a staple's canonical form is `SEQUENCE { blocks SEQUENCE OF OCTET STRING, +/// votes SEQUENCE OF OCTET STRING }`. A staple endorses one confirmed set of +/// blocks and carries at most one vote per representative (votes are +/// de-duplicated by issuer in `validate_vote_invariants`). Blocks are small: +/// their text fields are individually length-capped (see +/// `keetanetwork-block` validation, e.g. 1024-byte external data) and a block +/// serializes to a few KB at most; a vote certificate is an X.509-shaped record +/// of similar order. So even a large round — hundreds of blocks and hundreds of +/// representative votes at a few KB each — stays in the low single-digit +/// megabytes. 8 MiB leaves comfortable headroom above any realistic staple while +/// still bounding the allocation an attacker can force by ~3 orders of +/// magnitude below the previously-unbounded case. The repository does not define +/// a hard protocol maximum staple size; if one is established, tighten this +/// constant to it. const MAX_STAPLE_UNCOMPRESSED_BYTES: usize = 8 * 1024 * 1024; fn deflate(input: &[u8]) -> Result, VoteError> {