Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 45 additions & 3 deletions keetanetwork-vote/src/staple.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<Vec<u8>, VoteError> {
Ok(compress_to_vec_zlib(input, ZLIB_DEFAULT_LEVEL))
}

fn inflate(input: &[u8]) -> Result<Vec<u8>, 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)]
Expand Down Expand Up @@ -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();
Expand Down
Loading