Skip to content
Merged
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
# Changelog

## Unreleased

### Candid

* Bug fixes:
+ Bound the byte length of the type-table header, defaulting to 64 KiB and configurable with `DecoderConfig::set_max_header_len`. A header is a type description, so its size is a property of the interface rather than of the payload -- the largest header among the IC's own interfaces is about 2 KB -- while the counts it declares were free to describe millions of elements. Because every element a header introduces occupies at least one byte, one bound on its length now covers all of them at once: the argument count, a record or variant's field count, a function type's argument and result counts, and a service type's method count. The header is parsed from a bounded prefix of the input, so an oversized one costs the bound rather than its declared size, and the value section is unaffected -- a message may still carry a payload of any size its quota allows. `set_max_type_len` is unchanged and continues to bound a different dimension, the number of type-table entries.

## 2026-09-22

### Candid 0.10.36
Expand Down
78 changes: 69 additions & 9 deletions rust/candid/src/binary_parser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,70 @@ use std::convert::TryInto;

const MAX_TYPE_TABLE_LEN: u64 = 10_000; // Max type entries

/// Default bound on the byte length of the type-table header.
///
/// The header is a type description, so its size is a property of the interface
/// rather than of the payload: the largest header among the IC's own interfaces is
/// about 2 KB (NNS governance `manage_neuron`), and the management canister's
/// largest is 214 bytes. 64 KiB leaves roughly a 32x margin over that while keeping
/// the work any single header can demand bounded, independently of how large the
/// value section is allowed to be.
///
/// This bounds every wire-declared count in the header at once -- the argument
/// count, a record or variant's field count, a function type's argument and result
/// counts, a service type's method count -- because each element it introduces
/// occupies at least one byte. [`MAX_TYPE_TABLE_LEN`] remains a separate bound on a
/// different dimension: the number of type-table entries, whatever their size.
pub(crate) const DEFAULT_MAX_HEADER_LEN: usize = 64 * 1024;

// Upper bound on a single allocation step while reading a length-prefixed byte
// blob. The buffer grows in steps of at most this size.
const READ_CHUNK: u64 = 8 * 1024;

/// Marks a header read that stopped because the reader ran out of bytes, as opposed to
/// one that found something malformed.
///
/// It displays as the plain field name, so error text is unchanged; the type is what
/// makes the distinction recoverable from a `binrw::Error`.
#[derive(Debug)]
pub(crate) struct Truncated(pub(crate) &'static str);

impl std::fmt::Display for Truncated {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.0)
}
}

fn leb_failure(e: leb128::read::Error, name: &'static str) -> Box<dyn binrw::error::CustomError> {
match e {
// Ran out of input: within a bounded prefix, that is the bound.
leb128::read::Error::IoError(_) => Box::new(Truncated(name)),
// A value too large for the target type is malformed, not truncated.
leb128::read::Error::Overflow => Box::new(name),
}
}

/// Whether a header parse stopped because it ran out of input, rather than because the
/// header was malformed.
///
/// Inside a bounded prefix this is exactly the signal that the header needed more than
/// the bound. The failure offset cannot be used for it: a length-prefixed name or blob,
/// and a multi-byte LEB128, are reported at the offset they *start* at, which can be
/// well short of the boundary they cross. binrw also reports a failure inside an enum
/// variant as `EnumErrors` positioned at the start of the enum, so the whole error tree
/// is walked.
pub(crate) fn is_truncation(e: &BError) -> bool {
match e {
// `Backtrace::error` is guaranteed not to be another backtrace.
BError::Backtrace(bt) => is_truncation(&bt.error),
BError::EnumErrors { variant_errors, .. } => {
variant_errors.iter().any(|(_, inner)| is_truncation(inner))
}
BError::Io(io) => io.kind() == std::io::ErrorKind::UnexpectedEof,
_ => e.custom_err::<Truncated>().is_some(),
}
}

/// Read `len` bytes into a fresh `Vec`, growing the buffer in bounded steps.
///
/// A plain `#[br(count = len)]` on a `Vec<u8>` passes the wire-declared length
Expand All @@ -32,7 +92,7 @@ fn read_len_prefixed<R: std::io::Read>(reader: &mut R, pos: u64, len: u64) -> Bi
// Fewer bytes were available than declared: short input.
return Err(BError::Custom {
pos,
err: Box::new("not enough bytes"),
err: Box::new(Truncated("not enough bytes")),
});
}
remaining -= read;
Expand All @@ -58,25 +118,25 @@ fn read_text(len: u64) -> BinResult<String> {
#[binrw::parser(reader)]
fn read_leb(name: &'static str) -> BinResult<u64> {
let pos = reader.stream_position()?;
leb128::read::unsigned(reader).map_err(|_| BError::Custom {
leb128::read::unsigned(reader).map_err(|e| BError::Custom {
pos,
err: Box::new(name),
err: leb_failure(e, name),
})
}
#[binrw::parser(reader)]
fn read_sleb(name: &'static str) -> BinResult<i64> {
let pos = reader.stream_position()?;
leb128::read::signed(reader).map_err(|_| BError::Custom {
leb128::read::signed(reader).map_err(|e| BError::Custom {
pos,
err: Box::new(name),
err: leb_failure(e, name),
})
}
#[binrw::parser(reader)]
fn read_leb_u32(name: &'static str, range_msg: &'static str) -> BinResult<u32> {
let pos = reader.stream_position()?;
let v = leb128::read::unsigned(reader).map_err(|_| BError::Custom {
let v = leb128::read::unsigned(reader).map_err(|e| BError::Custom {
pos,
err: Box::new(name),
err: leb_failure(e, name),
})?;
v.try_into().map_err(|_| BError::Custom {
pos,
Expand All @@ -86,9 +146,9 @@ fn read_leb_u32(name: &'static str, range_msg: &'static str) -> BinResult<u32> {
#[binrw::parser(reader)]
fn read_leb_usize(name: &'static str, range_msg: &'static str) -> BinResult<usize> {
let pos = reader.stream_position()?;
let v = leb128::read::unsigned(reader).map_err(|_| BError::Custom {
let v = leb128::read::unsigned(reader).map_err(|e| BError::Custom {
pos,
err: Box::new(name),
err: leb_failure(e, name),
})?;
v.try_into().map_err(|_| BError::Custom {
pos,
Expand Down
74 changes: 65 additions & 9 deletions rust/candid/src/de.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,13 +33,7 @@ impl<'de> IDLDeserialize<'de> {
}
/// Create a new deserializer with IDL binary message. The config is used to adjust some parameters in the deserializer.
pub fn new_with_config(bytes: &'de [u8], config: &DecoderConfig) -> Result<Self> {
let mut de = Deserializer::from_bytes(bytes, config).with_context(|| {
if config.full_error_message || bytes.len() <= 500 {
format!("Cannot parse header {}", hex::encode(bytes))
} else {
"Cannot parse header".to_string()
}
})?;
let mut de = Deserializer::from_bytes(bytes, config)?;
de.add_cost((de.input.position() as usize).saturating_mul(4))?;
Ok(IDLDeserialize { de })
}
Expand Down Expand Up @@ -147,6 +141,7 @@ pub struct DecoderConfig {
pub decoding_quota: Option<usize>,
pub skipping_quota: Option<usize>,
pub max_type_len: Option<usize>,
pub max_header_len: Option<usize>,
full_error_message: bool,
}
impl DecoderConfig {
Expand All @@ -158,6 +153,7 @@ impl DecoderConfig {
decoding_quota: None,
skipping_quota: None,
max_type_len: None,
max_header_len: None,
#[cfg(not(target_arch = "wasm32"))]
full_error_message: true,
#[cfg(target_arch = "wasm32")]
Expand Down Expand Up @@ -222,6 +218,22 @@ impl DecoderConfig {
self.max_type_len = Some(n);
self
}
/// Limit the byte length of the type-table header, the part of the message that
/// describes the types of the values that follow.
///
/// A header's size is a property of the interface rather than of the payload, and
/// every element it declares -- an argument, a record or variant field, a function
/// argument or result, a service method -- costs at least one byte. Bounding its
/// length therefore bounds each of those counts at once, and bounds the work the
/// header can demand before any value is read.
///
/// Defaults to 64 KiB when unset, which is roughly 32x the largest header among
/// the IC's own interfaces. [`set_max_type_len`](#method.set_max_type_len) remains
/// a separate bound on the number of type-table entries.
pub fn set_max_header_len(&mut self, n: usize) -> &mut Self {
self.max_header_len = Some(n);
self
}
/// When set to false, error message only displays the concrete type when the type is small.
/// The error message also doesn't include the decoding states.
/// When set to true, error message always shows the full type and decoding states.
Expand All @@ -241,6 +253,7 @@ impl DecoderConfig {
decoding_quota,
skipping_quota,
max_type_len: original.max_type_len,
max_header_len: original.max_header_len,
full_error_message: original.full_error_message,
}
}
Expand Down Expand Up @@ -312,11 +325,54 @@ struct Deserializer<'de> {
text_fast_path: bool,
}

/// Context attached to a header parse failure. Includes the raw input only where the
/// configuration asks for it or the message is small enough for it to be useful.
fn header_context(bytes: &[u8], config: &DecoderConfig) -> String {
if config.full_error_message || bytes.len() <= 500 {
format!("Cannot parse header {}", hex::encode(bytes))
} else {
"Cannot parse header".to_string()
}
}

impl<'de> Deserializer<'de> {
fn from_bytes(bytes: &'de [u8], config: &DecoderConfig) -> Result<Self> {
// Parse the header from a bounded prefix of the input. A header that needs
// more than `max_header_len` bytes runs off the end of that prefix and fails
// there, so an oversized header costs only the bound rather than its declared
// size. The value section is read from the full input afterwards.
let max_header_len = config
.max_header_len
.unwrap_or(crate::binary_parser::DEFAULT_MAX_HEADER_LEN);
let bounded = bytes.len().min(max_header_len);
let mut probe = Cursor::new(&bytes[..bounded]);
let header = match Header::read_le_args(&mut probe, (config.max_type_len,)) {
Ok(header) => header,
Err(e) => {
// Report the bound only when the parse ran out of input while the
// prefix stopped short of the message. A malformed header -- bad magic,
// an unknown opcode, an over-long type table -- keeps its own
// diagnosis, whatever the size of the message around it.
if bounded < bytes.len() && crate::binary_parser::is_truncation(&e) {
// Deliberately without the input dump: attaching one would make
// rejecting an oversized header cost a diagnostic proportional to
// the whole message, which is the cost this bound exists to avoid.
// The message is self-describing in any case.
return Err(Error::msg(format!(
"Type table header exceeds the limit of {max_header_len} bytes"
)));
}
return Err(Error::from(e))
.with_context(|| header_context(bytes, config))
.map_err(Error::from);
}
};
let mut reader = Cursor::new(bytes);
let header = Header::read_le_args(&mut reader, (config.max_type_len,))?;
let (env, types) = header.to_types()?;
reader.set_position(probe.position());
let (env, types) = header
.to_types()
.with_context(|| header_context(bytes, config))
.map_err(Error::from)?;
Ok(Deserializer {
input: reader,
table: env.into(),
Expand Down
17 changes: 11 additions & 6 deletions rust/candid/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,13 +49,18 @@ fn get_binread_labels(e: &binrw::Error) -> Vec<Label> {
}
Custom { pos, err } => {
let pos = (*pos * 2) as usize;
let err = err
// `Truncated` carries the same field name as the plain `&str` case; it is a
// distinct type only so a read that ran out of input stays distinguishable
// from a malformed one. Both render identically here.
let message = err
.downcast_ref::<&str>()
.unwrap_or(&"unknown error (there's a bug in error reporting)");
vec![Label {
pos,
message: (*err).to_string(),
}]
.map(|s| (*s).to_string())
.or_else(|| {
err.downcast_ref::<crate::binary_parser::Truncated>()
.map(|t| t.0.to_string())
})
.unwrap_or_else(|| "unknown error (there's a bug in error reporting)".to_string());
vec![Label { pos, message }]
}
EnumErrors {
pos,
Expand Down
Loading
Loading