Skip to content

Commit 6d8072c

Browse files
authored
feat: parser limits (#67)
Related to #66. TinyWasm's default validator limits are deliberately broad for general WASM. For host that loads untrusted guest programs, a small binary can still encode disproportionate parse work (like a large local declaration or `array.new_fixed` count). This adds opt-in `ParseLimits` to `ParserOptions` for encoded module bytes, entries per section, parameters plus locals per function, `br_table` targets, and `array.new_fixed` elements. Byte, file, and stream entrypoints share the input limit; streaming counts total bytes read even when its buffer slides. The two operator fanouts are checked before wasmparser validation and lowering. Compact-import expansion is counted before validation when the section limit is set. All limits default to unlimited. The ordinary parser path and interpreter dispatch are unchanged; hosts that enable operator fanout limits take a guarded parser-only decode path. Validation should remain enabled for untrusted modules. These are input and known-amplification limits, **not** a hard peak-memory or elapsed-time guarantee: wasmparser can allocate while decoding a recursive type group before TinyWasm checks its expanded size. With limits in place, some pathological .wasm files now get properly rejected on my Apple Watch test app, instead of the app crashing. Local Mac validation: 50,000 deterministic fuzz mutations of adversarial parser fixtures. I also looked at guard techniques from other WASM parsers/runtimes test suites and rolled some of those tests in.
1 parent 2e469af commit 6d8072c

10 files changed

Lines changed: 605 additions & 21 deletions

File tree

‎Cargo.lock‎

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ Applications that only load `.twasm` can remove the parser and validator from th
7575

7676
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.
7777

78-
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.
78+
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.
7979

8080
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.
8181

‎crates/parser/Cargo.toml‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@ log = { workspace = true, optional = true }
1515
tinywasm-types = { workspace = true }
1616
wasmparser = { workspace = true, features = ["simd"] }
1717

18+
[dev-dependencies]
19+
wat.workspace = true
20+
1821
[features]
1922
default = ["log", "parallel", "std", "validate"]
2023

‎crates/parser/README.md‎

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,24 @@ This crate provides the parser and lowering pipeline that converts WebAssembly b
1212
## Usage
1313

1414
```rust
15-
use tinywasm_parser::{Parser, ParserOptions};
15+
use tinywasm_parser::{ParseLimits, Parser, ParserOptions};
1616

1717
let bytes = include_bytes!("./file.wasm");
1818

1919
let parser = Parser::default();
2020
let module = parser.parse_module_bytes(bytes)?;
2121

22-
let parser = Parser::new(ParserOptions::default().with_rewrite_optimization(false));
22+
let parser = Parser::new(ParserOptions::default().with_optimize(false));
23+
let module = parser.parse_module_bytes(bytes)?;
24+
25+
// Select explicit bounds before accepting untrusted input. Keep validation on.
26+
let limits = ParseLimits::new()
27+
.with_max_module_bytes(16 * 1024 * 1024)
28+
.with_max_section_items(100_000)
29+
.with_max_function_locals(10_000)
30+
.with_max_br_table_targets(4_096)
31+
.with_max_array_new_fixed_elements(4_096);
32+
let parser = Parser::new(ParserOptions::default().with_limits(limits));
2333
let module = parser.parse_module_bytes(bytes)?;
2434

2535
let module = parser.parse_module_file("path/to/file.wasm")?;
@@ -28,3 +38,7 @@ let module = parser.parse_module_stream(&mut stream)?;
2838
```
2939

3040
If you just want the default configuration, the top-level `parse_bytes`, `parse_file`, and `parse_stream` helpers are thin wrappers around `Parser::default()`.
41+
42+
The limits are opt-in and disabled by default. They bound encoded input and
43+
known parse-time amplification points; they are not a hard cap on peak memory
44+
or elapsed time. A host should also bound guest runtime resources after parsing.

‎crates/parser/src/conversion.rs‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,11 @@ pub(crate) fn convert_module_code(
165165
) -> Result<(FunctionCode, Option<FuncValidatorAllocations>, OperatorsReaderAllocations)> {
166166
let mut locals_reader = func.get_locals_reader()?;
167167
let mut local_types = metadata.signature(context.ty_idx)?.params.clone();
168+
crate::check_parse_limit(
169+
crate::ParseLimitKind::FunctionLocals,
170+
options.limits.max_function_locals,
171+
local_types.len(),
172+
)?;
168173

169174
#[cfg(feature = "validate")]
170175
let mut validator = validator;
@@ -173,6 +178,11 @@ pub(crate) fn convert_module_code(
173178
#[cfg(feature = "validate")]
174179
let position = locals_reader.original_position();
175180
let local = locals_reader.read()?;
181+
let expanded = local_types
182+
.len()
183+
.checked_add(local.0 as usize)
184+
.ok_or_else(|| crate::ParseError::Other("function local count overflow".into()))?;
185+
crate::check_parse_limit(crate::ParseLimitKind::FunctionLocals, options.limits.max_function_locals, expanded)?;
176186
#[cfg(feature = "validate")]
177187
if let Some(validator) = validator.as_mut() {
178188
validator.define_locals(position, local.0, local.1)?;

‎crates/parser/src/error.rs‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,34 @@ use alloc::string::{String, ToString};
22
use core::fmt::{Debug, Display};
33
use wasmparser::Encoding;
44

5+
/// The input or parse-time expansion limit that was exceeded.
6+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7+
pub enum ParseLimitKind {
8+
/// Encoded module bytes.
9+
ModuleBytes,
10+
/// Materialized entries in one module section.
11+
SectionItems,
12+
/// Parameters plus declared locals in one function.
13+
FunctionLocals,
14+
/// Explicit targets in one `br_table`.
15+
BrTableTargets,
16+
/// Elements in one `array.new_fixed`.
17+
ArrayNewFixedElements,
18+
}
19+
20+
impl Display for ParseLimitKind {
21+
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
22+
let name = match self {
23+
Self::ModuleBytes => "module bytes",
24+
Self::SectionItems => "section items",
25+
Self::FunctionLocals => "function locals",
26+
Self::BrTableTargets => "br_table targets",
27+
Self::ArrayNewFixedElements => "array.new_fixed elements",
28+
};
29+
f.write_str(name)
30+
}
31+
}
32+
533
#[derive(Debug, PartialEq, Eq)]
634
/// Errors that can occur when parsing a WebAssembly module
735
pub enum ParseError {
@@ -33,6 +61,13 @@ pub enum ParseError {
3361
},
3462
/// The end of the module was not reached
3563
EndNotReached,
64+
/// A configured parse limit was exceeded.
65+
LimitExceeded {
66+
/// The kind of limit.
67+
kind: ParseLimitKind,
68+
/// The configured maximum.
69+
limit: usize,
70+
},
3671
/// An unknown error occurred
3772
Other(String),
3873
}
@@ -53,6 +88,7 @@ impl Display for ParseError {
5388
write!(f, "invalid local count: expected {expected}, actual {actual}")
5489
}
5590
Self::EndNotReached => write!(f, "end of module not reached"),
91+
Self::LimitExceeded { kind, limit } => write!(f, "parse limit exceeded: {kind} (maximum {limit})"),
5692
Self::Other(message) => write!(f, "unknown error: {message}"),
5793
}
5894
}

‎crates/parser/src/lib.rs‎

Lines changed: 145 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,9 @@ mod selection;
3838
mod validation;
3939
mod visit;
4040

41+
#[cfg(all(test, feature = "std"))]
42+
mod tests;
43+
4144
#[cfg(parallel_parser)]
4245
mod parallel;
4346

@@ -50,6 +53,79 @@ use wasmparser::WasmFeatures;
5053

5154
pub use tinywasm_types::Module;
5255

56+
/// Optional limits for parsing modules from untrusted sources.
57+
///
58+
/// These limit encoded input and specific forms of parse-time expansion. They do
59+
/// not constitute a hard bound on the parser's total memory use. Validation
60+
/// should remain enabled for untrusted modules.
61+
#[non_exhaustive]
62+
#[derive(Debug, Clone, Copy, Default)]
63+
pub struct ParseLimits {
64+
/// Maximum encoded module size in bytes, including custom sections.
65+
pub max_module_bytes: Option<usize>,
66+
/// Maximum materialized entries in any one section. Recursive type groups
67+
/// and compact imports count by their expanded entries.
68+
pub max_section_items: Option<usize>,
69+
/// Maximum parameters plus declared locals in any one function.
70+
pub max_function_locals: Option<usize>,
71+
/// Maximum explicit targets in one `br_table` (excluding its default).
72+
pub max_br_table_targets: Option<usize>,
73+
/// Maximum elements in one `array.new_fixed`.
74+
pub max_array_new_fixed_elements: Option<usize>,
75+
}
76+
77+
impl ParseLimits {
78+
/// Create limits with every bound disabled.
79+
pub const fn new() -> Self {
80+
Self {
81+
max_module_bytes: None,
82+
max_section_items: None,
83+
max_function_locals: None,
84+
max_br_table_targets: None,
85+
max_array_new_fixed_elements: None,
86+
}
87+
}
88+
89+
/// Bound the encoded size of a module.
90+
pub const fn with_max_module_bytes(mut self, limit: usize) -> Self {
91+
self.max_module_bytes = Some(limit);
92+
self
93+
}
94+
95+
/// Bound the number of materialized entries in each section.
96+
pub const fn with_max_section_items(mut self, limit: usize) -> Self {
97+
self.max_section_items = Some(limit);
98+
self
99+
}
100+
101+
/// Bound parameters plus declared locals in each function.
102+
pub const fn with_max_function_locals(mut self, limit: usize) -> Self {
103+
self.max_function_locals = Some(limit);
104+
self
105+
}
106+
107+
/// Bound explicit targets in each `br_table`.
108+
pub const fn with_max_br_table_targets(mut self, limit: usize) -> Self {
109+
self.max_br_table_targets = Some(limit);
110+
self
111+
}
112+
113+
/// Bound elements in each `array.new_fixed`.
114+
pub const fn with_max_array_new_fixed_elements(mut self, limit: usize) -> Self {
115+
self.max_array_new_fixed_elements = Some(limit);
116+
self
117+
}
118+
}
119+
120+
pub(crate) fn check_parse_limit(kind: ParseLimitKind, limit: Option<usize>, observed: usize) -> Result<()> {
121+
if let Some(limit) = limit
122+
&& observed > limit
123+
{
124+
return Err(ParseError::LimitExceeded { kind, limit });
125+
}
126+
Ok(())
127+
}
128+
53129
/// Parser optimization and lowering options.
54130
#[non_exhaustive]
55131
#[derive(Debug, Clone)]
@@ -69,6 +145,9 @@ pub struct ParserOptions {
69145
/// Whether to deduplicate immutable function operands while parsing.
70146
pub deduplicate_operands: bool,
71147

148+
/// Optional limits for parsing untrusted modules. Unlimited by default.
149+
pub limits: ParseLimits,
150+
72151
#[cfg(parallel_parser)]
73152
/// Number of threads to use for parallel parsing.
74153
///
@@ -86,6 +165,7 @@ impl Default for ParserOptions {
86165
validation: cfg!(feature = "validate"),
87166
optimize: true,
88167
deduplicate_operands: true,
168+
limits: ParseLimits::default(),
89169
#[cfg(parallel_parser)]
90170
threads: None,
91171
}
@@ -137,6 +217,17 @@ impl ParserOptions {
137217
self.deduplicate_operands
138218
}
139219

220+
/// Set parse-time limits.
221+
pub const fn with_limits(mut self, limits: ParseLimits) -> Self {
222+
self.limits = limits;
223+
self
224+
}
225+
226+
/// Returns the configured parse-time limits.
227+
pub const fn limits(&self) -> &ParseLimits {
228+
&self.limits
229+
}
230+
140231
#[cfg(parallel_parser)]
141232
/// Set the number of threads for parallel parsing.
142233
///
@@ -187,10 +278,35 @@ impl Parser {
187278
}
188279

189280
#[cfg(feature = "std")]
190-
fn read_more(stream: &mut impl std::io::Read, buffer: &mut alloc::vec::Vec<u8>, hint: usize) -> Result<usize> {
281+
fn read_more(
282+
stream: &mut impl std::io::Read,
283+
buffer: &mut alloc::vec::Vec<u8>,
284+
hint: usize,
285+
total_read: &mut usize,
286+
max_module_bytes: Option<usize>,
287+
) -> Result<usize> {
191288
let len = buffer.len();
192289
// Size hints can come from untrusted section lengths.
193-
let increment = hint.clamp(1, 64 * 1024);
290+
let mut increment = hint.clamp(1, 64 * 1024);
291+
if let Some(limit) = max_module_bytes {
292+
let remaining = limit.saturating_sub(*total_read);
293+
if remaining == 0 {
294+
// A module exactly at the limit may still need an EOF probe.
295+
let mut extra = [0];
296+
let read_bytes = loop {
297+
match stream.read(&mut extra) {
298+
Ok(read_bytes) => break read_bytes,
299+
Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
300+
Err(e) => return Err(ParseError::Other(alloc::format!("Error reading from stream: {e}"))),
301+
}
302+
};
303+
if read_bytes != 0 {
304+
return Err(ParseError::LimitExceeded { kind: ParseLimitKind::ModuleBytes, limit });
305+
}
306+
return Ok(0);
307+
}
308+
increment = increment.min(remaining);
309+
}
194310
let new_len =
195311
len.checked_add(increment).ok_or_else(|| ParseError::Other("stream buffer is too large".into()))?;
196312
buffer
@@ -208,12 +324,15 @@ impl Parser {
208324
}
209325
};
210326
buffer.truncate(len + read_bytes);
327+
*total_read =
328+
total_read.checked_add(read_bytes).ok_or_else(|| ParseError::Other("stream byte count overflow".into()))?;
211329
Ok(read_bytes)
212330
}
213331

214332
/// Parse a [`Module`] from bytes
215333
pub fn parse_module_bytes(&self, wasm: impl AsRef<[u8]>) -> Result<Module> {
216334
let wasm = wasm.as_ref();
335+
check_parse_limit(ParseLimitKind::ModuleBytes, self.options.limits.max_module_bytes, wasm.len())?;
217336
let mut validator = self.validator();
218337
let mut reader = ModuleReader::default();
219338

@@ -225,7 +344,7 @@ impl Parser {
225344
wasmparser::Payload::CodeSectionEntry(function) => {
226345
reader.process_borrowed_code_section_entry(function, validator.as_mut(), &self.options)?;
227346
}
228-
payload => reader.process_payload(payload, validator.as_mut())?,
347+
payload => reader.process_payload(payload, validator.as_mut(), &self.options)?,
229348
}
230349
}
231350

@@ -254,6 +373,7 @@ impl Parser {
254373
let mut parser = wasmparser::Parser::new(0);
255374
let mut eof = false;
256375
let mut buffer_offset = 0;
376+
let mut total_read = 0;
257377

258378
loop {
259379
match parser.parse(&buffer[buffer_offset..], eof)? {
@@ -263,7 +383,13 @@ impl Parser {
263383
buffer.truncate(buffer.len() - buffer_offset);
264384
buffer_offset = 0;
265385
}
266-
let read_bytes = Self::read_more(&mut stream, &mut buffer, hint)?;
386+
let read_bytes = Self::read_more(
387+
&mut stream,
388+
&mut buffer,
389+
hint,
390+
&mut total_read,
391+
self.options.limits.max_module_bytes,
392+
)?;
267393
eof = read_bytes == 0;
268394
}
269395
wasmparser::Chunk::Parsed { consumed, payload } => {
@@ -287,7 +413,7 @@ impl Parser {
287413
reader.process_inline_code_section_entry(function, validator.as_mut(), &self.options)?;
288414
}
289415
payload => {
290-
reader.process_payload(payload, validator.as_mut())?;
416+
reader.process_payload(payload, validator.as_mut(), &self.options)?;
291417
}
292418
}
293419
buffer_offset += consumed;
@@ -296,7 +422,13 @@ impl Parser {
296422
if let Some((count, section_size)) = deferred_code_section {
297423
while buffer.len() - buffer_offset < section_size {
298424
let remaining = section_size - (buffer.len() - buffer_offset);
299-
let read_bytes = Self::read_more(&mut stream, &mut buffer, remaining)?;
425+
let read_bytes = Self::read_more(
426+
&mut stream,
427+
&mut buffer,
428+
remaining,
429+
&mut total_read,
430+
self.options.limits.max_module_bytes,
431+
)?;
300432
if read_bytes == 0 {
301433
return Err(ParseError::ParseError {
302434
message: "unexpected end-of-file".into(),
@@ -320,7 +452,13 @@ impl Parser {
320452
}
321453

322454
if !eof {
323-
let read_bytes = Self::read_more(&mut stream, &mut buffer, 1)?;
455+
let read_bytes = Self::read_more(
456+
&mut stream,
457+
&mut buffer,
458+
1,
459+
&mut total_read,
460+
self.options.limits.max_module_bytes,
461+
)?;
324462
eof = read_bytes == 0;
325463

326464
if !eof {

0 commit comments

Comments
 (0)