diff --git a/keetanetwork-vote/src/staple.rs b/keetanetwork-vote/src/staple.rs index 03d3fdd..9712395 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,37 @@ 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. 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> { 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 +415,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();