From 9ed6b3c8418aebe6754ae86f8596e97154843ff6 Mon Sep 17 00:00:00 2001 From: Matt Hargett Date: Fri, 25 Sep 2026 13:52:59 -0700 Subject: [PATCH] parser: add opt-in limits for untrusted modules --- Cargo.lock | 1 + README.md | 2 +- crates/parser/Cargo.toml | 3 + crates/parser/README.md | 18 ++- crates/parser/src/conversion.rs | 10 ++ crates/parser/src/error.rs | 36 +++++ crates/parser/src/lib.rs | 152 ++++++++++++++++++- crates/parser/src/module.rs | 90 ++++++++++- crates/parser/src/tests.rs | 261 ++++++++++++++++++++++++++++++++ crates/parser/src/visit.rs | 53 +++++-- 10 files changed, 605 insertions(+), 21 deletions(-) create mode 100644 crates/parser/src/tests.rs diff --git a/Cargo.lock b/Cargo.lock index 3b2eaf05..fad4ef6f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1036,6 +1036,7 @@ dependencies = [ "log", "tinywasm-types", "wasmparser", + "wat", ] [[package]] diff --git a/README.md b/README.md index 5fb864c8..f8845b1c 100644 --- a/README.md +++ b/README.md @@ -75,7 +75,7 @@ Applications that only load `.twasm` can remove the parser and validator from th WebAssembly [validation](https://webassembly.github.io/spec/core/valid/index.html) is enabled by default through the `validate` feature. Keep this feature enabled and leave `ParserOptions::validation` enabled for modules from untrusted sources. Without validation, parsing can produce modules that violate runtime assumptions and may panic during instantiation or execution. -Validation does not limit parsing or execution resources. Hosts that run untrusted code should also set input limits, configure stack and `ResourceLimiter` limits, and use fuel- or time-budgeted execution as needed. +Validation does not limit parsing or execution resources. Hosts that run untrusted code should also set opt-in `parser::ParseLimits` for input and known parse-time amplification points, configure stack and `ResourceLimiter` limits, and use fuel- or time-budgeted execution as needed. Loading `.twasm` checks the archive header and encoding but does not run WebAssembly validation or verify TinyWasm's runtime invariants. Load archives only from trusted sources. For untrusted input, parse a WebAssembly binary with validation enabled. diff --git a/crates/parser/Cargo.toml b/crates/parser/Cargo.toml index afedeed6..c6302326 100644 --- a/crates/parser/Cargo.toml +++ b/crates/parser/Cargo.toml @@ -15,6 +15,9 @@ log = { workspace = true, optional = true } tinywasm-types = { workspace = true } wasmparser = { workspace = true, features = ["simd"] } +[dev-dependencies] +wat.workspace = true + [features] default = ["log", "parallel", "std", "validate"] diff --git a/crates/parser/README.md b/crates/parser/README.md index cf1a6c49..23f7ce33 100644 --- a/crates/parser/README.md +++ b/crates/parser/README.md @@ -12,14 +12,24 @@ This crate provides the parser and lowering pipeline that converts WebAssembly b ## Usage ```rust -use tinywasm_parser::{Parser, ParserOptions}; +use tinywasm_parser::{ParseLimits, Parser, ParserOptions}; let bytes = include_bytes!("./file.wasm"); let parser = Parser::default(); let module = parser.parse_module_bytes(bytes)?; -let parser = Parser::new(ParserOptions::default().with_rewrite_optimization(false)); +let parser = Parser::new(ParserOptions::default().with_optimize(false)); +let module = parser.parse_module_bytes(bytes)?; + +// Select explicit bounds before accepting untrusted input. Keep validation on. +let limits = ParseLimits::new() + .with_max_module_bytes(16 * 1024 * 1024) + .with_max_section_items(100_000) + .with_max_function_locals(10_000) + .with_max_br_table_targets(4_096) + .with_max_array_new_fixed_elements(4_096); +let parser = Parser::new(ParserOptions::default().with_limits(limits)); let module = parser.parse_module_bytes(bytes)?; let module = parser.parse_module_file("path/to/file.wasm")?; @@ -28,3 +38,7 @@ let module = parser.parse_module_stream(&mut stream)?; ``` If you just want the default configuration, the top-level `parse_bytes`, `parse_file`, and `parse_stream` helpers are thin wrappers around `Parser::default()`. + +The limits are opt-in and disabled by default. They bound encoded input and +known parse-time amplification points; they are not a hard cap on peak memory +or elapsed time. A host should also bound guest runtime resources after parsing. diff --git a/crates/parser/src/conversion.rs b/crates/parser/src/conversion.rs index 5482d4d8..674e7a58 100644 --- a/crates/parser/src/conversion.rs +++ b/crates/parser/src/conversion.rs @@ -165,6 +165,11 @@ pub(crate) fn convert_module_code( ) -> Result<(FunctionCode, Option, OperatorsReaderAllocations)> { let mut locals_reader = func.get_locals_reader()?; let mut local_types = metadata.signature(context.ty_idx)?.params.clone(); + crate::check_parse_limit( + crate::ParseLimitKind::FunctionLocals, + options.limits.max_function_locals, + local_types.len(), + )?; #[cfg(feature = "validate")] let mut validator = validator; @@ -173,6 +178,11 @@ pub(crate) fn convert_module_code( #[cfg(feature = "validate")] let position = locals_reader.original_position(); let local = locals_reader.read()?; + let expanded = local_types + .len() + .checked_add(local.0 as usize) + .ok_or_else(|| crate::ParseError::Other("function local count overflow".into()))?; + crate::check_parse_limit(crate::ParseLimitKind::FunctionLocals, options.limits.max_function_locals, expanded)?; #[cfg(feature = "validate")] if let Some(validator) = validator.as_mut() { validator.define_locals(position, local.0, local.1)?; diff --git a/crates/parser/src/error.rs b/crates/parser/src/error.rs index 892e27bb..d7542452 100644 --- a/crates/parser/src/error.rs +++ b/crates/parser/src/error.rs @@ -2,6 +2,34 @@ use alloc::string::{String, ToString}; use core::fmt::{Debug, Display}; use wasmparser::Encoding; +/// The input or parse-time expansion limit that was exceeded. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ParseLimitKind { + /// Encoded module bytes. + ModuleBytes, + /// Materialized entries in one module section. + SectionItems, + /// Parameters plus declared locals in one function. + FunctionLocals, + /// Explicit targets in one `br_table`. + BrTableTargets, + /// Elements in one `array.new_fixed`. + ArrayNewFixedElements, +} + +impl Display for ParseLimitKind { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + let name = match self { + Self::ModuleBytes => "module bytes", + Self::SectionItems => "section items", + Self::FunctionLocals => "function locals", + Self::BrTableTargets => "br_table targets", + Self::ArrayNewFixedElements => "array.new_fixed elements", + }; + f.write_str(name) + } +} + #[derive(Debug, PartialEq, Eq)] /// Errors that can occur when parsing a WebAssembly module pub enum ParseError { @@ -33,6 +61,13 @@ pub enum ParseError { }, /// The end of the module was not reached EndNotReached, + /// A configured parse limit was exceeded. + LimitExceeded { + /// The kind of limit. + kind: ParseLimitKind, + /// The configured maximum. + limit: usize, + }, /// An unknown error occurred Other(String), } @@ -53,6 +88,7 @@ impl Display for ParseError { write!(f, "invalid local count: expected {expected}, actual {actual}") } Self::EndNotReached => write!(f, "end of module not reached"), + Self::LimitExceeded { kind, limit } => write!(f, "parse limit exceeded: {kind} (maximum {limit})"), Self::Other(message) => write!(f, "unknown error: {message}"), } } diff --git a/crates/parser/src/lib.rs b/crates/parser/src/lib.rs index c729f6bd..fd60acad 100644 --- a/crates/parser/src/lib.rs +++ b/crates/parser/src/lib.rs @@ -38,6 +38,9 @@ mod selection; mod validation; mod visit; +#[cfg(all(test, feature = "std"))] +mod tests; + #[cfg(parallel_parser)] mod parallel; @@ -50,6 +53,79 @@ use wasmparser::WasmFeatures; pub use tinywasm_types::Module; +/// Optional limits for parsing modules from untrusted sources. +/// +/// These limit encoded input and specific forms of parse-time expansion. They do +/// not constitute a hard bound on the parser's total memory use. Validation +/// should remain enabled for untrusted modules. +#[non_exhaustive] +#[derive(Debug, Clone, Copy, Default)] +pub struct ParseLimits { + /// Maximum encoded module size in bytes, including custom sections. + pub max_module_bytes: Option, + /// Maximum materialized entries in any one section. Recursive type groups + /// and compact imports count by their expanded entries. + pub max_section_items: Option, + /// Maximum parameters plus declared locals in any one function. + pub max_function_locals: Option, + /// Maximum explicit targets in one `br_table` (excluding its default). + pub max_br_table_targets: Option, + /// Maximum elements in one `array.new_fixed`. + pub max_array_new_fixed_elements: Option, +} + +impl ParseLimits { + /// Create limits with every bound disabled. + pub const fn new() -> Self { + Self { + max_module_bytes: None, + max_section_items: None, + max_function_locals: None, + max_br_table_targets: None, + max_array_new_fixed_elements: None, + } + } + + /// Bound the encoded size of a module. + pub const fn with_max_module_bytes(mut self, limit: usize) -> Self { + self.max_module_bytes = Some(limit); + self + } + + /// Bound the number of materialized entries in each section. + pub const fn with_max_section_items(mut self, limit: usize) -> Self { + self.max_section_items = Some(limit); + self + } + + /// Bound parameters plus declared locals in each function. + pub const fn with_max_function_locals(mut self, limit: usize) -> Self { + self.max_function_locals = Some(limit); + self + } + + /// Bound explicit targets in each `br_table`. + pub const fn with_max_br_table_targets(mut self, limit: usize) -> Self { + self.max_br_table_targets = Some(limit); + self + } + + /// Bound elements in each `array.new_fixed`. + pub const fn with_max_array_new_fixed_elements(mut self, limit: usize) -> Self { + self.max_array_new_fixed_elements = Some(limit); + self + } +} + +pub(crate) fn check_parse_limit(kind: ParseLimitKind, limit: Option, observed: usize) -> Result<()> { + if let Some(limit) = limit + && observed > limit + { + return Err(ParseError::LimitExceeded { kind, limit }); + } + Ok(()) +} + /// Parser optimization and lowering options. #[non_exhaustive] #[derive(Debug, Clone)] @@ -69,6 +145,9 @@ pub struct ParserOptions { /// Whether to deduplicate immutable function operands while parsing. pub deduplicate_operands: bool, + /// Optional limits for parsing untrusted modules. Unlimited by default. + pub limits: ParseLimits, + #[cfg(parallel_parser)] /// Number of threads to use for parallel parsing. /// @@ -86,6 +165,7 @@ impl Default for ParserOptions { validation: cfg!(feature = "validate"), optimize: true, deduplicate_operands: true, + limits: ParseLimits::default(), #[cfg(parallel_parser)] threads: None, } @@ -137,6 +217,17 @@ impl ParserOptions { self.deduplicate_operands } + /// Set parse-time limits. + pub const fn with_limits(mut self, limits: ParseLimits) -> Self { + self.limits = limits; + self + } + + /// Returns the configured parse-time limits. + pub const fn limits(&self) -> &ParseLimits { + &self.limits + } + #[cfg(parallel_parser)] /// Set the number of threads for parallel parsing. /// @@ -187,10 +278,35 @@ impl Parser { } #[cfg(feature = "std")] - fn read_more(stream: &mut impl std::io::Read, buffer: &mut alloc::vec::Vec, hint: usize) -> Result { + fn read_more( + stream: &mut impl std::io::Read, + buffer: &mut alloc::vec::Vec, + hint: usize, + total_read: &mut usize, + max_module_bytes: Option, + ) -> Result { let len = buffer.len(); // Size hints can come from untrusted section lengths. - let increment = hint.clamp(1, 64 * 1024); + let mut increment = hint.clamp(1, 64 * 1024); + if let Some(limit) = max_module_bytes { + let remaining = limit.saturating_sub(*total_read); + if remaining == 0 { + // A module exactly at the limit may still need an EOF probe. + let mut extra = [0]; + let read_bytes = loop { + match stream.read(&mut extra) { + Ok(read_bytes) => break read_bytes, + Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue, + Err(e) => return Err(ParseError::Other(alloc::format!("Error reading from stream: {e}"))), + } + }; + if read_bytes != 0 { + return Err(ParseError::LimitExceeded { kind: ParseLimitKind::ModuleBytes, limit }); + } + return Ok(0); + } + increment = increment.min(remaining); + } let new_len = len.checked_add(increment).ok_or_else(|| ParseError::Other("stream buffer is too large".into()))?; buffer @@ -208,12 +324,15 @@ impl Parser { } }; buffer.truncate(len + read_bytes); + *total_read = + total_read.checked_add(read_bytes).ok_or_else(|| ParseError::Other("stream byte count overflow".into()))?; Ok(read_bytes) } /// Parse a [`Module`] from bytes pub fn parse_module_bytes(&self, wasm: impl AsRef<[u8]>) -> Result { let wasm = wasm.as_ref(); + check_parse_limit(ParseLimitKind::ModuleBytes, self.options.limits.max_module_bytes, wasm.len())?; let mut validator = self.validator(); let mut reader = ModuleReader::default(); @@ -225,7 +344,7 @@ impl Parser { wasmparser::Payload::CodeSectionEntry(function) => { reader.process_borrowed_code_section_entry(function, validator.as_mut(), &self.options)?; } - payload => reader.process_payload(payload, validator.as_mut())?, + payload => reader.process_payload(payload, validator.as_mut(), &self.options)?, } } @@ -254,6 +373,7 @@ impl Parser { let mut parser = wasmparser::Parser::new(0); let mut eof = false; let mut buffer_offset = 0; + let mut total_read = 0; loop { match parser.parse(&buffer[buffer_offset..], eof)? { @@ -263,7 +383,13 @@ impl Parser { buffer.truncate(buffer.len() - buffer_offset); buffer_offset = 0; } - let read_bytes = Self::read_more(&mut stream, &mut buffer, hint)?; + let read_bytes = Self::read_more( + &mut stream, + &mut buffer, + hint, + &mut total_read, + self.options.limits.max_module_bytes, + )?; eof = read_bytes == 0; } wasmparser::Chunk::Parsed { consumed, payload } => { @@ -287,7 +413,7 @@ impl Parser { reader.process_inline_code_section_entry(function, validator.as_mut(), &self.options)?; } payload => { - reader.process_payload(payload, validator.as_mut())?; + reader.process_payload(payload, validator.as_mut(), &self.options)?; } } buffer_offset += consumed; @@ -296,7 +422,13 @@ impl Parser { if let Some((count, section_size)) = deferred_code_section { while buffer.len() - buffer_offset < section_size { let remaining = section_size - (buffer.len() - buffer_offset); - let read_bytes = Self::read_more(&mut stream, &mut buffer, remaining)?; + let read_bytes = Self::read_more( + &mut stream, + &mut buffer, + remaining, + &mut total_read, + self.options.limits.max_module_bytes, + )?; if read_bytes == 0 { return Err(ParseError::ParseError { message: "unexpected end-of-file".into(), @@ -320,7 +452,13 @@ impl Parser { } if !eof { - let read_bytes = Self::read_more(&mut stream, &mut buffer, 1)?; + let read_bytes = Self::read_more( + &mut stream, + &mut buffer, + 1, + &mut total_read, + self.options.limits.max_module_bytes, + )?; eof = read_bytes == 0; if !eof { diff --git a/crates/parser/src/module.rs b/crates/parser/src/module.rs index fcaba401..75e78905 100644 --- a/crates/parser/src/module.rs +++ b/crates/parser/src/module.rs @@ -2,7 +2,7 @@ use crate::log::debug; #[cfg(parallel_parser)] use crate::validation::{FuncToValidate, ValidatorResources}; use crate::validation::{FuncValidatorAllocations, Validator}; -use crate::{ParseError, ParserOptions, Result, conversion::*}; +use crate::{ParseError, ParseLimitKind, ParserOptions, Result, check_parse_limit, conversion::*}; use alloc::{boxed::Box, format, string::ToString, vec::Vec}; use core::marker::PhantomData; use core::ops::Range; @@ -66,7 +66,12 @@ impl<'a> ModuleReader<'a> { self.translation_metadata.as_ref().unwrap() } - pub(crate) fn process_payload(&mut self, payload: Payload<'_>, validator: Option<&mut Validator>) -> Result<()> { + pub(crate) fn process_payload( + &mut self, + payload: Payload<'_>, + validator: Option<&mut Validator>, + options: &ParserOptions, + ) -> Result<()> { #[cfg(feature = "validate")] let mut validator = validator; #[cfg(not(feature = "validate"))] @@ -104,6 +109,11 @@ impl<'a> ModuleReader<'a> { } Payload::TypeSection(reader) => { check_section("type", self.has_type_section)?; + check_parse_limit( + ParseLimitKind::SectionItems, + options.limits.max_section_items, + reader.count() as usize, + )?; self.has_type_section = true; #[cfg(feature = "validate")] if let Some(validator) = validator.as_mut() { @@ -113,6 +123,11 @@ impl<'a> ModuleReader<'a> { let mut rec_group_lengths = Vec::with_capacity(reader.count() as usize); for group in reader { let group = group?; + let expanded = types + .len() + .checked_add(group.types().len()) + .ok_or_else(|| ParseError::Other("type section item count overflow".into()))?; + check_parse_limit(ParseLimitKind::SectionItems, options.limits.max_section_items, expanded)?; let group_start = u32::try_from(types.len()) .map_err(|_| ParseError::Other("type section is too large".into()))?; let group_len = convert_rec_group(group, group_start, &mut types)?; @@ -125,6 +140,11 @@ impl<'a> ModuleReader<'a> { } Payload::GlobalSection(reader) => { check_section("global", !self.globals.is_empty())?; + check_parse_limit( + ParseLimitKind::SectionItems, + options.limits.max_section_items, + reader.count() as usize, + )?; #[cfg(feature = "validate")] if let Some(validator) = validator.as_mut() { validator.global_section(&reader)?; @@ -133,6 +153,11 @@ impl<'a> ModuleReader<'a> { } Payload::TableSection(reader) => { check_section("table", !self.tables.is_empty())?; + check_parse_limit( + ParseLimitKind::SectionItems, + options.limits.max_section_items, + reader.count() as usize, + )?; #[cfg(feature = "validate")] if let Some(validator) = validator.as_mut() { validator.table_section(&reader)?; @@ -158,6 +183,11 @@ impl<'a> ModuleReader<'a> { } Payload::MemorySection(reader) => { check_section("memory", !self.memory_types.is_empty())?; + check_parse_limit( + ParseLimitKind::SectionItems, + options.limits.max_section_items, + reader.count() as usize, + )?; #[cfg(feature = "validate")] if let Some(validator) = validator.as_mut() { validator.memory_section(&reader)?; @@ -167,6 +197,11 @@ impl<'a> ModuleReader<'a> { } Payload::TagSection(reader) => { check_section("tag", !self.tags.is_empty())?; + check_parse_limit( + ParseLimitKind::SectionItems, + options.limits.max_section_items, + reader.count() as usize, + )?; #[cfg(feature = "validate")] if let Some(validator) = validator.as_mut() { validator.tag_section(&reader)?; @@ -186,6 +221,11 @@ impl<'a> ModuleReader<'a> { } Payload::ElementSection(reader) => { debug!("Found element section"); + check_parse_limit( + ParseLimitKind::SectionItems, + options.limits.max_section_items, + reader.count() as usize, + )?; #[cfg(feature = "validate")] if let Some(validator) = validator.as_mut() { validator.element_section(&reader)?; @@ -197,6 +237,11 @@ impl<'a> ModuleReader<'a> { } Payload::DataSection(reader) => { check_section("data", !self.data.is_empty())?; + check_parse_limit( + ParseLimitKind::SectionItems, + options.limits.max_section_items, + reader.count() as usize, + )?; #[cfg(feature = "validate")] if let Some(validator) = validator.as_mut() { validator.data_section(&reader)?; @@ -208,6 +253,7 @@ impl<'a> ModuleReader<'a> { } Payload::DataCountSection { count, range } => { debug!("Found data count section"); + check_parse_limit(ParseLimitKind::SectionItems, options.limits.max_section_items, count as usize)?; if !self.data.is_empty() { return Err(ParseError::UnsupportedSection("Data count section after data section".into())); } @@ -220,6 +266,11 @@ impl<'a> ModuleReader<'a> { } Payload::FunctionSection(reader) => { check_section("function", !self.code_type_addrs.is_empty())?; + check_parse_limit( + ParseLimitKind::SectionItems, + options.limits.max_section_items, + reader.count() as usize, + )?; #[cfg(feature = "validate")] if let Some(validator) = validator.as_mut() { validator.function_section(&reader)?; @@ -239,6 +290,30 @@ impl<'a> ModuleReader<'a> { } Payload::ImportSection(reader) => { check_section("import", !self.imports.is_empty())?; + check_parse_limit( + ParseLimitKind::SectionItems, + options.limits.max_section_items, + reader.count() as usize, + )?; + if options.limits.max_section_items.is_some() { + // Compact import groups can expand well beyond the outer + // section count. Count them before validation materializes + // the individual imports. This scan is opt-in and does not + // inspect function bodies or affect opcode lowering. + let mut expanded = 0usize; + for group in reader.clone() { + let group = group?; + let group_len = match group { + wasmparser::Imports::Single(..) => 1, + wasmparser::Imports::Compact1 { items, .. } => items.count() as usize, + wasmparser::Imports::Compact2 { names, .. } => names.count() as usize, + }; + expanded = expanded + .checked_add(group_len) + .ok_or_else(|| ParseError::Other("import section item count overflow".into()))?; + check_parse_limit(ParseLimitKind::SectionItems, options.limits.max_section_items, expanded)?; + } + } #[cfg(feature = "validate")] if let Some(validator) = validator.as_mut() { validator.import_section(&reader)?; @@ -246,6 +321,11 @@ impl<'a> ModuleReader<'a> { let mut imports = Vec::with_capacity(reader.count() as usize); for import in reader.into_imports() { let import = convert_module_import(import?)?; + check_parse_limit( + ParseLimitKind::SectionItems, + options.limits.max_section_items, + imports.len() + 1, + )?; match import.kind { ImportKind::Function(type_idx) => { if self.types.get(type_idx).and_then(SubType::as_func).is_none() { @@ -278,6 +358,11 @@ impl<'a> ModuleReader<'a> { } Payload::ExportSection(reader) => { check_section("export", !self.exports.is_empty())?; + check_parse_limit( + ParseLimitKind::SectionItems, + options.limits.max_section_items, + reader.count() as usize, + )?; #[cfg(feature = "validate")] if let Some(validator) = validator.as_mut() { validator.export_section(&reader)?; @@ -321,6 +406,7 @@ impl<'a> ModuleReader<'a> { options: &ParserOptions, ) -> Result { debug!("Found code section ({count} functions)"); + check_parse_limit(ParseLimitKind::SectionItems, options.limits.max_section_items, count as usize)?; if self.has_code_section { return Err(ParseError::DuplicateSection("Code section".into())); } diff --git a/crates/parser/src/tests.rs b/crates/parser/src/tests.rs new file mode 100644 index 00000000..f53dc0bd --- /dev/null +++ b/crates/parser/src/tests.rs @@ -0,0 +1,261 @@ +use crate::{ParseError, ParseLimitKind, ParseLimits, Parser, ParserOptions}; +use std::io::{Cursor, Read}; +use std::{vec, vec::Vec}; + +#[cfg(parallel_parser)] +use std::string::String; + +fn limited(limits: ParseLimits) -> Parser { + Parser::new(ParserOptions::new().with_limits(limits)) +} + +fn assert_limit(error: ParseError, kind: ParseLimitKind, limit: usize) { + assert_eq!(error, ParseError::LimitExceeded { kind, limit }); +} + +fn parse_error(result: Result) -> ParseError { + result.err().expect("expected a parse error") +} + +fn uleb(mut value: u32, bytes: &mut Vec) { + loop { + let mut byte = (value & 0x7f) as u8; + value >>= 7; + if value != 0 { + byte |= 0x80; + } + bytes.push(byte); + if value == 0 { + break; + } + } +} + +fn section(id: u8, payload: &[u8], wasm: &mut Vec) { + wasm.push(id); + uleb(payload.len() as u32, wasm); + wasm.extend_from_slice(payload); +} + +fn compact_local_declaration(count: u32) -> Vec { + let mut wasm = b"\0asm\x01\0\0\0".to_vec(); + section(1, &[1, 0x60, 0, 0], &mut wasm); + section(3, &[1, 0], &mut wasm); + let mut body = vec![1]; + uleb(count, &mut body); + body.extend_from_slice(&[0x7f, 0x0b]); + let mut code = vec![1]; + uleb(body.len() as u32, &mut code); + code.extend_from_slice(&body); + section(10, &code, &mut wasm); + wasm +} + +#[test] +fn module_byte_limit_has_exact_boundary_for_bytes_and_streams() { + let wasm = wat::parse_str("(module (func))").unwrap(); + let exact = wasm.len(); + let at_limit = limited(ParseLimits::new().with_max_module_bytes(exact)); + assert!(at_limit.parse_module_bytes(&wasm).is_ok()); + assert!(at_limit.parse_module_stream(Cursor::new(&wasm)).is_ok()); + + let below = limited(ParseLimits::new().with_max_module_bytes(exact - 1)); + assert_limit(parse_error(below.parse_module_bytes(&wasm)), ParseLimitKind::ModuleBytes, exact - 1); + assert_limit(parse_error(below.parse_module_stream(Cursor::new(&wasm))), ParseLimitKind::ModuleBytes, exact - 1); + + let above = limited(ParseLimits::new().with_max_module_bytes(exact + 1)); + assert!(above.parse_module_bytes(&wasm).is_ok()); + assert!(above.parse_module_stream(Cursor::new(&wasm)).is_ok()); +} + +#[test] +fn stream_limit_counts_total_bytes_even_when_buffer_slides() { + let mut wasm = b"\0asm\x01\0\0\0".to_vec(); + // A custom section with 65535 bytes of payload, supplied by an endless + // reader. The parser must stop after the configured total input budget. + wasm.extend_from_slice(&[0, 0xff, 0xff, 0x03]); + let input = Cursor::new(wasm).chain(std::io::repeat(0)); + let parser = limited(ParseLimits::new().with_max_module_bytes(128)); + assert_limit(parse_error(parser.parse_module_stream(input)), ParseLimitKind::ModuleBytes, 128); +} + +#[test] +fn section_limit_rejects_declared_and_expanded_entries() { + let functions = wat::parse_str("(module (func) (func))").unwrap(); + let parser = limited(ParseLimits::new().with_max_section_items(1)); + assert_limit(parse_error(parser.parse_module_bytes(&functions)), ParseLimitKind::SectionItems, 1); + + let rec_group = wat::parse_str("(module (rec (type (struct)) (type (array i32))))").unwrap(); + assert_limit(parse_error(parser.parse_module_bytes(&rec_group)), ParseLimitKind::SectionItems, 1); + + // One compact-import group materializes two function imports. + let compact_imports = + [0, 97, 115, 109, 1, 0, 0, 0, 1, 4, 1, 0x60, 0, 0, 2, 12, 1, 1, b'm', 0, 0x7e, 0, 0, 2, 1, b'a', 1, b'b']; + assert_limit(parse_error(parser.parse_module_bytes(compact_imports)), ParseLimitKind::SectionItems, 1); +} + +#[test] +fn compact_local_count_is_checked_before_expansion() { + let parser = limited(ParseLimits::new().with_max_function_locals(64)); + let too_many = compact_local_declaration(1_000_000); + assert!(too_many.len() < 32); + assert_limit(parse_error(parser.parse_module_bytes(&too_many)), ParseLimitKind::FunctionLocals, 64); + + let at_limit = compact_local_declaration(64); + assert!(parser.parse_module_bytes(&at_limit).is_ok()); + let above = compact_local_declaration(65); + assert_limit(parse_error(parser.parse_module_bytes(&above)), ParseLimitKind::FunctionLocals, 64); + + let with_param = wat::parse_str("(module (func (param i32) (local i32 i32)))").unwrap(); + let parser = limited(ParseLimits::new().with_max_function_locals(2)); + assert_limit(parse_error(parser.parse_module_bytes(&with_param)), ParseLimitKind::FunctionLocals, 2); +} + +#[test] +fn branch_table_fanout_is_checked_before_collection() { + let wasm = wat::parse_str("(module (func (param i32) block block local.get 0 br_table 0 1 0 end end))").unwrap(); + let parser = limited(ParseLimits::new().with_max_br_table_targets(1)); + assert_limit(parse_error(parser.parse_module_bytes(&wasm)), ParseLimitKind::BrTableTargets, 1); + let parser = limited(ParseLimits::new().with_max_br_table_targets(2)); + assert!(parser.parse_module_bytes(&wasm).is_ok()); +} + +#[test] +fn array_new_fixed_fanout_is_checked_before_validation() { + let wasm = + wat::parse_str("(module (type $a (array (mut i32))) (func unreachable array.new_fixed $a 1000000000 drop))") + .unwrap(); + assert!(wasm.len() < 64); + let parser = limited(ParseLimits::new().with_max_array_new_fixed_elements(4)); + assert_limit(parse_error(parser.parse_module_bytes(&wasm)), ParseLimitKind::ArrayNewFixedElements, 4); + + let small = + wat::parse_str("(module (type $a (array (mut i32))) (func i32.const 7 array.new_fixed $a 1 drop))").unwrap(); + assert!(parser.parse_module_bytes(&small).is_ok()); +} + +#[test] +fn fanout_and_local_limits_also_apply_without_validation() { + let limits = ParseLimits::new() + .with_max_function_locals(64) + .with_max_br_table_targets(1) + .with_max_array_new_fixed_elements(4); + let parser = Parser::new(ParserOptions::new().with_validation(false).with_limits(limits)); + assert_limit( + parse_error(parser.parse_module_bytes(compact_local_declaration(1_000_000))), + ParseLimitKind::FunctionLocals, + 64, + ); + let branch_table = + wat::parse_str("(module (func (param i32) block block local.get 0 br_table 0 1 0 end end))").unwrap(); + assert_limit(parse_error(parser.parse_module_bytes(&branch_table)), ParseLimitKind::BrTableTargets, 1); + let array = + wat::parse_str("(module (type $a (array (mut i32))) (func unreachable array.new_fixed $a 1000000000 drop))") + .unwrap(); + assert_limit(parse_error(parser.parse_module_bytes(&array)), ParseLimitKind::ArrayNewFixedElements, 4); +} + +#[test] +fn bounded_mutations_of_adversarial_inputs_do_not_panic() { + let seeds = [ + wat::parse_str("(module (func (param i32) block block local.get 0 br_table 0 1 0 end end))").unwrap(), + wat::parse_str("(module (type $a (array (mut i32))) (func unreachable array.new_fixed $a 1000000000 drop))") + .unwrap(), + compact_local_declaration(1_000_000), + ]; + let parser = limited( + ParseLimits::new() + .with_max_module_bytes(256) + .with_max_section_items(16) + .with_max_function_locals(64) + .with_max_br_table_targets(4) + .with_max_array_new_fixed_elements(4), + ); + for seed in &seeds { + for index in 0..seed.len() { + let mut mutated = seed.clone(); + mutated[index] ^= 0xff; + let _ = std::panic::catch_unwind(|| parser.parse_module_bytes(&mutated)) + .expect("bounded parser panicked on a mutated input"); + } + } + + // A reproducible local run can raise this count without slowing CI. + let iterations = + std::env::var("TINYWASM_PARSE_MUTATIONS").ok().and_then(|value| value.parse::().ok()).unwrap_or(512); + let mut state = 0x9e37_79b9_7f4a_7c15_u64; + for iteration in 0..iterations { + state ^= state << 13; + state ^= state >> 7; + state ^= state << 17; + let mut mutated = seeds[(state as usize) % seeds.len()].clone(); + for _ in 0..3 { + state ^= state << 13; + state ^= state >> 7; + state ^= state << 17; + match state % 3 { + 0 => { + let index = (state as usize) % mutated.len(); + mutated[index] ^= (state >> 32) as u8; + } + 1 => mutated.truncate((state as usize) % mutated.len()), + _ if mutated.len() < 256 => mutated.push((state >> 32) as u8), + _ => {} + } + if mutated.is_empty() { + break; + } + } + if std::panic::catch_unwind(|| parser.parse_module_bytes(&mutated)).is_err() { + panic!("bounded parser panicked on mutation {iteration}"); + } + } +} + +#[cfg(parallel_parser)] +#[test] +fn limits_match_for_serial_and_parallel_lowering() { + let mut source = String::from("(module"); + for _ in 0..9 { + source.push_str("(func "); + source.push_str(&"nop ".repeat(2048)); + source.push(')'); + } + source.push(')'); + let wasm = wat::parse_str(&source).unwrap(); + assert!(wasm.len() > 16 * 1024); + let limits = ParseLimits::new() + .with_max_module_bytes(wasm.len()) + .with_max_section_items(9) + .with_max_function_locals(0) + .with_max_br_table_targets(0) + .with_max_array_new_fixed_elements(0); + for threads in [1, 4] { + let parser = Parser::new(ParserOptions::new().with_limits(limits).with_threads(threads)); + assert!(parser.parse_module_bytes(&wasm).is_ok()); + assert!(parser.parse_module_stream(Cursor::new(&wasm)).is_ok()); + } + + let mut oversized = String::from("(module"); + for index in 0..9 { + oversized.push_str("(func "); + if index == 8 { + oversized.push_str("(local i32) "); + } + oversized.push_str(&"nop ".repeat(2048)); + oversized.push(')'); + } + oversized.push(')'); + let oversized = wat::parse_str(&oversized).unwrap(); + let limits = + ParseLimits::new().with_max_module_bytes(oversized.len()).with_max_section_items(9).with_max_function_locals(0); + for threads in [1, 4] { + let parser = Parser::new(ParserOptions::new().with_limits(limits).with_threads(threads)); + assert_limit(parse_error(parser.parse_module_bytes(&oversized)), ParseLimitKind::FunctionLocals, 0); + assert_limit( + parse_error(parser.parse_module_stream(Cursor::new(&oversized))), + ParseLimitKind::FunctionLocals, + 0, + ); + } +} diff --git a/crates/parser/src/visit.rs b/crates/parser/src/visit.rs index 7ca82e3b..3642d066 100644 --- a/crates/parser/src/visit.rs +++ b/crates/parser/src/visit.rs @@ -1,5 +1,5 @@ use crate::{ - ParserOptions, Result, + ParseLimitKind, ParserOptions, Result, check_parse_limit, conversion::{FunctionLoweringContext, convert_heap_type, value_lane}, emitter::{Emitter, LabelId}, macros::visit::*, @@ -20,6 +20,22 @@ use wasmparser::{FunctionBody, OperatorsReader, OperatorsReaderAllocations, Visi #[cfg(feature = "validate")] use wasmparser::{FuncValidator, FuncValidatorAllocations, ValidatorResources, VisitOperator}; +fn check_operator_limits(op: &wasmparser::Operator<'_>, options: &ParserOptions) -> Result<()> { + match op { + wasmparser::Operator::BrTable { targets } => check_parse_limit( + ParseLimitKind::BrTableTargets, + options.limits.max_br_table_targets, + targets.len() as usize, + ), + wasmparser::Operator::ArrayNewFixed { array_size, .. } => check_parse_limit( + ParseLimitKind::ArrayNewFixedElements, + options.limits.max_array_new_fixed_elements, + *array_size as usize, + ), + _ => Ok(()), + } +} + #[derive(Debug, Clone, Copy)] enum BlockKind { Function, @@ -464,11 +480,20 @@ pub(crate) fn process_operators( while !reader.eof() { let position = reader.original_position(); - let res = reader - .visit_operator(&mut builder) - .map_err(|e| crate::ParseError::ParseError { message: e.to_string(), offset: position }); + let res = + if options.limits.max_br_table_targets.is_some() || options.limits.max_array_new_fixed_elements.is_some() { + let op = reader + .read() + .map_err(|e| crate::ParseError::ParseError { message: e.to_string(), offset: position })?; + check_operator_limits(&op, options)?; + wasmparser::VisitOperator::visit_operator(&mut builder, &op) + } else { + reader + .visit_operator(&mut builder) + .map_err(|e| crate::ParseError::ParseError { message: e.to_string(), offset: position })? + }; - if let Err(e) = res.flatten() { + if let Err(e) = res { core::hint::cold_path(); return Err(e); } @@ -506,11 +531,21 @@ pub(crate) fn process_operators_and_validate( while !reader.eof() { let position = reader.original_position(); - let res = reader - .visit_operator(&mut ValidateThenVisit { validator: &mut validator, builder: &mut builder, position }) - .map_err(|e| crate::ParseError::ParseError { message: e.to_string(), offset: position }); + let mut visitor = ValidateThenVisit { validator: &mut validator, builder: &mut builder, position }; + let res = + if options.limits.max_br_table_targets.is_some() || options.limits.max_array_new_fixed_elements.is_some() { + let op = reader + .read() + .map_err(|e| crate::ParseError::ParseError { message: e.to_string(), offset: position })?; + check_operator_limits(&op, options)?; + visitor.visit_operator(&op) + } else { + reader + .visit_operator(&mut visitor) + .map_err(|e| crate::ParseError::ParseError { message: e.to_string(), offset: position })? + }; - if let Err(e) = res.flatten() { + if let Err(e) = res { core::hint::cold_path(); return Err(e); }