Skip to content
Merged
Show file tree
Hide file tree
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
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
3 changes: 3 additions & 0 deletions crates/parser/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]

Expand Down
18 changes: 16 additions & 2 deletions crates/parser/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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")?;
Expand All @@ -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.
10 changes: 10 additions & 0 deletions crates/parser/src/conversion.rs
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,11 @@ pub(crate) fn convert_module_code(
) -> Result<(FunctionCode, Option<FuncValidatorAllocations>, 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;
Expand All @@ -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)?;
Expand Down
36 changes: 36 additions & 0 deletions crates/parser/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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),
}
Expand All @@ -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}"),
}
}
Expand Down
152 changes: 145 additions & 7 deletions crates/parser/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ mod selection;
mod validation;
mod visit;

#[cfg(all(test, feature = "std"))]
mod tests;

#[cfg(parallel_parser)]
mod parallel;

Expand All @@ -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<usize>,
/// Maximum materialized entries in any one section. Recursive type groups
/// and compact imports count by their expanded entries.
pub max_section_items: Option<usize>,
/// Maximum parameters plus declared locals in any one function.
pub max_function_locals: Option<usize>,
/// Maximum explicit targets in one `br_table` (excluding its default).
pub max_br_table_targets: Option<usize>,
/// Maximum elements in one `array.new_fixed`.
pub max_array_new_fixed_elements: Option<usize>,
}

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<usize>, 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)]
Expand All @@ -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.
///
Expand All @@ -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,
}
Expand Down Expand Up @@ -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.
///
Expand Down Expand Up @@ -187,10 +278,35 @@ impl Parser {
}

#[cfg(feature = "std")]
fn read_more(stream: &mut impl std::io::Read, buffer: &mut alloc::vec::Vec<u8>, hint: usize) -> Result<usize> {
fn read_more(
stream: &mut impl std::io::Read,
buffer: &mut alloc::vec::Vec<u8>,
hint: usize,
total_read: &mut usize,
max_module_bytes: Option<usize>,
) -> Result<usize> {
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
Expand All @@ -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<Module> {
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();

Expand All @@ -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)?,
}
}

Expand Down Expand Up @@ -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)? {
Expand All @@ -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 } => {
Expand All @@ -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;
Expand All @@ -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(),
Expand All @@ -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 {
Expand Down
Loading
Loading