Wasmtime can panic when adding excessive fields to a `wasi:http/types.fields` instance
Description
Wasmtime is a runtime for WebAssembly. Prior to versions 24.0.6, 36.0.6, 4.0.04, 41.0.4, and 42.0.0, Wasmtime's implementation of the wasi:http/types.fields resource is susceptible to panics when too many fields are added to the set of headers. Wasmtime's implementation in the wasmtime-wasi-http crate is backed by a data structure which panics when it reaches excessive capacity and this condition was not handled gracefully in Wasmtime. Panicking in a WASI implementation is a Denial of Service vector for embedders and is treated as a security vulnerability in Wasmtime. Wasmtime 24.0.6, 36.0.6, 40.0.4, 41.0.4, and 42.0.0 patch this vulnerability and return a trap to the guest instead of panicking. There are no known workarounds at this time. Embedders are encouraged to update to a patched version of Wasmtime.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
wasmtimecrates.io | < 24.0.6 | 24.0.6 |
wasmtimecrates.io | >= 25.0.0, < 36.0.6 | 36.0.6 |
wasmtimecrates.io | >= 37.0.0, < 40.0.4 | 40.0.4 |
Affected products
1- Range: < 24.0.6
Patches
1301dc7162ccaFix two security advisories. (#12652)
51 files changed · +1864 −321
crates/cli-flags/src/lib.rs+12 −0 modified@@ -497,6 +497,18 @@ wasmtime_option_group! { pub keyvalue_in_memory_data: Vec<KeyValuePair>, /// Enable support for WASIp3 APIs. pub p3: Option<bool>, + /// Maximum resources the guest is allowed to create simultaneously. + pub max_resources: Option<usize>, + /// Fuel to use for all hostcalls to limit guest<->host data transfer. + pub hostcall_fuel: Option<usize>, + /// Maximum value, in bytes, for a wasi-random 0.2 + /// `get{,-insecure}-random-bytes` `len` parameter. Calls with a value + /// exceeding this limit will trap. + pub max_random_size: Option<u64>, + /// Maximum value, in bytes, for the contents of a wasi-http 0.2 + /// `fields` resource (aka `headers` and `trailers`). `fields` methods + /// which cause the contents to exceed this size limit will trap. + pub max_http_fields_size: Option<usize>, } enum Wasi {
crates/test-programs/artifacts/build.rs+10 −5 modified@@ -78,21 +78,26 @@ impl Artifacts { // Bucket, based on the name of the test, into a "kind" which // generates a `foreach_*` macro below. let kind = match test.name.as_str() { + s if s.starts_with("p1_cli_") + || s.starts_with("p2_cli_") + || s.starts_with("p3_cli_") => + { + "cli" + } s if s.starts_with("p1_") => "p1", s if s.starts_with("p2_http_") => "p2_http", - s if s.starts_with("p2_cli_") => "p2_cli", s if s.starts_with("p2_api_") => "p2_api", s if s.starts_with("p2_") => "p2", + s if s.starts_with("p3_http_") => "p3_http", + s if s.starts_with("p3_api_") => "p3_api", + s if s.starts_with("p3_") => "p3", s if s.starts_with("nn_") => "nn", s if s.starts_with("piped_") => "piped", s if s.starts_with("dwarf_") => "dwarf", s if s.starts_with("config_") => "config", s if s.starts_with("keyvalue_") => "keyvalue", s if s.starts_with("tls_") => "tls", s if s.starts_with("async_") => "async", - s if s.starts_with("p3_http_") => "p3_http", - s if s.starts_with("p3_api_") => "p3_api", - s if s.starts_with("p3_") => "p3", s if s.starts_with("fuzz_") => "fuzz", // If you're reading this because you hit this panic, either add // it to a test suite above or add a new "suite". The purpose of @@ -283,7 +288,7 @@ impl Artifacts { // Prevent stray files for now that we don't understand. Some(_) => panic!("unknown file extension on {path:?}"), - None => unreachable!(), + None => unreachable!("no extension in path {path:?}"), } } }
crates/test-programs/src/bin/p1_cli_hostcall_fuel.rs+118 −0 added@@ -0,0 +1,118 @@ +use std::ptr; + +fn main() { + big_poll(); + big_string(); + big_iovecs(); +} + +fn big_string() { + let mut s = String::new(); + for _ in 0..10_000 { + s.push_str("hello world"); + } + let dir_fd = test_programs::preview1::open_scratch_directory(".").unwrap(); + assert_eq!( + unsafe { wasip1::path_create_directory(dir_fd, &s) }, + Err(wasip1::ERRNO_NOMEM) + ); +} + +fn big_iovecs() { + let mut iovs = Vec::new(); + let mut ciovs = Vec::new(); + for _ in 0..10_000 { + iovs.push(wasip1::Iovec { + buf: ptr::null_mut(), + buf_len: 0, + }); + ciovs.push(wasip1::Ciovec { + buf: ptr::null(), + buf_len: 0, + }); + } + let dir_fd = test_programs::preview1::open_scratch_directory(".").unwrap(); + let fd = unsafe { + wasip1::path_open( + dir_fd, + 0, + "hi", + wasip1::OFLAGS_CREAT, + wasip1::RIGHTS_FD_WRITE | wasip1::RIGHTS_FD_READ, + 0, + 0, + ) + .unwrap() + }; + + unsafe { + assert_eq!(wasip1::fd_write(fd, &ciovs), Err(wasip1::ERRNO_NOMEM)); + assert_eq!(wasip1::fd_read(fd, &iovs), Err(wasip1::ERRNO_NOMEM)); + assert_eq!(wasip1::fd_pwrite(fd, &ciovs, 0), Err(wasip1::ERRNO_NOMEM)); + assert_eq!(wasip1::fd_pread(fd, &iovs, 0), Err(wasip1::ERRNO_NOMEM)); + } + + ciovs.truncate(1); + iovs.truncate(1); + iovs.push(wasip1::Iovec { + buf: ptr::null_mut(), + buf_len: 10_000, + }); + ciovs.push(wasip1::Ciovec { + buf: ptr::null(), + buf_len: 10_000, + }); + unsafe { + assert_eq!(wasip1::fd_write(fd, &ciovs), Err(wasip1::ERRNO_NOMEM)); + assert_eq!(wasip1::fd_read(fd, &iovs), Err(wasip1::ERRNO_NOMEM)); + assert_eq!(wasip1::fd_pwrite(fd, &ciovs, 0), Err(wasip1::ERRNO_NOMEM)); + assert_eq!(wasip1::fd_pread(fd, &iovs, 0), Err(wasip1::ERRNO_NOMEM)); + } +} + +fn big_poll() { + let mut huge_poll = Vec::new(); + let mut huge_events = Vec::new(); + for _ in 0..10_000 { + huge_poll.push(subscribe_timeout(0)); + huge_events.push(empty_event()); + } + let err = unsafe { + wasip1::poll_oneoff( + huge_poll.as_ptr(), + huge_events.as_mut_ptr(), + huge_poll.len(), + ) + .unwrap_err() + }; + assert_eq!(err, wasip1::ERRNO_NOMEM); + + fn subscribe_timeout(timeout: u64) -> wasip1::Subscription { + wasip1::Subscription { + userdata: 0, + u: wasip1::SubscriptionU { + tag: wasip1::EVENTTYPE_CLOCK.raw(), + u: wasip1::SubscriptionUU { + clock: wasip1::SubscriptionClock { + id: wasip1::CLOCKID_MONOTONIC, + timeout, + precision: 0, + flags: 0, + }, + }, + }, + } + } + + fn empty_event() -> wasip1::Event { + wasip1::Event { + error: wasip1::ERRNO_SUCCESS, + fd_readwrite: wasip1::EventFdReadwrite { + nbytes: 0, + flags: 0, + }, + type_: wasip1::EVENTTYPE_CLOCK, + userdata: 0, + } + } +}
crates/test-programs/src/bin/p1_sleep_quickly_but_lots.rs+98 −0 added@@ -0,0 +1,98 @@ +use std::process; +use test_programs::preview1::open_scratch_directory; + +fn main() { + let arg = std::env::args().nth(1).unwrap(); + let dir_fd = match open_scratch_directory(&arg) { + Ok(dir_fd) => dir_fd, + Err(err) => { + eprintln!("{err}"); + process::exit(1) + } + }; + + // Wait for just one timeout (maybe hitting a fast path) + let subs = [subscribe_timeout(0)]; + for _ in 0..1000 { + let mut events = [empty_event()]; + unsafe { + wasip1::poll_oneoff(subs.as_ptr(), events.as_mut_ptr(), 1).unwrap(); + } + } + + // Wait for two timeouts + let subs = [subscribe_timeout(0), subscribe_timeout(0)]; + for _ in 0..1000 { + let mut events = [empty_event(), empty_event()]; + unsafe { + wasip1::poll_oneoff(subs.as_ptr(), events.as_mut_ptr(), 2).unwrap(); + } + } + + let file_fd = unsafe { + wasip1::path_open( + dir_fd, + 0, + "hello.txt", + wasip1::OFLAGS_CREAT, + wasip1::RIGHTS_FD_WRITE | wasip1::RIGHTS_FD_READ, + 0, + 0, + ) + .expect("creating a file for writing") + }; + + // Wait for a timeout fd operations + let subs = [ + subscribe_timeout(0), + subscribe_fd(wasip1::EVENTTYPE_FD_READ, file_fd), + subscribe_fd(wasip1::EVENTTYPE_FD_WRITE, file_fd), + ]; + for _ in 0..1000 { + let mut events = [empty_event(), empty_event(), empty_event()]; + unsafe { + wasip1::poll_oneoff(subs.as_ptr(), events.as_mut_ptr(), 3).unwrap(); + } + } +} + +fn subscribe_timeout(timeout: u64) -> wasip1::Subscription { + wasip1::Subscription { + userdata: 0, + u: wasip1::SubscriptionU { + tag: wasip1::EVENTTYPE_CLOCK.raw(), + u: wasip1::SubscriptionUU { + clock: wasip1::SubscriptionClock { + id: wasip1::CLOCKID_MONOTONIC, + timeout, + precision: 0, + flags: 0, + }, + }, + }, + } +} + +fn subscribe_fd(ty: wasip1::Eventtype, file_descriptor: wasip1::Fd) -> wasip1::Subscription { + wasip1::Subscription { + userdata: 0, + u: wasip1::SubscriptionU { + tag: ty.raw(), + u: wasip1::SubscriptionUU { + fd_read: wasip1::SubscriptionFdReadwrite { file_descriptor }, + }, + }, + } +} + +fn empty_event() -> wasip1::Event { + wasip1::Event { + error: wasip1::ERRNO_SUCCESS, + fd_readwrite: wasip1::EventFdReadwrite { + nbytes: 0, + flags: 0, + }, + type_: wasip1::EVENTTYPE_CLOCK, + userdata: 0, + } +}
crates/test-programs/src/bin/p2_api_proxy.rs+65 −0 modified@@ -1,3 +1,4 @@ +use anyhow::{Context, Result}; use test_programs::wasi::http::types::{ Headers, IncomingRequest, Method, OutgoingBody, OutgoingResponse, ResponseOutparam, }; @@ -29,6 +30,16 @@ impl test_programs::proxy::exports::wasi::http::incoming_handler::Guest for T { return; } + (Method::Get, Some(p)) if p.starts_with("/modify_fields/") => { + let r = modify_fields_handler(request); + response_for(r, outparam); + return; + } + (Method::Get, Some(p)) if p.starts_with("/new_fields/") => { + let r = new_fields_handler(request); + response_for(r, outparam); + return; + } _ => {} } @@ -69,10 +80,64 @@ impl test_programs::proxy::exports::wasi::http::incoming_handler::Guest for T { } } +fn response_for(r: Result<()>, outparam: ResponseOutparam) { + let resp = OutgoingResponse::new(Headers::new()); + resp.set_status_code(if r.is_ok() { 200 } else { 500 }) + .unwrap(); + let body = resp.body().expect("outgoing response"); + ResponseOutparam::set(outparam, Ok(resp)); + let _ = body.write().and_then(|out| { + let _ = out.blocking_write_and_flush(format!("{r:?}").as_bytes()); + drop(out); + Ok(()) + }); + let _ = OutgoingBody::finish(body, None); +} + // Technically this should not be here for a proxy, but given the current // framework for tests it's required since this file is built as a `bin` fn main() {} fn test_filesystem() { assert!(std::fs::File::open(".").is_err()); } + +fn add_bytes_to_headers(headers: Headers, size: usize) { + if size == 0 { + return; + } else if size < 10 { + headers.append("k", &b"abcdefghi"[0..size - 1]).unwrap() + } else { + for chunk in 0..(size / 10) { + let k = format!("g{chunk:04}"); + let mut v = format!("h{chunk:04}"); + if chunk == 0 { + for _ in 0..(size % 10) { + v.push('#'); + } + } + headers.append(k.as_str(), v.as_bytes()).unwrap() + } + } +} + +fn modify_fields_handler(request: IncomingRequest) -> Result<()> { + let path = request.path_with_query().unwrap(); + let rest = path.trim_start_matches("/modify_fields/"); + let added_field_bytes: usize = rest + .parse() + .context("expect remainder of url to parse as number")?; + add_bytes_to_headers(request.headers().clone(), added_field_bytes); + + Ok(()) +} +fn new_fields_handler(request: IncomingRequest) -> Result<()> { + let path = request.path_with_query().unwrap(); + let rest = path.trim_start_matches("/new_fields/"); + let added_field_bytes: usize = rest + .parse() + .context("expect remainder of url to parse as number")?; + add_bytes_to_headers(Headers::new(), added_field_bytes); + + Ok(()) +}
crates/test-programs/src/bin/p2_cli_hostcall_fuel.rs+282 −0 added@@ -0,0 +1,282 @@ +use wasip2::filesystem::types::*; +use wasip2::sockets::network::*; + +fn main() { + match std::env::args().nth(1).as_deref() { + Some("poll") => poll(), + Some("read") => read(), + Some("write") => write(), + Some("write-stream") => write_stream(), + Some("write-stream-blocking") => write_stream_blocking(), + Some("mkdir") => mkdir(), + Some("resolve") => resolve(), + Some("udp-send-many") => udp_send_many(), + Some("udp-send-big") => udp_send_big(), + Some("write-zeroes") => write_zeroes(), + Some("write-stream-buffer-too-large") => write_stream_buffer_too_large(), + Some("write-zeroes-buffer-too-large") => write_zeroes_buffer_too_large(), + Some("read-file-big") => read_file_big(), + Some("read-tcp-big") => read_tcp_big(), + other => panic!("unknown arg {other:?}"), + } +} + +fn poll() { + let mut events = Vec::new(); + let sub = wasip2::clocks::monotonic_clock::subscribe_duration(0); + for _ in 0..5000 { + events.push(&sub); + } + + wasip2::io::poll::poll(&events); + unreachable!() +} + +fn preopen() -> Descriptor { + let mut dirs = wasip2::filesystem::preopens::get_directories(); + assert_eq!(dirs.len(), 1); + dirs.pop().unwrap().0 +} + +fn read() { + let f = preopen() + .open_at( + PathFlags::empty(), + "1mb", + OpenFlags::empty(), + DescriptorFlags::empty(), + ) + .unwrap(); + + // 0-length is ok + f.read(0, 0).unwrap(); + + // This isn't transferring data from the guest to the host, so this is ok. + f.read(1 << 20, 0).unwrap(); + + // Host shouldn't allocate based on what the guest asks for... + f.read(u64::MAX, 0).unwrap(); + + f.read_via_stream(0) + .unwrap() + .blocking_read(1 << 20) + .unwrap(); +} + +fn write() { + let f = preopen() + .open_at( + PathFlags::empty(), + "hi", + OpenFlags::CREATE, + DescriptorFlags::empty(), + ) + .unwrap(); + f.write(&[0; 5001], 0).unwrap(); + unreachable!() +} + +fn write_stream() { + preopen() + .open_at( + PathFlags::empty(), + "hi", + OpenFlags::CREATE, + DescriptorFlags::empty(), + ) + .unwrap() + .write_via_stream(0) + .unwrap() + .write(&[0; 5001]) + .unwrap(); + unreachable!() +} + +fn write_stream_blocking() { + preopen() + .open_at( + PathFlags::empty(), + "hi", + OpenFlags::CREATE, + DescriptorFlags::empty(), + ) + .unwrap() + .write_via_stream(0) + .unwrap() + .blocking_write_and_flush(&[0; 5001]) + .unwrap(); + unreachable!() +} + +fn mkdir() { + let mut name = String::new(); + for _ in 0..5001 { + name.push_str("a"); + } + preopen().create_directory_at(&name).unwrap(); + unreachable!() +} + +fn resolve() { + let network = wasip2::sockets::instance_network::instance_network(); + let mut name = String::new(); + for _ in 0..5001 { + name.push_str("a"); + } + wasip2::sockets::ip_name_lookup::resolve_addresses(&network, &name).unwrap(); + unreachable!(); +} + +fn udp_socket() -> wasip2::sockets::udp::UdpSocket { + let socket = + wasip2::sockets::udp_create_socket::create_udp_socket(IpAddressFamily::Ipv4).unwrap(); + let network = wasip2::sockets::instance_network::instance_network(); + + socket + .start_bind( + &network, + IpSocketAddress::Ipv4(Ipv4SocketAddress { + address: (127, 0, 0, 1), + port: 0, + }), + ) + .unwrap(); + socket.finish_bind().unwrap(); + socket +} + +fn udp_send_many() { + let socket = udp_socket(); + let (_incoming, outgoing) = socket.stream(None).unwrap(); + let mut dgrams = Vec::new(); + + for _ in 0..5000 { + dgrams.push(wasip2::sockets::udp::OutgoingDatagram { + data: Vec::new(), + remote_address: None, + }); + } + + outgoing.send(&dgrams).unwrap(); + unreachable!() +} + +fn udp_send_big() { + let socket = udp_socket(); + let (_incoming, outgoing) = socket.stream(None).unwrap(); + let mut dgrams = Vec::new(); + + for _ in 0..2 { + dgrams.push(wasip2::sockets::udp::OutgoingDatagram { + data: vec![0; 2500], + remote_address: None, + }); + } + + outgoing.send(&dgrams).unwrap(); + unreachable!() +} + +fn write_zeroes() { + preopen() + .open_at( + PathFlags::empty(), + "hi", + OpenFlags::CREATE, + DescriptorFlags::empty(), + ) + .unwrap() + .write_via_stream(0) + .unwrap() + .write_zeroes(u64::MAX) + .unwrap(); + unreachable!() +} + +fn write_stream_buffer_too_large() { + preopen() + .open_at( + PathFlags::empty(), + "hi", + OpenFlags::CREATE, + DescriptorFlags::empty(), + ) + .unwrap() + .write_via_stream(0) + .unwrap() + .blocking_write_and_flush(&[0; 5000]) + .unwrap(); + unreachable!() +} + +fn write_zeroes_buffer_too_large() { + preopen() + .open_at( + PathFlags::empty(), + "hi", + OpenFlags::CREATE, + DescriptorFlags::empty(), + ) + .unwrap() + .write_via_stream(0) + .unwrap() + .blocking_write_zeroes_and_flush(5000) + .unwrap(); + unreachable!() +} + +fn read_file_big() { + preopen() + .open_at( + PathFlags::empty(), + "1mb", + OpenFlags::empty(), + DescriptorFlags::empty(), + ) + .unwrap() + .read_via_stream(0) + .unwrap() + .blocking_read(u64::MAX) + .unwrap(); +} + +fn read_tcp_big() { + let server = + wasip2::sockets::tcp_create_socket::create_tcp_socket(IpAddressFamily::Ipv4).unwrap(); + let client = + wasip2::sockets::tcp_create_socket::create_tcp_socket(IpAddressFamily::Ipv4).unwrap(); + + server + .start_bind( + &wasip2::sockets::instance_network::instance_network(), + IpSocketAddress::Ipv4(Ipv4SocketAddress { + address: (127, 0, 0, 1), + port: 0, + }), + ) + .unwrap(); + server.finish_bind().unwrap(); + server.start_listen().unwrap(); + server.finish_listen().unwrap(); + + client + .start_connect( + &wasip2::sockets::instance_network::instance_network(), + server.local_address().unwrap(), + ) + .unwrap(); + client.subscribe().block(); + let (input, _output) = client.finish_connect().unwrap(); + + { + server.subscribe().block(); + let (socket, input, output) = server.accept().unwrap(); + drop((input, output)); + drop(socket); + } + + match input.blocking_read(u64::MAX) { + Err(wasip2::io::streams::StreamError::Closed) => {} + other => panic!("unexpected result: {other:?}"), + } +}
crates/test-programs/src/bin/p2_cli_http_headers.rs+33 −0 added@@ -0,0 +1,33 @@ +fn main() { + let fields = wasip2::http::types::Fields::new(); + + match std::env::args().nth(1).as_deref() { + Some("append") => { + for i in 0.. { + if fields.append(&format!("a{i}"), b"a").is_err() { + break; + } + } + } + Some("append-empty") => { + for i in 0.. { + if fields.append(&format!("a{i}"), b"").is_err() { + break; + } + } + } + Some("append-same") => loop { + if fields.append("a", b"b").is_err() { + break; + } + }, + Some("append-same-empty") => loop { + if fields.append("a", b"").is_err() { + break; + } + }, + other => panic!("unknown test {other:?}"), + } + + unreachable!(); +}
crates/test-programs/src/bin/p2_cli_many_resources.rs+7 −0 added@@ -0,0 +1,7 @@ +use test_programs::wasi::clocks::monotonic_clock::subscribe_duration; + +fn main() { + loop { + std::mem::forget(subscribe_duration(1_000_000)); + } +}
crates/test-programs/src/bin/p2_cli_max_resources.rs+6 −0 added@@ -0,0 +1,6 @@ +fn main() { + let mut buf = Vec::new(); + for _ in 0..100 { + buf.push(wasip2::clocks::monotonic_clock::subscribe_duration(0)); + } +}
crates/test-programs/src/bin/p2_random.rs+20 −7 modified@@ -1,16 +1,23 @@ +use std::mem::MaybeUninit; use test_programs::wasi::random; fn main() { - let mut bytes = [0_u8; 256]; + let p1_random_size: usize = std::env::var("TEST_P1_RANDOM_LEN") + .map(|v| v.parse().expect("TEST_P1_RANDOM_LEN should be a usize")) + .unwrap_or(256); + let mut bytes: Box<[MaybeUninit<u8>]> = Box::new_uninit_slice(p1_random_size); unsafe { - wasip1::random_get(bytes.as_mut_ptr(), bytes.len()).unwrap(); + wasip1::random_get(bytes.as_mut_ptr() as *mut u8, bytes.len()).unwrap(); } - assert!(bytes.iter().any(|x| *x != 0)); + assert!(bytes.iter().any(|x| unsafe { x.assume_init() } != 0)); + let p2_random_size: u64 = std::env::var("TEST_P2_RANDOM_LEN") + .map(|v| v.parse().expect("TEST_P2_RANDOM_LEN should be a u64")) + .unwrap_or(256); // Acquired random bytes should be of the expected length. - let array = random::random::get_random_bytes(100); - assert_eq!(array.len(), 100); + let array = random::random::get_random_bytes(p2_random_size); + assert_eq!(array.len(), p2_random_size as usize); // It shouldn't take 100+ tries to get a nonzero random integer. for i in 0.. { @@ -27,9 +34,15 @@ fn main() { assert_eq!(a1, a2); assert_eq!(b1, b2); + let p2_insecure_random_size: u64 = std::env::var("TEST_P2_INSECURE_RANDOM_LEN") + .map(|v| { + v.parse() + .expect("TEST_P2_INSECURE_RANDOM_LEN should be a u64") + }) + .unwrap_or(256); // Acquired random bytes should be of the expected length. - let array = random::insecure::get_insecure_random_bytes(100); - assert_eq!(array.len(), 100); + let array = random::insecure::get_insecure_random_bytes(p2_insecure_random_size); + assert_eq!(array.len(), p2_insecure_random_size as usize); // It shouldn't take 100+ tries to get a nonzero random integer. for i in 0.. {
crates/wasi-common/Cargo.toml+1 −1 modified@@ -27,7 +27,7 @@ async-trait = { workspace = true } wasmtime-environ = { workspace = true } # Optional, enabled by wasmtime feature: -wasmtime = { workspace = true, optional = true, features = ['runtime'] } +wasmtime = { workspace = true, optional = true, features = ['runtime', 'component-model'] } # Optional, enabled by sync feature: cap-fs-ext = { workspace = true, optional = true } cap-time-ext = { workspace = true, optional = true }
crates/wasi-common/src/tokio/sched/unix.rs+13 −6 modified@@ -5,8 +5,8 @@ use crate::{ subscription::{RwEventFlags, Subscription}, }, }; -use std::future::Future; -use std::pin::Pin; +use std::future::{self, Future}; +use std::pin::{Pin, pin}; use std::task::{Context, Poll as FPoll}; struct FirstReady<'a, T>(Vec<Pin<Box<dyn Future<Output = T> + Send + 'a>>>); @@ -83,13 +83,20 @@ pub async fn poll_oneoff<'a>(poll: &mut Poll<'a>) -> Result<(), Error> { Subscription::MonotonicClock { .. } => unreachable!(), } } - if let Some(Some(remaining_duration)) = duration { - match tokio::time::timeout(remaining_duration, futures).await { + match duration { + Some(Some(remaining)) => match tokio::time::timeout(remaining, futures).await { Ok(r) => r?, Err(_deadline_elapsed) => {} + }, + Some(None) => { + let mut futures = pin!(futures); + future::poll_fn(|cx| match futures.as_mut().poll(cx) { + FPoll::Ready(e) => FPoll::Ready(e), + FPoll::Pending => FPoll::Ready(Ok(())), + }) + .await? } - } else { - futures.await?; + None => futures.await?, } Ok(())
crates/wasi-common/tests/all/async_.rs+4 −6 modified@@ -287,9 +287,7 @@ async fn p1_file_write() { async fn p1_path_open_lots() { run(P1_PATH_OPEN_LOTS, true).await.unwrap() } - -#[expect( - dead_code, - reason = "tested in the wasi-cli crate, satisfying foreach_api! macro" -)] -fn p1_cli_much_stdout() {} +#[test_log::test(tokio::test(flavor = "multi_thread"))] +async fn p1_sleep_quickly_but_lots() { + run(P1_SLEEP_QUICKLY_BUT_LOTS, true).await.unwrap() +}
crates/wasi-common/tests/all/sync.rs+4 −6 modified@@ -281,9 +281,7 @@ fn p1_file_write() { fn p1_path_open_lots() { run(P1_PATH_OPEN_LOTS, true).unwrap() } - -#[expect( - dead_code, - reason = "tested in the wasi-cli crate, satisfying foreach_api! macro" -)] -fn p1_cli_much_stdout() {} +#[test_log::test] +fn p1_sleep_quickly_but_lots() { + run(P1_SLEEP_QUICKLY_BUT_LOTS, true).unwrap() +}
crates/wasi-http/src/body.rs+17 −9 modified@@ -1,6 +1,7 @@ //! Implementation of the `wasi:http/types` interface's various body types. -use crate::{bindings::http::types, types::FieldMap}; +use crate::bindings::http::types; +use crate::types::FieldMap; use bytes::Bytes; use http_body::{Body, Frame}; use http_body_util::BodyExt; @@ -24,6 +25,7 @@ pub type HyperOutgoingBody = UnsyncBoxBody<Bytes, types::ErrorCode>; #[derive(Debug)] pub struct HostIncomingBody { body: IncomingBodyState, + field_size_limit: usize, /// An optional worker task to keep alive while this body is being read. /// This ensures that if the parent of this body is dropped before the body /// then the backing data behind this worker is kept alive. @@ -32,10 +34,15 @@ pub struct HostIncomingBody { impl HostIncomingBody { /// Create a new `HostIncomingBody` with the given `body` and a per-frame timeout - pub fn new(body: HyperIncomingBody, between_bytes_timeout: Duration) -> HostIncomingBody { + pub fn new( + body: HyperIncomingBody, + between_bytes_timeout: Duration, + field_size_limit: usize, + ) -> HostIncomingBody { let body = BodyWithTimeout::new(body, between_bytes_timeout); HostIncomingBody { body: IncomingBodyState::Start(body), + field_size_limit, worker: None, } } @@ -155,7 +162,7 @@ enum StreamEnd { /// Body was completely read and trailers were read. Here are the trailers. /// Note that `None` means that the body finished without trailers. - Trailers(Option<FieldMap>), + Trailers(Option<http::HeaderMap>), } /// The concrete type behind the `wasi:io/streams.input-stream` resource returned @@ -339,14 +346,15 @@ impl Pollable for HostFutureTrailers { match rx.await { // Trailers were read for us and here they are, so store the // result. - Ok(StreamEnd::Trailers(t)) => *self = Self::Done(Ok(t)), - + Ok(StreamEnd::Trailers(Some(t))) => { + *self = Self::Done(Ok(Some(FieldMap::new(t, body.field_size_limit)))); + } // The body wasn't fully read and was dropped before trailers // were reached. It's up to us now to complete the body. Ok(StreamEnd::Remaining(b)) => body.body = IncomingBodyState::Start(b), // This means there were no trailers present. - Err(_) => { + Ok(StreamEnd::Trailers(None)) | Err(_) => { *self = HostFutureTrailers::Done(Ok(None)); } } @@ -370,8 +378,8 @@ impl Pollable for HostFutureTrailers { Some(Ok(frame)) => { // If this frame is a data frame ignore it as we're only // interested in trailers. - if let Ok(headers) = frame.into_trailers() { - break Ok(Some(headers)); + if let Ok(header_map) = frame.into_trailers() { + break Ok(Some(FieldMap::new(header_map, body.field_size_limit))); } } } @@ -521,7 +529,7 @@ impl HostOutgoingBody { } let message = if let Some(ts) = trailers { - FinishMessage::Trailers(ts) + FinishMessage::Trailers(ts.into_inner()) } else { FinishMessage::Finished };
crates/wasi-http/src/http_impl.rs+1 −1 modified@@ -79,7 +79,7 @@ where builder = builder.uri(uri.build().map_err(http_request_error)?); - for (k, v) in req.headers.iter() { + for (k, v) in req.headers.as_ref().iter() { builder = builder.header(k, v); }
crates/wasi-http/src/types_impl.rs+48 −29 modified@@ -3,12 +3,14 @@ use crate::bindings::http::types::{self, Headers, Method, Scheme, StatusCode, Trailers}; use crate::body::{HostFutureTrailers, HostIncomingBody, HostOutgoingBody, StreamContext}; use crate::types::{ - FieldMap, HostFields, HostFutureIncomingResponse, HostIncomingRequest, HostIncomingResponse, - HostOutgoingRequest, HostOutgoingResponse, HostResponseOutparam, remove_forbidden_headers, + FieldMap, FieldSizeLimitError, HostFields, HostFutureIncomingResponse, HostIncomingRequest, + HostIncomingResponse, HostOutgoingRequest, HostOutgoingResponse, HostResponseOutparam, + remove_forbidden_headers, }; use crate::{HttpError, HttpResult, WasiHttpImpl, WasiHttpView, get_content_length}; use std::any::Any; use std::str::FromStr; +use wasmtime::bail; use wasmtime::component::{Resource, ResourceTable, ResourceTableError}; use wasmtime::{error::Context as _, format_err}; use wasmtime_wasi::p2::{DynInputStream, DynOutputStream, DynPollable}; @@ -80,10 +82,11 @@ where T: WasiHttpView, { fn new(&mut self) -> wasmtime::Result<Resource<HostFields>> { + let limit = self.ctx().field_size_limit; let id = self .table() .push(HostFields::Owned { - fields: hyper::HeaderMap::new(), + fields: FieldMap::empty(limit), }) .context("[new_fields] pushing fields")?; @@ -114,6 +117,14 @@ where fields.append(header, value); } + let size = FieldMap::content_size(&fields); + if size > self.ctx().field_size_limit { + bail!(FieldSizeLimitError { + size, + limit: self.ctx().field_size_limit, + }); + } + let fields = FieldMap::new(fields, self.ctx().field_size_limit); let id = self .table() .push(HostFields::Owned { fields }) @@ -141,11 +152,12 @@ where Err(_) => return Ok(vec![]), }; - if !fields.contains_key(&header) { + if !fields.as_ref().contains_key(&header) { return Ok(vec![]); } let res = fields + .as_ref() .get_all(&header) .into_iter() .map(|val| val.as_bytes().to_owned()) @@ -157,7 +169,7 @@ where let fields = get_fields(self.table(), &fields).context("[fields_get] getting fields")?; match hyper::header::HeaderName::from_bytes(name.as_bytes()) { - Ok(header) => Ok(fields.contains_key(&header)), + Ok(header) => Ok(fields.as_ref().contains_key(&header)), Err(_) => Ok(false), } } @@ -185,14 +197,18 @@ where } } - Ok(get_fields_mut(self.table(), &fields) + match get_fields_mut(self.table(), &fields) .context("[fields_set] getting mutable fields")? - .map(|fields| { - fields.remove(&header); + { + Ok(fields) => { + fields.remove_all(&header); for value in values { - fields.append(&header, value); + fields.append(&header, value)?; } - })) + Ok(Ok(())) + } + Err(e) => Ok(Err(e)), + } } fn delete( @@ -210,7 +226,7 @@ where } Ok(get_fields_mut(self.table(), &fields)?.map(|fields| { - fields.remove(header); + fields.remove_all(&header); })) } @@ -234,18 +250,23 @@ where Err(_) => return Ok(Err(types::HeaderError::InvalidSyntax)), }; - Ok(get_fields_mut(self.table(), &fields) + match get_fields_mut(self.table(), &fields) .context("[fields_append] getting mutable fields")? - .map(|fields| { - fields.append(header, value); - })) + { + Ok(fields) => { + fields.append(&header, value)?; + Ok(Ok(())) + } + Err(e) => Ok(Err(e)), + } } fn entries( &mut self, fields: Resource<HostFields>, ) -> wasmtime::Result<Vec<(String, Vec<u8>)>> { Ok(get_fields(self.table(), &fields)? + .as_ref() .iter() .map(|(name, value)| (name.as_str().to_owned(), value.as_bytes().to_owned())) .collect()) @@ -270,7 +291,7 @@ where T: WasiHttpView, { fn method(&mut self, id: Resource<HostIncomingRequest>) -> wasmtime::Result<Method> { - let method = self.table().get(&id)?.parts.method.clone(); + let method = self.table().get(&id)?.method.clone(); Ok(method.into()) } fn path_with_query( @@ -279,7 +300,6 @@ where ) -> wasmtime::Result<Option<String>> { let req = self.table().get(&id)?; Ok(req - .parts .uri .path_and_query() .map(|path_and_query| path_and_query.as_str().to_owned())) @@ -300,11 +320,7 @@ where let _ = self.table().get(&id)?; fn get_fields(elem: &mut dyn Any) -> &mut FieldMap { - &mut elem - .downcast_mut::<HostIncomingRequest>() - .unwrap() - .parts - .headers + &mut elem.downcast_mut::<HostIncomingRequest>().unwrap().headers } let headers = self.table().push_child( @@ -376,7 +392,7 @@ where return Ok(Err(())); } - let size = match get_content_length(&req.headers) { + let size = match get_content_length(req.headers.as_ref()) { Ok(size) => size, Err(..) => return Ok(Err(())), }; @@ -653,7 +669,7 @@ where { let trailers = self.table().get_mut(&id)?; match trailers { - HostFutureTrailers::Waiting(_) => return Ok(None), + HostFutureTrailers::Waiting { .. } => return Ok(None), HostFutureTrailers::Consumed => return Ok(Some(Err(()))), HostFutureTrailers::Done(_) => {} }; @@ -742,7 +758,7 @@ where return Ok(Err(())); } - let size = match get_content_length(&resp.headers) { + let size = match get_content_length(resp.headers.as_ref()) { Ok(size) => size, Err(..) => return Ok(Err(())), }; @@ -821,6 +837,7 @@ where ) -> wasmtime::Result< Option<Result<Result<Resource<HostIncomingResponse>, types::ErrorCode>, ()>>, > { + let field_size_limit = self.ctx().field_size_limit; let resp = self.table().get_mut(&id)?; match resp { @@ -841,15 +858,17 @@ where Ok(Err(e)) => return Ok(Some(Ok(Err(e)))), }; - let (mut parts, body) = resp.resp.into_parts(); + let (parts, body) = resp.resp.into_parts(); - remove_forbidden_headers(self, &mut parts.headers); + let mut headers = FieldMap::new(parts.headers, field_size_limit); + remove_forbidden_headers(self, &mut headers); let resp = self.table().push(HostIncomingResponse { status: parts.status.as_u16(), - headers: parts.headers, + headers, body: Some({ - let mut body = HostIncomingBody::new(body, resp.between_bytes_timeout); + let mut body = + HostIncomingBody::new(body, resp.between_bytes_timeout, field_size_limit); if let Some(worker) = resp.worker { body.retain_worker(worker); }
crates/wasi-http/src/types.rs+172 −19 modified@@ -6,13 +6,14 @@ use crate::{ body::{HostIncomingBody, HyperIncomingBody, HyperOutgoingBody}, }; use bytes::Bytes; +use http::header::{HeaderMap, HeaderName, HeaderValue}; use http_body_util::BodyExt; use hyper::body::Body; -use hyper::header::HeaderName; use std::any::Any; +use std::fmt; use std::time::Duration; -use wasmtime::bail; use wasmtime::component::{Resource, ResourceTable}; +use wasmtime::{Result, bail}; use wasmtime_wasi::p2::Pollable; use wasmtime_wasi::runtime::AbortOnDropJoinHandle; @@ -24,16 +25,39 @@ use { tokio::time::timeout, }; +/// Default maximum size for the contents of a fields resource. +/// +/// Typically, HTTP proxies limit headers to 8k. This number is higher than that +/// because it not only includes the wire-size of headers but it additionally +/// includes factors for the in-memory representation of `HeaderMap`. This is in +/// theory high enough that no one runs into it but low enough such that a +/// completely full `HeaderMap` doesn't break the bank in terms of memory +/// consumption. +const DEFAULT_FIELD_SIZE_LIMIT: usize = 128 * 1024; + /// Capture the state necessary for use in the wasi-http API implementation. #[derive(Debug)] pub struct WasiHttpCtx { - _priv: (), + pub(crate) field_size_limit: usize, } impl WasiHttpCtx { /// Create a new context. pub fn new() -> Self { - Self { _priv: () } + Self { + field_size_limit: DEFAULT_FIELD_SIZE_LIMIT, + } + } + + /// Set the maximum size for any fields resources created by this context. + /// + /// The limit specified here is roughly a byte limit for the size of the + /// in-memory representation of headers. This means that the limit needs to + /// be larger than the literal representation of headers on the wire to + /// account for in-memory Rust-side data structures representing the header + /// names/values/etc. + pub fn set_field_size_limit(&mut self, limit: usize) { + self.field_size_limit = limit; } } @@ -96,14 +120,17 @@ pub trait WasiHttpView { B::Error: Into<ErrorCode>, Self: Sized, { + let field_size_limit = self.ctx().field_size_limit; let (parts, body) = req.into_parts(); let body = body.map_err(Into::into).boxed_unsync(); let body = HostIncomingBody::new( body, // TODO: this needs to be plumbed through std::time::Duration::from_millis(600 * 1000), + field_size_limit, ); - let incoming_req = HostIncomingRequest::new(self, parts, scheme, Some(body))?; + let incoming_req = + HostIncomingRequest::new(self, parts, scheme, Some(body), field_size_limit)?; Ok(self.table().push(incoming_req)?) } @@ -306,12 +333,9 @@ pub const DEFAULT_FORBIDDEN_HEADERS: [http::header::HeaderName; 9] = [ HeaderName::from_static("http2-settings"), ]; -/// Removes forbidden headers from a [`hyper::HeaderMap`]. -pub(crate) fn remove_forbidden_headers( - view: &mut dyn WasiHttpView, - headers: &mut hyper::HeaderMap, -) { - let forbidden_keys = Vec::from_iter(headers.keys().filter_map(|name| { +/// Removes forbidden headers from a [`FieldMap`]. +pub(crate) fn remove_forbidden_headers(view: &mut dyn WasiHttpView, headers: &mut FieldMap) { + let forbidden_keys = Vec::from_iter(headers.as_ref().keys().filter_map(|name| { if view.is_forbidden_header(name) { Some(name.clone()) } else { @@ -320,7 +344,7 @@ pub(crate) fn remove_forbidden_headers( })); for name in forbidden_keys { - headers.remove(name); + headers.remove_all(&name); } } @@ -534,7 +558,9 @@ impl TryInto<http::Method> for types::Method { /// The concrete type behind a `wasi:http/types.incoming-request` resource. #[derive(Debug)] pub struct HostIncomingRequest { - pub(crate) parts: http::request::Parts, + pub(crate) method: http::method::Method, + pub(crate) uri: http::uri::Uri, + pub(crate) headers: FieldMap, pub(crate) scheme: Scheme, pub(crate) authority: String, /// The body of the incoming request. @@ -545,9 +571,10 @@ impl HostIncomingRequest { /// Create a new `HostIncomingRequest`. pub fn new( view: &mut dyn WasiHttpView, - mut parts: http::request::Parts, + parts: http::request::Parts, scheme: Scheme, body: Option<HostIncomingBody>, + field_size_limit: usize, ) -> wasmtime::Result<Self> { let authority = match parts.uri.authority() { Some(authority) => authority.to_string(), @@ -557,9 +584,13 @@ impl HostIncomingRequest { }, }; - remove_forbidden_headers(view, &mut parts.headers); + let mut headers = FieldMap::new(parts.headers, field_size_limit); + remove_forbidden_headers(view, &mut headers); + Ok(Self { - parts, + method: parts.method, + uri: parts.uri, + headers, authority, scheme, body, @@ -594,7 +625,7 @@ impl TryFrom<HostOutgoingResponse> for hyper::Response<HyperOutgoingBody> { let mut builder = hyper::Response::builder().status(resp.status); - *builder.headers_mut().unwrap() = resp.headers; + *builder.headers_mut().unwrap() = resp.headers.map; match resp.body { Some(body) => builder.body(body), @@ -668,8 +699,130 @@ pub enum HostFields { }, } -/// An owned version of `HostFields` -pub type FieldMap = hyper::HeaderMap; +/// An owned version of `HostFields`. A wrapper on http `HeaderMap` that +/// keeps a running tally of memory consumed by header names and values. +#[derive(Debug, Clone)] +pub struct FieldMap { + map: HeaderMap, + limit: usize, + size: usize, +} + +/// Error given when a `FieldMap` has exceeded the size limit. +#[derive(Debug)] +pub struct FieldSizeLimitError { + /// The erroring `FieldMap` operation would require this content size + pub(crate) size: usize, + /// The limit set on `FieldMap` content size + pub(crate) limit: usize, +} +impl fmt::Display for FieldSizeLimitError { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "Field size limit {} exceeded: {}", self.limit, self.size) + } +} +impl std::error::Error for FieldSizeLimitError {} + +impl FieldMap { + /// Construct a `FieldMap` from a `HeaderMap` and a size limit. + /// + /// Construction with a `HeaderMap` which exceeds the size limit is + /// allowed, but subsequent operations to expand the resource use will + /// fail. + pub fn new(map: HeaderMap, limit: usize) -> Self { + let size = Self::content_size(&map); + Self { map, size, limit } + } + /// Construct an empty `FieldMap` + pub fn empty(limit: usize) -> Self { + Self { + map: HeaderMap::new(), + size: 0, + limit, + } + } + /// Get the `HeaderMap` out of the `FieldMap` + pub fn into_inner(self) -> HeaderMap { + self.map + } + /// Calculate the content size of a `HeaderMap`. This is a sum of the size + /// of all of the keys and all of the values. + pub(crate) fn content_size(map: &HeaderMap) -> usize { + let mut sum = 0; + for key in map.keys() { + sum += header_name_size(key); + } + for value in map.values() { + sum += header_value_size(value); + } + sum + } + /// Remove all values associated with a key in a map. + /// + /// Returns an empty list if the key is not already present within the map. + pub fn remove_all(&mut self, key: &HeaderName) -> Vec<HeaderValue> { + use http::header::Entry; + match self.map.try_entry(key) { + Ok(Entry::Vacant { .. }) | Err(_) => Vec::new(), + Ok(Entry::Occupied(e)) => { + let (name, value_drain) = e.remove_entry_mult(); + let mut removed = header_name_size(&name); + let values = value_drain.collect::<Vec<_>>(); + for v in values.iter() { + removed += header_value_size(v); + } + self.size -= removed; + values + } + } + } + /// Add a value associated with a key to the map. + /// + /// If `key` is already present within the map then `value` is appended to + /// the list of values it already has. + pub fn append(&mut self, key: &HeaderName, value: HeaderValue) -> Result<bool> { + let key_size = header_name_size(key); + let val_size = header_value_size(&value); + let new_size = if !self.map.contains_key(key) { + self.size + key_size + val_size + } else { + self.size + val_size + }; + if new_size > self.limit { + bail!(FieldSizeLimitError { + limit: self.limit, + size: new_size + }) + } + self.size = new_size; + Ok(self.map.try_append(key, value)?) + } +} + +/// Returns the size, in accounting cost, to consider for `name`. +/// +/// This includes both the byte length of the `name` itself as well as the size +/// of the data structure itself as it'll reside within a `HeaderMap`. +fn header_name_size(name: &HeaderName) -> usize { + name.as_str().len() + size_of::<HeaderName>() +} + +/// Same as `header_name_size`, but for values. +/// +/// This notably includes the size of `HeaderValue` itself to ensure that all +/// headers have a nonzero size as otherwise this would never limit addition of +/// an empty header value. +fn header_value_size(value: &HeaderValue) -> usize { + value.len() + size_of::<HeaderValue>() +} + +// We impl AsRef, but not AsMut, because any modifications of the +// underlying HeaderMap must account for changes in size +impl AsRef<HeaderMap> for FieldMap { + fn as_ref(&self) -> &HeaderMap { + &self.map + } +} /// A handle to a future incoming response. pub type FutureIncomingResponseHandle =
crates/wasi-http/tests/all/p2.rs+125 −11 modified@@ -124,6 +124,7 @@ async fn run_wasi_http( send_request: Option<RequestSender>, rejected_authority: Option<String>, early_drop: bool, + field_size_limit: Option<usize>, ) -> wasmtime::Result<Result<hyper::Response<Collected<Bytes>>, ErrorCode>> { let stdout = MemoryOutputPipe::new(4096); let stderr = MemoryOutputPipe::new(4096); @@ -132,15 +133,19 @@ async fn run_wasi_http( let mut config = Config::new(); config.wasm_backtrace_details(wasmtime::WasmBacktraceDetails::Enable); config.wasm_component_model(true); - let engine = Engine::new(&config)?; - let component = Component::from_file(&engine, component_filename)?; + let engine = Engine::new(&config).context("creating engine")?; + let component = + Component::from_file(&engine, component_filename).context("loading component")?; // Create our wasi context. let mut builder = WasiCtx::builder(); builder.stdout(stdout.clone()); builder.stderr(stderr.clone()); let wasi = builder.build(); - let http = WasiHttpCtx::new(); + let mut http = WasiHttpCtx::new(); + if let Some(limit) = field_size_limit { + http.set_field_size_limit(limit); + } let ctx = Ctx { table, wasi, @@ -153,15 +158,22 @@ async fn run_wasi_http( let mut store = Store::new(&engine, ctx); let mut linker = Linker::new(&engine); - wasmtime_wasi_http::add_to_linker_async(&mut linker)?; + wasmtime_wasi_http::add_to_linker_async(&mut linker).context("add crate to linker")?; let proxy = wasmtime_wasi_http::bindings::Proxy::instantiate_async(&mut store, &component, &linker) - .await?; + .await + .context("instantiate proxy")?; - let req = store.data_mut().new_incoming_request(Scheme::Http, req)?; + let req = store + .data_mut() + .new_incoming_request(Scheme::Http, req) + .context("new incoming request")?; let (sender, receiver) = tokio::sync::oneshot::channel(); - let out = store.data_mut().new_response_outparam(sender)?; + let out = store + .data_mut() + .new_response_outparam(sender) + .context("new response outparam")?; let receiver = if early_drop { // Drop the receiver early, emulating a host event like @@ -176,7 +188,8 @@ async fn run_wasi_http( proxy .wasi_http_incoming_handler() .call_handle(&mut store, req, out) - .await?; + .await + .context("calling incoming handler")?; Ok::<_, wasmtime::Error>(()) }); @@ -185,7 +198,7 @@ async fn run_wasi_http( let resp = match r.await { Ok(Ok(resp)) => { let (parts, body) = resp.into_parts(); - let collected = BodyExt::collect(body).await?; + let collected = BodyExt::collect(body).await.context("collecting body")?; Some(Ok(hyper::Response::from_parts(parts, collected))) } Ok(Err(e)) => Some(Err(e)), @@ -197,9 +210,9 @@ async fn run_wasi_http( // Now that the response has been processed, we can wait on the wasm to // finish without deadlocking. - handle.await.context("Component execution")?; + handle.await.context("awaiting execution")?; - Ok(resp.expect("wasm never called set-response-outparam")) + Ok(resp.context("wasm never called set-response-outparam")?) } else { handle.await.context("Component execution")?; Ok(Err(ErrorCode::HttpResponseTimeout)) @@ -219,6 +232,7 @@ async fn wasi_http_proxy_tests() -> wasmtime::Result<()> { None, None, false, + None, ) .await?; @@ -351,6 +365,7 @@ async fn do_wasi_http_hash_all(override_send_request: bool) -> Result<()> { send_request, None, false, + None, ) .await??; @@ -399,6 +414,7 @@ async fn wasi_http_hash_all_with_reject() -> Result<()> { None, Some("forbidden.com".to_string()), false, + None, ) .await??; @@ -519,6 +535,7 @@ async fn do_wasi_http_echo(uri: &str, url_header: Option<&str>) -> Result<()> { None, None, false, + None, ) .await??; @@ -551,6 +568,7 @@ async fn wasi_http_without_port() -> Result<()> { None, None, false, + None, ) .await??; @@ -574,6 +592,7 @@ async fn wasi_http_no_trap_on_early_drop() -> Result<()> { None, None, true, + None, ) .await?; @@ -583,3 +602,98 @@ async fn wasi_http_no_trap_on_early_drop() -> Result<()> { panic!("test expects an error"); } } + +#[test_log::test(tokio::test)] +async fn wasi_http_fields_limit_incoming_request() -> Result<()> { + use crate::p2::types::FieldSizeLimitError; + use http::{HeaderName, HeaderValue, Request}; + use http_body_util::combinators::BoxBody; + use hyper::Error; + + fn request_with_header_size(uri: &str, total: usize) -> Request<BoxBody<Bytes, Error>> { + let mut builder = hyper::Request::builder().uri(uri).method(http::Method::GET); + + let headers = builder.headers_mut().expect("builder error"); + let chunks = total / 10; + let remainder = total % 10; + for chunk in 0..chunks { + let mut v = format!("v{chunk:04}"); + if chunk == 0 { + for _ in 0..remainder { + v.push('x'); + } + } + headers.insert( + HeaderName::from_bytes(format!("k{chunk:04}").as_bytes()) + .expect("valid header name"), + HeaderValue::from_str(&v).expect("valid header value"), + ); + } + builder + .body(body::empty()) + .expect("complete building request") + } + + let resp = run_wasi_http( + test_programs_artifacts::P2_API_PROXY_COMPONENT, + request_with_header_size("http://host/", 256), + None, + None, + false, + Some(255), + ) + .await + .context("request with headers on wire over the size limit")??; + assert_eq!(resp.status(), 200); + + let resp = run_wasi_http( + test_programs_artifacts::P2_API_PROXY_COMPONENT, + request_with_header_size("http://host/new_fields/50", 0), + None, + None, + false, + Some(500), + ) + .await + .context("new_fields with size matching the size limit")??; + assert_eq!(resp.status(), 200); + + let err = run_wasi_http( + test_programs_artifacts::P2_API_PROXY_COMPONENT, + request_with_header_size("http://host/new_fields/500", 0), + None, + None, + false, + Some(500), + ) + .await + .err() + .expect("new_fields exceeding the size limit"); + assert!(err.downcast_ref::<FieldSizeLimitError>().is_some()); + + let resp = run_wasi_http( + test_programs_artifacts::P2_API_PROXY_COMPONENT, + request_with_header_size("http://host/modify_fields/50", 10), + None, + None, + false, + Some(500), + ) + .await??; + assert_eq!(resp.status(), 200); + + let err = run_wasi_http( + test_programs_artifacts::P2_API_PROXY_COMPONENT, + request_with_header_size("http://host/modify_fields/50", 100), + None, + None, + false, + Some(500), + ) + .await + .err() + .expect("run_wasi_http should give error"); + assert!(err.downcast_ref::<FieldSizeLimitError>().is_some()); + + Ok(()) +}
crates/wasi-io/src/impls.rs+5 −3 modified@@ -4,6 +4,7 @@ use crate::streams::{DynInputStream, DynOutputStream, StreamError, StreamResult} use alloc::collections::BTreeMap; use alloc::string::String; use alloc::vec::Vec; +use bytes::Bytes; use core::future::Future; use core::pin::Pin; use core::task::{Context, Poll}; @@ -163,9 +164,10 @@ impl streams::HostOutputStream for ResourceTable { )); } - self.get_mut(&stream)? - .blocking_write_zeroes_and_flush(len as usize) - .await + // TODO: We could optimize this to not allocate one big zeroed buffer, and instead write + // repeatedly from a 'static buffer of zeros. + let bs = Bytes::from_iter(core::iter::repeat(0).take(len as usize)); + self.get_mut(&stream)?.blocking_write_and_flush(bs).await } fn write_zeroes(&mut self, stream: Resource<DynOutputStream>, len: u64) -> StreamResult<()> {
crates/wasi-io/src/streams.rs+6 −31 modified@@ -233,44 +233,19 @@ pub trait OutputStream: Pollable { /// Returning an Err which downcasts to a [`StreamError`] will be /// reported to Wasm as the empty error result. Otherwise, errors will trap. fn write_zeroes(&mut self, nelem: usize) -> StreamResult<()> { + let n = self.check_write()?; + if nelem > n { + return Err(StreamError::trap( + "cannot write more zeroes than `check_write` allows", + )); + }; // TODO: We could optimize this to not allocate one big zeroed buffer, and instead write // repeatedly from a 'static buffer of zeros. let bs = Bytes::from_iter(core::iter::repeat(0).take(nelem)); self.write(bs)?; Ok(()) } - /// Perform a write of up to 4096 zeroes, and then flush the stream. - /// Block until all of these operations are complete, or an error - /// occurs. - /// - /// This is a convenience wrapper around the use of `check-write`, - /// `subscribe`, `write-zeroes`, and `flush`, and is implemented with - /// the following pseudo-code: - /// - /// ```text - /// let pollable = this.subscribe(); - /// while num_zeroes != 0 { - /// // Wait for the stream to become writable - /// pollable.block(); - /// let Ok(n) = this.check-write(); // eliding error handling - /// let len = min(n, num_zeroes); - /// this.write-zeroes(len); // eliding error handling - /// num_zeroes -= len; - /// } - /// this.flush(); - /// // Wait for completion of `flush` - /// pollable.block(); - /// // Check for any errors that arose during `flush` - /// let _ = this.check-write(); // eliding error handling - /// ``` - async fn blocking_write_zeroes_and_flush(&mut self, nelem: usize) -> StreamResult<()> { - // TODO: We could optimize this to not allocate one big zeroed buffer, and instead write - // repeatedly from a 'static buffer of zeros. - let bs = Bytes::from_iter(core::iter::repeat(0).take(nelem)); - self.blocking_write_and_flush(bs).await - } - /// Simultaneously waits for this stream to be writable and then returns how /// much may be written or the last error that happened. async fn write_ready(&mut self) -> StreamResult<usize> {
crates/wasi/src/cli/file.rs+1 −1 modified@@ -124,7 +124,7 @@ impl Pollable for InputFile { impl InputStream for InputFile { fn read(&mut self, size: usize) -> StreamResult<Bytes> { - let mut buf = bytes::BytesMut::zeroed(size); + let mut buf = bytes::BytesMut::zeroed(size.min(crate::MAX_READ_SIZE_ALLOC)); let bytes_read = self .file .read(&mut buf)
crates/wasi/src/ctx.rs+14 −0 modified@@ -349,6 +349,20 @@ impl WasiCtxBuilder { self } + /// Configures the maximum len accepted by + /// `wasi:random/random.get-random-bytes` and + /// `wasi:random/insecure.get-insecure-random-bytes`. Calls with a len + /// larger than this limit will trap. + /// + /// Limited to 64M by default. This limit protects the host implementation + /// from memory exhaustion from untrusted guest input. A limit of `u64::MAX` + /// is equivalent to no limit, but note that this enables a guest to also + /// force the host to attempt an allocation of that size. + pub fn max_random_size(&mut self, max_size: u64) -> &mut Self { + self.random.max_size = max_size; + self + } + /// Configures `wasi:clocks/wall-clock` to use the `clock` specified. /// /// By default the host's wall clock is used.
crates/wasi/src/lib.rs+18 −0 modified@@ -12,6 +12,24 @@ //! //! For WASIp3, see [`p3`]. WASIp3 support is experimental, unstable and incomplete. +/// The maximum size, in bytes, that this crate will allocate on the host on a +/// per-read basis. +/// +/// This is used to limit the size of `wasi:io/streams.input-stream#read`, for +/// example, along with a variety of other read-style APIs. All of these APIs +/// have the shape where the guest asks the host to read some data for it, and +/// then it's returned. The data is allocated on the host, however, meaning that +/// when the guest asks for an extremely large read that could cause a very +/// large allocations on the host. For now this constant serves as a hard limit +/// on the size of the allocation a host will make. +/// +/// TODO: make this configurable per-I/O? Per-WASI? It's not great that this +/// just a hard and unconfigurable limit. There are probably situations where +/// performance will be improved if this limit were higher or removed. Doing so +/// without exposing hosts to guest-controlled resource exhaustion is the +/// important part, though. +const MAX_READ_SIZE_ALLOC: usize = 64 * 1024; + pub mod cli; pub mod clocks; mod ctx;
crates/wasi/src/p0.rs+3 −0 modified@@ -70,6 +70,9 @@ impl wiggle::GuestErrorType for types::Errno { } impl<T: Snapshot1 + Send> wasi_unstable::WasiUnstable for T { + fn set_hostcall_fuel(&mut self, fuel: usize) { + Snapshot1::set_hostcall_fuel(self, fuel) + } fn args_get( &mut self, memory: &mut GuestMemory<'_>,
crates/wasi/src/p1.rs+157 −75 modified@@ -143,6 +143,7 @@ pub struct WasiP1Ctx { table: ResourceTable, wasi: WasiCtx, adapter: WasiP1Adapter, + hostcall_fuel: usize, } impl WasiP1Ctx { @@ -151,8 +152,81 @@ impl WasiP1Ctx { table: ResourceTable::new(), wasi, adapter: WasiP1Adapter::new(), + hostcall_fuel: 0, } } + + fn consume_fuel(&mut self, fuel: usize) -> Result<()> { + if fuel > self.hostcall_fuel { + return Err(types::Errno::Nomem.into()); + } + self.hostcall_fuel -= fuel; + Ok(()) + } + + /// Assumes the host is going to copy all of `array` in which case a + /// corresponding amount of fuel is consumed to ensure it's not too large. + fn consume_fuel_for_array<T>(&mut self, array: wiggle::GuestPtr<[T]>) -> Result<()> { + let byte_size = usize::try_from(array.len())? + .checked_mul(size_of::<T>()) + .ok_or(types::Errno::Overflow)?; + self.consume_fuel(byte_size) + } + + /// Returns the first non-empty buffer in `ciovs` or a single empty buffer + /// if they're all empty. + /// + /// Additionally consumes a corresponding amount of fuel appropriate to the + /// size of `ciovs` and the first non-empty array. + fn first_non_empty_ciovec( + &mut self, + memory: &GuestMemory<'_>, + ciovs: types::CiovecArray, + ) -> Result<GuestPtr<[u8]>> { + self.consume_fuel_for_array(ciovs)?; + for iov in ciovs.iter() { + let iov = memory.read(iov?)?; + if iov.buf_len == 0 { + continue; + } + let ret = iov.buf.as_array(iov.buf_len); + self.consume_fuel_for_array(ret)?; + return Ok(ret); + } + Ok(GuestPtr::new((0, 0))) + } + + /// Returns the first non-empty buffer in `iovs` or a single empty buffer if + /// they're all empty. + /// + /// Additionally consumes a corresponding amount of fuel appropriate to the + /// size of `ciovs` and the first non-empty array. + fn first_non_empty_iovec( + &mut self, + memory: &GuestMemory<'_>, + iovs: types::IovecArray, + ) -> Result<GuestPtr<[u8]>> { + self.consume_fuel_for_array(iovs)?; + for iov in iovs.iter() { + let iov = memory.read(iov?)?; + if iov.buf_len == 0 { + continue; + } + let ret = iov.buf.as_array(iov.buf_len); + self.consume_fuel_for_array(ret)?; + return Ok(ret); + } + Ok(GuestPtr::new((0, 0))) + } + + /// Copies the guest string `ptr` into the host. + /// + /// Consumes a corresponding amount of fuel for the byte size of `ptr` and + /// fails if it's too large. + fn read_string(&mut self, memory: &GuestMemory<'_>, ptr: GuestPtr<str>) -> Result<String> { + self.consume_fuel(usize::try_from(ptr.len())?)?; + Ok(memory.as_cow_str(ptr)?.into_owned()) + } } impl WasiView for WasiP1Ctx { @@ -556,6 +630,7 @@ impl WasiP1Ctx { ciovs: types::CiovecArray, write: FdWrite, ) -> Result<types::Size, types::Error> { + let buf = self.first_non_empty_ciovec(memory, ciovs)?; let t = self.transact()?; let desc = t.get_descriptor(fd)?; match desc { @@ -577,7 +652,6 @@ impl WasiP1Ctx { let append = *append; drop(t); let f = self.table.get(&fd)?.file()?; - let buf = first_non_empty_ciovec(memory, ciovs)?; let do_write = move |f: &cap_std::fs::File, buf: &[u8]| match (append, write) { // Note that this is implementing Linux semantics of @@ -628,7 +702,6 @@ impl WasiP1Ctx { } let stream = stream.borrowed(); drop(t); - let buf = first_non_empty_ciovec(memory, ciovs)?; let n = BlockingMode::Blocking .write(memory, &mut self.table, stream, buf) .await? @@ -1106,46 +1179,14 @@ fn write_byte(memory: &mut GuestMemory<'_>, ptr: GuestPtr<u8>, byte: u8) -> Resu Ok(next) } -fn read_string<'a>(memory: &'a GuestMemory<'_>, ptr: GuestPtr<str>) -> Result<String> { - Ok(memory.as_cow_str(ptr)?.into_owned()) -} - -// Returns the first non-empty buffer in `ciovs` or a single empty buffer if -// they're all empty. -fn first_non_empty_ciovec( - memory: &GuestMemory<'_>, - ciovs: types::CiovecArray, -) -> Result<GuestPtr<[u8]>> { - for iov in ciovs.iter() { - let iov = memory.read(iov?)?; - if iov.buf_len == 0 { - continue; - } - return Ok(iov.buf.as_array(iov.buf_len)); - } - Ok(GuestPtr::new((0, 0))) -} - -// Returns the first non-empty buffer in `iovs` or a single empty buffer if -// they're all empty. -fn first_non_empty_iovec( - memory: &GuestMemory<'_>, - iovs: types::IovecArray, -) -> Result<GuestPtr<[u8]>> { - for iov in iovs.iter() { - let iov = memory.read(iov?)?; - if iov.buf_len == 0 { - continue; - } - return Ok(iov.buf.as_array(iov.buf_len)); - } - Ok(GuestPtr::new((0, 0))) -} - // Implement the WasiSnapshotPreview1 trait using only the traits that are // required for T, i.e., in terms of the preview 2 wit interface, and state // stored in the WasiP1Adapter struct. impl wasi_snapshot_preview1::WasiSnapshotPreview1 for WasiP1Ctx { + fn set_hostcall_fuel(&mut self, fuel: usize) { + self.hostcall_fuel = fuel; + } + #[instrument(skip(self, memory))] fn args_get( &mut self, @@ -1628,6 +1669,7 @@ impl wasi_snapshot_preview1::WasiSnapshotPreview1 for WasiP1Ctx { fd: types::Fd, iovs: types::IovecArray, ) -> Result<types::Size, types::Error> { + let iov = self.first_non_empty_iovec(memory, iovs)?; let t = self.transact()?; let desc = t.get_descriptor(fd)?; match desc { @@ -1644,7 +1686,6 @@ impl wasi_snapshot_preview1::WasiSnapshotPreview1 for WasiP1Ctx { drop(t); let pos = position.load(Ordering::Relaxed); let file = self.table.get(&fd)?.file()?; - let iov = first_non_empty_iovec(memory, iovs)?; let bytes_read = match (file.as_blocking_file(), memory.as_slice_mut(iov)?) { // Try to read directly into wasm memory where possible // when the current thread can block and additionally wasm @@ -1683,15 +1724,14 @@ impl wasi_snapshot_preview1::WasiSnapshotPreview1 for WasiP1Ctx { Descriptor::Stdin { stream, .. } => { let stream = stream.borrowed(); drop(t); - let buf = first_non_empty_iovec(memory, iovs)?; let read = BlockingMode::Blocking - .read(&mut self.table, stream, buf.len().try_into()?) + .read(&mut self.table, stream, iov.len().try_into()?) .await?; - if read.len() > buf.len().try_into()? { + if read.len() > iov.len().try_into()? { return Err(types::Errno::Range.into()); } - let buf = buf.get_range(0..u32::try_from(read.len())?).unwrap(); - memory.copy_from_slice(&read, buf)?; + let iov = iov.get_range(0..u32::try_from(read.len())?).unwrap(); + memory.copy_from_slice(&read, iov)?; let n = read.len().try_into()?; Ok(n) } @@ -1709,6 +1749,7 @@ impl wasi_snapshot_preview1::WasiSnapshotPreview1 for WasiP1Ctx { iovs: types::IovecArray, offset: types::Filesize, ) -> Result<types::Size, types::Error> { + let buf = self.first_non_empty_iovec(memory, iovs)?; let t = self.transact()?; let desc = t.get_descriptor(fd)?; let (buf, read) = match desc { @@ -1718,7 +1759,6 @@ impl wasi_snapshot_preview1::WasiSnapshotPreview1 for WasiP1Ctx { let fd = fd.borrowed(); let blocking_mode = *blocking_mode; drop(t); - let buf = first_non_empty_iovec(memory, iovs)?; let stream = self.filesystem().read_via_stream(fd, offset)?; let read = blocking_mode @@ -2006,7 +2046,7 @@ impl wasi_snapshot_preview1::WasiSnapshotPreview1 for WasiP1Ctx { path: GuestPtr<str>, ) -> Result<(), types::Error> { let dirfd = self.get_dir_fd(dirfd)?; - let path = read_string(memory, path)?; + let path = self.read_string(memory, path)?; self.filesystem() .create_directory_at(dirfd.borrowed(), path) .await?; @@ -2024,7 +2064,7 @@ impl wasi_snapshot_preview1::WasiSnapshotPreview1 for WasiP1Ctx { path: GuestPtr<str>, ) -> Result<types::Filestat, types::Error> { let dirfd = self.get_dir_fd(dirfd)?; - let path = read_string(memory, path)?; + let path = self.read_string(memory, path)?; let filesystem::DescriptorStat { type_, link_count: nlink, @@ -2085,7 +2125,7 @@ impl wasi_snapshot_preview1::WasiSnapshotPreview1 for WasiP1Ctx { )?; let dirfd = self.get_dir_fd(dirfd)?; - let path = read_string(memory, path)?; + let path = self.read_string(memory, path)?; self.filesystem() .set_times_at(dirfd, flags.into(), path, atim, mtim) .await?; @@ -2106,8 +2146,8 @@ impl wasi_snapshot_preview1::WasiSnapshotPreview1 for WasiP1Ctx { ) -> Result<(), types::Error> { let src_fd = self.get_dir_fd(src_fd)?; let target_fd = self.get_dir_fd(target_fd)?; - let src_path = read_string(memory, src_path)?; - let target_path = read_string(memory, target_path)?; + let src_path = self.read_string(memory, src_path)?; + let target_path = self.read_string(memory, target_path)?; self.filesystem() .link_at(src_fd, src_flags.into(), src_path, target_fd, target_path) .await?; @@ -2128,7 +2168,7 @@ impl wasi_snapshot_preview1::WasiSnapshotPreview1 for WasiP1Ctx { _fs_rights_inheriting: types::Rights, fdflags: types::Fdflags, ) -> Result<types::Fd, types::Error> { - let path = read_string(memory, path)?; + let path = self.read_string(memory, path)?; let mut flags = filesystem::DescriptorFlags::empty(); if fs_rights_base.contains(types::Rights::FD_READ) { @@ -2187,7 +2227,7 @@ impl wasi_snapshot_preview1::WasiSnapshotPreview1 for WasiP1Ctx { buf_len: types::Size, ) -> Result<types::Size, types::Error> { let dirfd = self.get_dir_fd(dirfd)?; - let path = read_string(memory, path)?; + let path = self.read_string(memory, path)?; let mut path = self .filesystem() .readlink_at(dirfd, path) @@ -2210,7 +2250,7 @@ impl wasi_snapshot_preview1::WasiSnapshotPreview1 for WasiP1Ctx { path: GuestPtr<str>, ) -> Result<(), types::Error> { let dirfd = self.get_dir_fd(dirfd)?; - let path = read_string(memory, path)?; + let path = self.read_string(memory, path)?; self.filesystem().remove_directory_at(dirfd, path).await?; Ok(()) } @@ -2228,8 +2268,8 @@ impl wasi_snapshot_preview1::WasiSnapshotPreview1 for WasiP1Ctx { ) -> Result<(), types::Error> { let src_fd = self.get_dir_fd(src_fd)?; let dest_fd = self.get_dir_fd(dest_fd)?; - let src_path = read_string(memory, src_path)?; - let dest_path = read_string(memory, dest_path)?; + let src_path = self.read_string(memory, src_path)?; + let dest_path = self.read_string(memory, dest_path)?; self.filesystem() .rename_at(src_fd, src_path, dest_fd, dest_path) .await?; @@ -2245,8 +2285,8 @@ impl wasi_snapshot_preview1::WasiSnapshotPreview1 for WasiP1Ctx { dest_path: GuestPtr<str>, ) -> Result<(), types::Error> { let dirfd = self.get_dir_fd(dirfd)?; - let src_path = read_string(memory, src_path)?; - let dest_path = read_string(memory, dest_path)?; + let src_path = self.read_string(memory, src_path)?; + let dest_path = self.read_string(memory, dest_path)?; self.filesystem() .symlink_at(dirfd.borrowed(), src_path, dest_path) .await?; @@ -2314,9 +2354,18 @@ impl wasi_snapshot_preview1::WasiSnapshotPreview1 for WasiP1Ctx { let subs = subs.as_array(nsubscriptions); let events = events.as_array(nsubscriptions); - let n = usize::try_from(nsubscriptions).unwrap_or(usize::MAX); - let mut pollables = Vec::with_capacity(n); + + self.consume_fuel_for_array(subs)?; + self.consume_fuel_for_array(events)?; + + let mut temp = TempResources { + ctx: self, + pollables: Vec::with_capacity(n), + inputs: Vec::new(), + outputs: Vec::new(), + }; + let mut borrowed_pollables = Vec::with_capacity(n); for sub in subs.iter() { let sub = memory.read(sub?)?; let p = match sub.u { @@ -2331,7 +2380,7 @@ impl wasi_snapshot_preview1::WasiSnapshotPreview1 for WasiP1Ctx { types::Clockid::Monotonic => (timeout, absolute), types::Clockid::Realtime if !absolute => (timeout, false), types::Clockid::Realtime => { - let now = wall_clock::Host::now(&mut self.clocks()) + let now = wall_clock::Host::now(&mut temp.ctx.clocks()) .context("failed to call `wall_clock::now`") .map_err(types::Error::trap)?; @@ -2354,11 +2403,11 @@ impl wasi_snapshot_preview1::WasiSnapshotPreview1 for WasiP1Ctx { _ => return Err(types::Errno::Inval.into()), }; if absolute { - monotonic_clock::Host::subscribe_instant(&mut self.clocks(), timeout) + monotonic_clock::Host::subscribe_instant(&mut temp.ctx.clocks(), timeout) .context("failed to call `monotonic_clock::subscribe_instant`") .map_err(types::Error::trap)? } else { - monotonic_clock::Host::subscribe_duration(&mut self.clocks(), timeout) + monotonic_clock::Host::subscribe_duration(&mut temp.ctx.clocks(), timeout) .context("failed to call `monotonic_clock::subscribe_duration`") .map_err(types::Error::trap)? } @@ -2367,29 +2416,32 @@ impl wasi_snapshot_preview1::WasiSnapshotPreview1 for WasiP1Ctx { file_descriptor, }) => { let stream = { - let t = self.transact()?; + let t = temp.ctx.transact()?; let desc = t.get_descriptor(file_descriptor)?; match desc { Descriptor::Stdin { stream, .. } => stream.borrowed(), Descriptor::File(File { fd, position, .. }) => { let pos = position.load(Ordering::Relaxed); let fd = fd.borrowed(); drop(t); - self.filesystem().read_via_stream(fd, pos)? + let r = temp.ctx.filesystem().read_via_stream(fd, pos)?; + let ret = r.borrowed(); + temp.inputs.push(r); + ret } // TODO: Support sockets _ => return Err(types::Errno::Badf.into()), } }; - streams::HostInputStream::subscribe(&mut self.table, stream) + streams::HostInputStream::subscribe(&mut temp.ctx.table, stream) .context("failed to call `subscribe` on `input-stream`") .map_err(types::Error::trap)? } types::SubscriptionU::FdWrite(types::SubscriptionFdReadwrite { file_descriptor, }) => { let stream = { - let t = self.transact()?; + let t = temp.ctx.transact()?; let desc = t.get_descriptor(file_descriptor)?; match desc { Descriptor::Stdout { stream, .. } @@ -2404,32 +2456,38 @@ impl wasi_snapshot_preview1::WasiSnapshotPreview1 for WasiP1Ctx { let position = position.clone(); let append = *append; drop(t); - if append { - self.filesystem().append_via_stream(fd)? + let r = if append { + temp.ctx.filesystem().append_via_stream(fd)? } else { let pos = position.load(Ordering::Relaxed); - self.filesystem().write_via_stream(fd, pos)? - } + temp.ctx.filesystem().write_via_stream(fd, pos)? + }; + let ret = r.borrowed(); + temp.outputs.push(r); + ret } // TODO: Support sockets _ => return Err(types::Errno::Badf.into()), } }; - streams::HostOutputStream::subscribe(&mut self.table, stream) + streams::HostOutputStream::subscribe(&mut temp.ctx.table, stream) .context("failed to call `subscribe` on `output-stream`") .map_err(types::Error::trap)? } }; - pollables.push(p); + borrowed_pollables.push(p.borrowed()); + temp.pollables.push(p); } - let ready: HashSet<_> = self + let ready: HashSet<_> = temp + .ctx .table - .poll(pollables) + .poll(borrowed_pollables) .await .context("failed to call `poll-oneoff`") .map_err(types::Error::trap)? .into_iter() .collect(); + drop(temp); let mut count: types::Size = 0; for (sub, event) in (0..) @@ -2526,7 +2584,31 @@ impl wasi_snapshot_preview1::WasiSnapshotPreview1 for WasiP1Ctx { .checked_add(1) .ok_or_else(|| types::Error::from(types::Errno::Overflow))? } - Ok(count) + return Ok(count); + + struct TempResources<'a> { + ctx: &'a mut WasiP1Ctx, + pollables: Vec<Resource<crate::p2::bindings::io::streams::Pollable>>, + inputs: Vec<Resource<crate::p2::bindings::io::streams::InputStream>>, + outputs: Vec<Resource<crate::p2::bindings::io::streams::OutputStream>>, + } + + impl Drop for TempResources<'_> { + fn drop(&mut self) { + for p in self.pollables.drain(..) { + use wasmtime_wasi_io::bindings::wasi::io::poll::HostPollable; + self.ctx.table.drop(p).unwrap(); + } + for p in self.inputs.drain(..) { + assert!(p.owned()); + self.ctx.table.delete(p).unwrap(); + } + for p in self.outputs.drain(..) { + assert!(p.owned()); + self.ctx.table.delete(p).unwrap(); + } + } + } } #[instrument(skip(self, _memory))]
crates/wasi/src/p2/filesystem.rs+1 −1 modified@@ -86,7 +86,7 @@ impl FileInputStream { fn blocking_read(file: &cap_std::fs::File, offset: u64, size: usize) -> ReadState { use system_interface::fs::FileIoExt; - let mut buf = BytesMut::zeroed(size); + let mut buf = BytesMut::zeroed(size.min(crate::MAX_READ_SIZE_ALLOC)); loop { match file.read_at(&mut buf, offset) { Ok(0) => return ReadState::Closed,
crates/wasi/src/p2/host/filesystem.rs+6 −1 modified@@ -115,7 +115,12 @@ impl HostDescriptor for WasiFilesystemCtxView<'_> { let (mut buffer, r) = f .run_blocking(move |f| { - let mut buffer = vec![0; len.try_into().unwrap_or(usize::MAX)]; + let mut buffer = vec![ + 0; + len.try_into() + .unwrap_or(usize::MAX) + .min(crate::MAX_READ_SIZE_ALLOC) + ]; let r = f.read_vectored_at(&mut [IoSliceMut::new(&mut buffer)], offset); (buffer, r) })
crates/wasi/src/p2/host/random.rs+7 −0 modified@@ -1,9 +1,13 @@ use crate::p2::bindings::random::{insecure, insecure_seed, random}; use crate::random::WasiRandomCtx; use cap_rand::{Rng, distributions::Standard}; +use wasmtime::bail; impl random::Host for WasiRandomCtx { fn get_random_bytes(&mut self, len: u64) -> wasmtime::Result<Vec<u8>> { + if len > self.max_size { + bail!("requested len {len:?} exceeds limit {}", self.max_size); + } Ok((&mut self.random) .sample_iter(Standard) .take(len as usize) @@ -17,6 +21,9 @@ impl random::Host for WasiRandomCtx { impl insecure::Host for WasiRandomCtx { fn get_insecure_random_bytes(&mut self, len: u64) -> wasmtime::Result<Vec<u8>> { + if len > self.max_size { + bail!("requested len {len:?} exceeds limit {}", self.max_size); + } Ok((&mut self.insecure_random) .sample_iter(Standard) .take(len as usize)
crates/wasi/src/p2/host/udp.rs+3 −1 modified@@ -210,10 +210,12 @@ impl udp::HostIncomingDatagramStream for WasiSocketsCtxView<'_> { } let mut datagrams = vec![]; + let mut sum = 0; - while datagrams.len() < max_results { + while datagrams.len() < max_results && sum < crate::MAX_READ_SIZE_ALLOC { match recv_one(stream) { Ok(Some(datagram)) => { + sum += 1 + datagram.data.len(); datagrams.push(datagram); } Ok(None) => {
crates/wasi/src/p2/pipe.rs+13 −8 modified@@ -163,7 +163,7 @@ impl AsyncReadStream { let mut reader = pin!(reader); loop { use tokio::io::AsyncReadExt; - let mut buf = bytes::BytesMut::with_capacity(4096); + let mut buf = bytes::BytesMut::with_capacity(crate::MAX_READ_SIZE_ALLOC); let sent = match reader.read_buf(&mut buf).await { Ok(nbytes) if nbytes == 0 => sender.send(Err(StreamError::Closed)).await, Ok(_) => sender.send(Ok(buf.freeze())).await, @@ -516,29 +516,31 @@ mod test { // suitable design for all applications, and we will probably make a knob or change the // behavior at some point, but this test shows the behavior as it is implemented: async fn backpressure_read_stream() { - let (r, mut w) = simplex(16 * 1024); // Make sure this buffer isn't a bottleneck + let (r, mut w) = simplex(4 * crate::MAX_READ_SIZE_ALLOC); // Make sure this buffer isn't a bottleneck let mut reader = AsyncReadStream::new(r); let writer_task = tokio::task::spawn(async move { // Write twice as much as we can buffer up in an AsyncReadStream: - w.write_all(&[123; 8192]).await.unwrap(); + w.write_all(&[123; 2 * crate::MAX_READ_SIZE_ALLOC]) + .await + .unwrap(); w }); resolves_immediately(reader.ready()).await; // Now we expect the reader task has sent 4k from the stream to the reader. // Try to read out one bigger than the buffer available: - let bs = reader.read(4097).unwrap(); - assert_eq!(bs.len(), 4096); + let bs = reader.read(crate::MAX_READ_SIZE_ALLOC + 1).unwrap(); + assert_eq!(bs.len(), crate::MAX_READ_SIZE_ALLOC); // Allow the crank to turn more: resolves_immediately(reader.ready()).await; // Again we expect the reader task has sent 4k from the stream to the reader. // Try to read out one bigger than the buffer available: - let bs = reader.read(4097).unwrap(); - assert_eq!(bs.len(), 4096); + let bs = reader.read(crate::MAX_READ_SIZE_ALLOC + 1).unwrap(); + assert_eq!(bs.len(), crate::MAX_READ_SIZE_ALLOC); // The writer task is now finished - join with it: let w = resolves_immediately(writer_task).await; @@ -550,7 +552,10 @@ mod test { resolves_immediately(reader.ready()).await; // Now we expect the reader to be empty, and the stream.dropd: - assert!(matches!(reader.read(4097), Err(StreamError::Closed))); + assert!(matches!( + reader.read(crate::MAX_READ_SIZE_ALLOC + 1), + Err(StreamError::Closed) + )); } #[test_log::test(test_log::test(tokio::test(flavor = "multi_thread")))]
crates/wasi/src/p2/tcp.rs+1 −1 modified@@ -66,7 +66,7 @@ impl TcpReader { return Ok(bytes::Bytes::new()); } - let mut buf = bytes::BytesMut::with_capacity(size); + let mut buf = bytes::BytesMut::with_capacity(size.min(crate::MAX_READ_SIZE_ALLOC)); let n = match self.stream.try_read_buf(&mut buf) { // A 0-byte read indicates that the stream has closed. Ok(0) => {
crates/wasi/src/random.rs+8 −0 modified@@ -41,10 +41,16 @@ impl HasData for WasiRandom { type Data<'a> = &'a mut WasiRandomCtx; } +/// Default largest length accepted by wasi 0.2 `get-random-bytes` and +/// `get-insecure-random-bytes` methods. This constant must match docs in +/// cli-flags crate. +pub const DEFAULT_MAX_SIZE: u64 = 64 << 20; + pub struct WasiRandomCtx { pub(crate) random: Box<dyn RngCore + Send>, pub(crate) insecure_random: Box<dyn RngCore + Send>, pub(crate) insecure_random_seed: u128, + pub(crate) max_size: u64, } impl Default for WasiRandomCtx { @@ -60,10 +66,12 @@ impl Default for WasiRandomCtx { // API. let insecure_random_seed = cap_rand::thread_rng(cap_rand::ambient_authority()).r#gen::<u128>(); + let max_size = DEFAULT_MAX_SIZE; Self { random: thread_rng(), insecure_random, insecure_random_seed, + max_size, } } }
crates/wasi/tests/all/p1.rs+6 −6 modified@@ -3,6 +3,7 @@ use std::path::Path; use test_programs_artifacts::*; use wasmtime::Result; use wasmtime::{Linker, Module}; +use wasmtime_wasi::WasiView; use wasmtime_wasi::p1::{WasiP1Ctx, add_to_linker_async}; async fn run(path: &str, inherit_stdio: bool) -> Result<()> { @@ -19,6 +20,7 @@ async fn run(path: &str, inherit_stdio: bool) -> Result<()> { } builder.build_p1() })?; + store.data_mut().wasi.ctx().table.set_max_capacity(1000); let instance = linker.instantiate_async(&mut store, &module).await?; let start = instance.get_typed_func::<(), ()>(&mut store, "_start")?; start.call_async(&mut store, ()).await?; @@ -241,9 +243,7 @@ async fn p1_file_write() { async fn p1_path_open_lots() { run(P1_PATH_OPEN_LOTS, true).await.unwrap() } - -#[expect( - dead_code, - reason = "tested in the wasi-cli crate, satisfying foreach_api! macro" -)] -fn p1_cli_much_stdout() {} +#[test_log::test(tokio::test(flavor = "multi_thread"))] +async fn p1_sleep_quickly_but_lots() { + run(P1_SLEEP_QUICKLY_BUT_LOTS, true).await.unwrap() +}
crates/wasi/tests/all/p2/async_.rs+7 −9 modified@@ -17,10 +17,7 @@ async fn run(path: &str, inherit_stdio: bool) -> Result<()> { if inherit_stdio { builder.inherit_stdio(); } - MyWasiCtx { - wasi: builder.build(), - table: Default::default(), - } + MyWasiCtx::new(builder.build()) })?; let component = Component::from_file(&engine, path)?; let command = Command::instantiate_async(&mut store, &component, &linker).await?; @@ -260,6 +257,12 @@ async fn p1_file_write() { async fn p1_path_open_lots() { run(P1_PATH_OPEN_LOTS_COMPONENT, false).await.unwrap() } +#[test_log::test(tokio::test(flavor = "multi_thread"))] +async fn p1_sleep_quickly_but_lots() { + run(P1_SLEEP_QUICKLY_BUT_LOTS_COMPONENT, false) + .await + .unwrap() +} #[test_log::test(tokio::test(flavor = "multi_thread"))] async fn p2_sleep() { @@ -371,8 +374,3 @@ async fn p2_udp_send_too_much() { "unpermitted: argument exceeds permitted size" ) } -#[expect( - dead_code, - reason = "tested in the wasi-cli crate, satisfying foreach_api! macro" -)] -fn p1_cli_much_stdout() {}
crates/wasi/tests/all/p2/sync.rs+5 −9 modified@@ -21,10 +21,7 @@ fn run(path: &str, inherit_stdio: bool) -> Result<()> { builder.inherit_stdio(); } builder.allow_blocking_current_thread(blocking); - MyWasiCtx { - wasi: builder.build(), - table: Default::default(), - } + MyWasiCtx::new(builder.build()) })?; let command = Command::instantiate(&mut store, &component, &linker)?; command @@ -250,6 +247,10 @@ fn p1_file_write() { fn p1_path_open_lots() { run(P1_PATH_OPEN_LOTS_COMPONENT, false).unwrap() } +#[test_log::test] +fn p1_sleep_quickly_but_lots() { + run(P1_SLEEP_QUICKLY_BUT_LOTS_COMPONENT, false).unwrap() +} #[test_log::test] fn p2_sleep() { @@ -351,8 +352,3 @@ fn p2_udp_send_too_much() { "unpermitted: argument exceeds permitted size" ) } -#[expect( - dead_code, - reason = "tested in the wasi-cli crate, satisfying foreach_api! macro" -)] -fn p1_cli_much_stdout() {}
crates/wasi/tests/all/p3/mod.rs+6 −35 modified@@ -24,11 +24,12 @@ async fn run_allow_blocking_current_thread( .context("failed to link `wasi:cli@0.2.x`")?; wasmtime_wasi::p3::add_to_linker(&mut linker).context("failed to link `wasi:cli@0.3.x`")?; - let (mut store, _td) = Ctx::new(&engine, name, |builder| MyWasiCtx { - wasi: builder - .allow_blocking_current_thread(allow_blocking_current_thread) - .build(), - table: Default::default(), + let (mut store, _td) = Ctx::new(&engine, name, |builder| { + MyWasiCtx::new( + builder + .allow_blocking_current_thread(allow_blocking_current_thread) + .build(), + ) })?; let component = Component::from_file(&engine, path)?; let command = Command::instantiate_async(&mut store, &component, &linker) @@ -163,33 +164,3 @@ async fn p3_file_write() -> wasmtime::Result<()> { async fn p3_file_write_blocking() -> wasmtime::Result<()> { run_allow_blocking_current_thread(P3_FILE_WRITE_COMPONENT, true).await } - -#[expect( - dead_code, - reason = "tested in the wasi-cli crate, satisfying foreach_api! macro" -)] -fn p3_cli_hello_stdout() {} - -#[expect( - dead_code, - reason = "tested in the wasi-cli crate, satisfying foreach_api! macro" -)] -fn p3_cli_hello_stdout_post_return() {} - -#[expect( - dead_code, - reason = "tested in the wasi-cli crate, satisfying foreach_api! macro" -)] -fn p3_cli_much_stdout() {} - -#[expect( - dead_code, - reason = "tested in the wasi-cli crate, satisfying foreach_api! macro" -)] -fn p3_cli_serve_hello_world() {} - -#[expect( - dead_code, - reason = "tested in the wasi-cli crate, satisfying foreach_api! macro" -)] -fn p3_cli_serve_sleep() {}
crates/wasi/tests/all/store.rs+10 −2 modified@@ -72,8 +72,16 @@ impl<T> Drop for Ctx<T> { } pub struct MyWasiCtx { - pub wasi: WasiCtx, - pub table: ResourceTable, + wasi: WasiCtx, + table: ResourceTable, +} + +impl MyWasiCtx { + pub fn new(wasi: WasiCtx) -> Self { + let mut table = ResourceTable::new(); + table.set_max_capacity(1000); + Self { wasi, table } + } } impl WasiView for Ctx<MyWasiCtx> {
crates/wasmtime/src/runtime/component/func/options.rs+25 −0 modified@@ -315,6 +315,12 @@ pub struct LiftContext<'a> { host_resource_data: &'a mut HostResourceData, task_state: &'a mut ComponentTaskState, + + /// Remaining fuel for this hostcall/lift operation. + /// + /// This is decremented for strings/lists, for example, to cap the size of + /// data the host allocates on behalf of the guest. + hostcall_fuel: usize, } #[doc(hidden)] @@ -327,6 +333,7 @@ impl<'a> LiftContext<'a> { instance_handle: Instance, ) -> LiftContext<'a> { let store_id = store.id(); + let hostcall_fuel = store.hostcall_fuel(); // From `&mut StoreOpaque` provided the goal here is to project out // three different disjoint fields owned by the store: memory, // `CallContexts`, and `HandleTable`. There's no native API for that @@ -349,6 +356,7 @@ impl<'a> LiftContext<'a> { task_state, host_table, host_resource_data, + hostcall_fuel, } } @@ -462,4 +470,21 @@ impl<'a> LiftContext<'a> { pub fn validate_scope_exit(&mut self) -> Result<()> { self.resource_tables().validate_scope_exit() } + + /// Consumes `amt` units of fuel, typically a number of bytes, from this + /// context. + /// + /// Returns an error if the fuel is exhausted which will cause a trap in the + /// guest. Note that this is distinct from Wasm's fuel, this is just for + /// keeping track of data flowing from the guest to the host. + pub fn consume_fuel(&mut self, amt: usize) -> Result<()> { + match self.hostcall_fuel.checked_sub(amt) { + Some(new) => self.hostcall_fuel = new, + None => bail!( + "too much data is being copied between the host and the guest: \ + fuel allocated for hostcalls has been exhausted" + ), + } + Ok(()) + } }
crates/wasmtime/src/runtime/component/func/typed.rs+2 −2 modified@@ -1651,7 +1651,7 @@ impl WasmStr { } }; match byte_len.and_then(|len| ptr.checked_add(len)) { - Some(n) if n <= cx.memory().len() => {} + Some(n) if n <= cx.memory().len() => cx.consume_fuel(n - ptr)?, _ => bail!("string pointer/length out of bounds of memory"), } Ok(WasmStr { @@ -1909,7 +1909,7 @@ impl<T: Lift> WasmList<T> { .checked_mul(T::SIZE32) .and_then(|len| ptr.checked_add(len)) { - Some(n) if n <= cx.memory().len() => {} + Some(n) if n <= cx.memory().len() => cx.consume_fuel(n - ptr)?, _ => bail!("list pointer/length out of bounds of memory"), } if ptr % usize::try_from(T::ALIGN32)? != 0 {
crates/wasmtime/src/runtime/component/resource_table.rs+58 −4 modified@@ -36,6 +36,7 @@ impl core::error::Error for ResourceTableError {} pub struct ResourceTable { entries: Vec<Entry>, free_head: Option<usize>, + max_capacity: usize, } #[derive(Debug)] @@ -66,6 +67,11 @@ struct Tombstone; // necessary. const DELETE_WITH_TOMBSTONE: bool = false; +/// Default setting for `ResourceTable::max_capacity`, chosen to be high +/// enough that it doesn't need changing all that often but low enough that +/// exhausting it isn't a massive problem for the host. +const DEFAULT_MAX_CAPACITY: usize = 1_000_000; + /// This structure tracks parent and child relationships for a given table entry. /// /// Parents and children are referred to by table index. We maintain the @@ -105,10 +111,7 @@ impl TableEntry { impl ResourceTable { /// Create an empty table pub fn new() -> Self { - ResourceTable { - entries: Vec::new(), - free_head: None, - } + ResourceTable::with_capacity(0) } /// Returns whether or not this table is empty. @@ -122,11 +125,28 @@ impl ResourceTable { }) } + /// Returns the maximum capacity of this table, in elements, before adding + /// any more will be refused. + pub fn max_capacity(&self) -> usize { + self.max_capacity + } + + /// Configures the maximum number of entries that may be present within this + /// table. + /// + /// Note that this does not retroactively shrink the table nor evict + /// existing entries should the maximum be smaller than the current size of + /// the entry table. + pub fn set_max_capacity(&mut self, max: usize) { + self.max_capacity = max; + } + /// Create an empty table with at least the specified capacity. pub fn with_capacity(capacity: usize) -> Self { ResourceTable { entries: Vec::with_capacity(capacity), free_head: None, + max_capacity: DEFAULT_MAX_CAPACITY, } } @@ -197,6 +217,9 @@ impl ResourceTable { self.entries[free] = Entry::Occupied { entry: e }; Ok(free.try_into().unwrap()) } else { + if self.entries.len() >= self.max_capacity { + return Err(ResourceTableError::Full); + } let ix = self .entries .len() @@ -446,3 +469,34 @@ pub fn test_free_list() { let x = table.push(()).unwrap(); assert_eq!(x.rep(), 2); } + +#[test] +fn test_max_capacity() { + let mut table = ResourceTable::new(); + assert_eq!(table.max_capacity(), DEFAULT_MAX_CAPACITY); + + table.set_max_capacity(0); + assert_eq!(table.max_capacity(), 0); + assert!(table.push(()).is_err()); + + table.set_max_capacity(1); + assert_eq!(table.max_capacity(), 1); + let x = table.push(()).unwrap(); + assert!(table.push(()).is_err()); + + table.set_max_capacity(0); + assert!(table.push(()).is_err()); + table.delete(x).unwrap(); + let x = table.push(()).unwrap(); + table.delete(x).unwrap(); + + table.set_max_capacity(10); + + let handles = (0..10).map(|_| table.push(()).unwrap()).collect::<Vec<_>>(); + assert!(table.push(()).is_err()); + for handle in handles { + table.delete(handle).unwrap(); + } + + table.push(()).unwrap(); +}
crates/wasmtime/src/runtime/component/store.rs+78 −2 modified@@ -8,11 +8,20 @@ use crate::runtime::vm::component::{ CallContext, ComponentInstance, HandleTable, OwnedComponentInstance, }; use crate::store::{StoreData, StoreId, StoreOpaque}; -use crate::{Engine, StoreContextMut}; +use crate::{AsContext, AsContextMut, Engine, Store, StoreContextMut}; use core::pin::Pin; use wasmtime_environ::PrimaryMap; use wasmtime_environ::component::RuntimeComponentInstanceIndex; +/// Default amount of fuel allowed for all guest-to-host calls in the component +/// model. +/// +/// This is the maximal amount of data which will be copied from the guest to +/// the host by default. This is set large enough as to not be hit all that +/// often in theory but also small enough such that if left unconfigured on a +/// host doesn't mean that it's automatically susceptible to DoS for example. +const DEFAULT_HOSTCALL_FUEL: usize = 128 << 20; + /// Extensions to `Store` which are only relevant for component-related /// information. pub struct ComponentStoreData { @@ -36,6 +45,13 @@ pub struct ComponentStoreData { /// Metadata/tasks/etc related to component-model-async and concurrency /// support. task_state: ComponentTaskState, + + /// Fuel to be used for each time the guest calls the host or transfers data + /// to the host. + /// + /// Caps the size of the allocations made on the host to this amount + /// effectively. + hostcall_fuel: usize, } /// State tracking for tasks within components. @@ -82,6 +98,7 @@ impl ComponentStoreData { } else { ComponentTaskState::NotConcurrent(Default::default()) }, + hostcall_fuel: DEFAULT_HOSTCALL_FUEL, } } @@ -268,7 +285,6 @@ impl StoreOpaque { self.store_data_mut().components.trapped = true; } - #[cfg(feature = "component-model-async")] pub(crate) fn component_data(&self) -> &ComponentStoreData { &self.store_data().components } @@ -391,6 +407,66 @@ impl StoreOpaque { }; state.scopes.pop(); } + + pub(crate) fn hostcall_fuel(&self) -> usize { + self.component_data().hostcall_fuel + } + + pub(crate) fn set_hostcall_fuel(&mut self, fuel: usize) { + self.component_data_mut().hostcall_fuel = fuel; + } +} + +impl<T> Store<T> { + /// Returns the amount of "hostcall fuel" used for guest-to-host component + /// calls. + /// + /// This is either the default amount if it hasn't been configured or + /// returns the last value passed to [`Store::set_hostcall_fuel`]. + /// + /// See [`Store::set_hostcall_fuel`] `for more details. + pub fn hostcall_fuel(&self) -> usize { + self.as_context().0.hostcall_fuel() + } + + /// Sets the amount of "hostcall fuel" used for guest-to-host component + /// calls. + /// + /// Whenever the guest calls the host it often wants to transfer some data + /// as well, such as strings or lists. This configured fuel value can be + /// used to limit the amount of data that the host allocates on behalf of + /// the guest. This is a DoS mitigation mechanism to prevent a malicious + /// guest from causing the host to allocate an unbounded amount of memory + /// for example. + /// + /// Fuel is considered distinct for each host call. The host is responsible + /// for ensuring it retains a proper amount of data between host calls if + /// applicable. The `fuel` provided here will be the initial value for each + /// time the guest calls the host. + /// + /// The `fuel` value here should roughly corresponds to the maximal number + /// of bytes that the guest may transfer to the host in one call. + /// + /// Note that data transferred from the host to the guest is not limited + /// because it's already resident on the host itself. Only data from the + /// guest to the host is limited. + /// + /// The default value for this is 128 MiB. + pub fn set_hostcall_fuel(&mut self, fuel: usize) { + self.as_context_mut().set_hostcall_fuel(fuel) + } +} + +impl<T> StoreContextMut<'_, T> { + /// See [`Store::hostcall_fuel`]. + pub fn hostcall_fuel(&self) -> usize { + self.0.hostcall_fuel() + } + + /// See [`Store::set_hostcall_fuel`]. + pub fn set_hostcall_fuel(&mut self, fuel: usize) { + self.0.set_hostcall_fuel(fuel) + } } #[derive(Default)]
crates/wasmtime/src/runtime/component/values.rs+1 −1 modified@@ -919,7 +919,7 @@ fn load_list(cx: &mut LiftContext<'_>, ty: TypeListIndex, ptr: usize, len: usize .checked_mul(element_size) .and_then(|len| ptr.checked_add(len)) { - Some(n) if n <= cx.memory().len() => {} + Some(n) if n <= cx.memory().len() => cx.consume_fuel(n - ptr)?, _ => bail!("list pointer/length out of bounds of memory"), } if ptr % usize::try_from(element_alignment)? != 0 {
crates/wiggle/generate/src/module_trait.rs+7 −0 modified@@ -81,6 +81,13 @@ pub fn define_module_trait(m: &Module, settings: &CodegenSettings) -> TokenStrea quote! { pub trait #traitname { #(#traitmethods)* + + /// Indicates, if this implementation supports it, that the + /// specified amount of fuel should be the maximal spent for the + /// upcoming function call. + fn set_hostcall_fuel(&mut self, fuel: usize) { + let _ = fuel; + } } } }
crates/wiggle/generate/src/wasmtime.rs+3 −0 modified@@ -116,14 +116,17 @@ fn generate_func( let body = quote! { let export = caller.get_export("memory"); + let fuel = wiggle::wasmtime_crate::AsContextMut::as_context_mut(&mut caller).hostcall_fuel(); let (mut mem, ctx) = match &export { Some(wiggle::wasmtime_crate::Extern::Memory(m)) => { let (mem, ctx) = m.data_and_store_mut(&mut caller); let ctx = get_cx(ctx); + ctx.set_hostcall_fuel(fuel); (wiggle::GuestMemory::Unshared(mem), ctx) } Some(wiggle::wasmtime_crate::Extern::SharedMemory(m)) => { let ctx = get_cx(caller.data_mut()); + ctx.set_hostcall_fuel(fuel); (wiggle::GuestMemory::Shared(m.data()), ctx) } _ => wiggle::error::bail!("missing required memory export"),
src/commands/run.rs+9 −3 modified@@ -1057,8 +1057,8 @@ impl RunCommand { } } } - - store.data_mut().wasi_http = Some(Arc::new(WasiHttpCtx::new())); + let http = self.run.wasi_http_ctx()?; + store.data_mut().wasi_http = Some(Arc::new(http)); } } @@ -1153,7 +1153,13 @@ impl RunCommand { let mut builder = wasmtime_wasi::WasiCtxBuilder::new(); builder.inherit_stdio().args(&self.compute_argv()?); self.run.configure_wasip2(&mut builder)?; - let ctx = builder.build_p1(); + let mut ctx = builder.build_p1(); + if let Some(max) = self.run.common.wasi.max_resources { + ctx.ctx().table.set_max_capacity(max); + } + if let Some(fuel) = self.run.common.wasi.hostcall_fuel { + store.set_hostcall_fuel(fuel); + } store.data_mut().wasip1_ctx = Some(Arc::new(Mutex::new(ctx))); Ok(()) }
src/commands/serve.rs+10 −2 modified@@ -232,10 +232,14 @@ impl ServeCommand { builder.stdout(LogStream::new(stdout_prefix, Output::Stdout)); builder.stderr(LogStream::new(stderr_prefix, Output::Stderr)); + let mut table = wasmtime::component::ResourceTable::new(); + if let Some(max) = self.run.common.wasi.max_resources { + table.set_max_capacity(max); + } let mut host = Host { - table: wasmtime::component::ResourceTable::new(), + table, ctx: builder.build(), - http: WasiHttpCtx::new(), + http: self.run.wasi_http_ctx()?, http_outgoing_body_buffer_chunks: self.run.common.wasi.http_outgoing_body_buffer_chunks, http_outgoing_body_chunk_size: self.run.common.wasi.http_outgoing_body_chunk_size, @@ -303,6 +307,10 @@ impl ServeCommand { let mut store = Store::new(engine, host); + if let Some(fuel) = self.run.common.wasi.hostcall_fuel { + store.set_hostcall_fuel(fuel); + } + store.data_mut().limits = self.run.store_limits(); store.limiter(|t| &mut t.limits);
src/common.rs+12 −0 modified@@ -325,10 +325,22 @@ impl RunCommon { if let Some(enable) = self.common.wasi.udp { builder.allow_udp(enable); } + if let Some(max_size) = self.common.wasi.max_random_size { + builder.max_random_size(max_size); + } Ok(()) } + #[cfg(feature = "wasi-http")] + pub fn wasi_http_ctx(&self) -> Result<wasmtime_wasi_http::WasiHttpCtx> { + let mut http = wasmtime_wasi_http::WasiHttpCtx::new(); + if let Some(limit) = self.common.wasi.max_http_fields_size { + http.set_field_size_limit(limit); + } + Ok(http) + } + pub fn compute_preopen_sockets(&self) -> Result<Vec<TcpListener>> { let mut listeners = vec![];
tests/all/cli_tests.rs+260 −24 modified@@ -172,9 +172,12 @@ fn assert_trap_code(status: &ExitStatus) { // Run a simple WASI hello world, snapshot0 edition. #[test] fn hello_wasi_snapshot0() -> Result<()> { - let wasm = build_wasm("tests/all/cli_tests/hello_wasi_snapshot0.wat")?; for preview2 in ["-Spreview2=n", "-Spreview2=y"] { - let stdout = run_wasmtime(&["-Ccache=n", preview2, wasm.path().to_str().unwrap()])?; + let stdout = run_wasmtime(&[ + "-Ccache=n", + preview2, + "tests/all/cli_tests/hello_wasi_snapshot0.wat", + ])?; assert_eq!(stdout, "Hello, world!\n"); } Ok(()) @@ -183,8 +186,7 @@ fn hello_wasi_snapshot0() -> Result<()> { // Run a simple WASI hello world, snapshot1 edition. #[test] fn hello_wasi_snapshot1() -> Result<()> { - let wasm = build_wasm("tests/all/cli_tests/hello_wasi_snapshot1.wat")?; - let stdout = run_wasmtime(&["-Ccache=n", wasm.path().to_str().unwrap()])?; + let stdout = run_wasmtime(&["-Ccache=n", "tests/all/cli_tests/hello_wasi_snapshot1.wat"])?; assert_eq!(stdout, "Hello, world!\n"); Ok(()) } @@ -1113,15 +1115,15 @@ mod test_programs { use std::thread::{self, JoinHandle}; use test_programs_artifacts::*; use tokio::net::TcpStream; - use wasmtime::{Result, bail, error::Context as _}; + use wasmtime::{Result, bail, error::Context as _, format_err}; macro_rules! assert_test_exists { ($name:ident) => { #[expect(unused_imports, reason = "just here to assert the test is here")] use self::$name as _; }; } - foreach_p2_cli!(assert_test_exists); + foreach_cli!(assert_test_exists); #[test] fn p2_cli_hello_stdout() -> Result<()> { @@ -2149,7 +2151,7 @@ start a print 1234 } #[test] - fn p2_cli_p3_hello_stdout() -> Result<()> { + fn p3_cli_hello_stdout() -> Result<()> { let output = run_wasmtime(&[ "run", "-Wcomponent-model-async", @@ -2166,7 +2168,23 @@ start a print 1234 } #[test] - fn p2_cli_p3_hello_stdout_post_return() -> Result<()> { + fn p2_cli_hello_stdout_invoke() -> Result<()> { + println!("{P2_CLI_HELLO_STDOUT_COMPONENT}"); + let output = run_wasmtime(&[ + "run", + "-Wcomponent-model", + "--invoke", + "run()", + P2_CLI_HELLO_STDOUT_COMPONENT, + ])?; + // First this component prints "hello, world", then the invoke + // result is printed as "ok". + assert_eq!(output, "hello, world\nok\n"); + Ok(()) + } + + #[test] + fn p3_cli_hello_stdout_post_return() -> Result<()> { let output = run_wasmtime(&[ "run", "-Wcomponent-model-async", @@ -2183,7 +2201,7 @@ start a print 1234 } #[test] - fn p2_cli_p3_hello_stdout_post_return_invoke() -> Result<()> { + fn p3_cli_hello_stdout_post_return_invoke() -> Result<()> { let output = run_wasmtime(&[ "run", "-Wcomponent-model-async", @@ -2201,24 +2219,138 @@ start a print 1234 Ok(()) } - mod invoke { - use super::*; + #[test] + fn p2_random_limits() -> Result<()> { + println!("{P2_RANDOM_COMPONENT}"); + + // By default, p2_random.rs fits within the random limits - always + // asks for 256 bytes. + let output = run_wasmtime(&["run", P2_RANDOM_COMPONENT])?; + assert_eq!(output, ""); + + // Lowering limit to 255 bytes should produce a trap in the first + // call, which goes by way of the wasip1 adapter: + let output = run_wasmtime(&["run", "-Smax-random-size=255", P2_RANDOM_COMPONENT]) + .err() + .ok_or_else(|| format_err!("execution with max-random-size=255 should trap"))?; + let output = format!("{output:?}"); + assert!( + output.contains("lib_generated::random_get"), + "expected error stack frames to contain 'wasip1::lib_generated::random_get'. Got:\n{output}" + ); + assert!( + output.contains("requested len 256 exceeds limit 255"), + "expected error stack frames to contain 'requested len 256 exceeds limit 255'. Got:\n{output}" + ); + + // Lowering limit to 255 bytes and setting environment variable for + // the first call to only request 255 bytes should be OK, and then the + // program produce a trap in the second call, which calls the wasip2 + // random import directly: + let output = run_wasmtime(&[ + "run", + "-Smax-random-size=255", + "--env", + "TEST_P1_RANDOM_LEN=255", + P2_RANDOM_COMPONENT, + ]) + .err() + .ok_or_else(|| format_err!("execution with max-random-size=255 should trap"))?; + let output = format!("{output:?}"); + assert!( + output.contains("random::random::get_random_bytes"), + "expected error stack frames to contain 'wasi::random::random::get_random_bytes'. Got:\n{output}" + ); + assert!( + output.contains("requested len 256 exceeds limit 255"), + "expected error stack frames to contain 'requested len 256 exceeds limit 255'. Got:\n{output}" + ); + + // Lowering limit to 255 bytes and setting environment variable for + // the first and second calls to be at the limit should produce a trap + // in the third call, which calls the wasip2 insecure random import: + let output = run_wasmtime(&[ + "run", + "-Smax-random-size=255", + "--env", + "TEST_P1_RANDOM_LEN=255", + "--env", + "TEST_P2_RANDOM_LEN=255", + P2_RANDOM_COMPONENT, + ]) + .err() + .ok_or_else(|| format_err!("execution with max-random-size=255 should trap"))?; + let output = format!("{output:?}"); + assert!( + output.contains("random::insecure::get_insecure_random_bytes"), + "expected error stack frames to contain 'wasi::random::insecure::get_insecure_random_bytes'. Got:\n{output}" + ); + assert!( + output.contains("requested len 256 exceeds limit 255"), + "expected error stack frames to contain 'requested len 256 exceeds limit 255'. Got:\n{output}" + ); + + // Lowering limit to 255 bytes and setting environment variable for + // the first and second calls to be under the limit should trap in the + // third call, which calls the wasip2 insecure random import: + let output = run_wasmtime(&[ + "run", + "-Smax-random-size=255", + "--env", + "TEST_P1_RANDOM_LEN=255", + "--env", + "TEST_P2_RANDOM_LEN=255", + "--env", + "TEST_P2_INSECURE_RANDOM_LEN=255", + P2_RANDOM_COMPONENT, + ]) + .context("setting all calls to be equal to the limit should pass")?; + assert_eq!(output, ""); + + Ok(()) + } - #[test] - fn p2_cli_hello_stdout() -> Result<()> { - println!("{P2_CLI_HELLO_STDOUT_COMPONENT}"); - let output = run_wasmtime(&[ + #[test] + fn p2_cli_http_headers() -> Result<()> { + for test in ["append", "append-empty", "append-same", "append-same-empty"] { + let err = run_wasmtime(&[ "run", - "-Wcomponent-model", - "--invoke", - "run()", - P2_CLI_HELLO_STDOUT_COMPONENT, - ])?; - // First this component prints "hello, world", then the invoke - // result is printed as "ok". - assert_eq!(output, "hello, world\nok\n"); - Ok(()) + "-Shttp", + "-Smax-http-fields-size=1048576", + P2_CLI_HTTP_HEADERS_COMPONENT, + test, + ]) + .unwrap_err(); + assert!( + err.to_string() + .contains("Field size limit 1048576 exceeded") + || err.to_string().contains("max size reached"), + "bad error message: {err:?}" + ); + + // gated by default too + let err = + run_wasmtime(&["run", "-Shttp", P2_CLI_HTTP_HEADERS_COMPONENT, test]).unwrap_err(); + assert!( + err.to_string().contains("Field size limit"), + "bad error message: {err:?}" + ); } + + // With an extremely large limit Wasmtime still shouldn't panic. + let err = run_wasmtime(&[ + "run", + "-Shttp", + &format!("-Smax-http-fields-size={}", 1 << 30), + P2_CLI_HTTP_HEADERS_COMPONENT, + "append", + ]) + .unwrap_err(); + assert!( + err.to_string().contains("max size reached"), + "bad error message: {err:?}" + ); + Ok(()) } #[test] @@ -2281,6 +2413,18 @@ start a print 1234 ) } + #[test] + fn p2_cli_max_resources() -> Result<()> { + let err = run_wasmtime(&["run", "-Smax-resources=50", P2_CLI_MAX_RESOURCES_COMPONENT]) + .unwrap_err(); + assert!( + err.to_string().contains("resource table has no free keys"), + "bad error message: {err}" + ); + run_wasmtime(&["run", "-Smax-resources=200", P2_CLI_MAX_RESOURCES_COMPONENT])?; + Ok(()) + } + #[tokio::test] #[cfg_attr(not(feature = "component-model-async"), ignore)] async fn p3_cli_serve_hello_world() -> Result<()> { @@ -2521,6 +2665,98 @@ start a print 1234 assert!(stderr.contains("guest timed out"), "bad stderr: {stderr}"); Ok(()) } + + #[test] + fn p2_cli_many_resources() -> Result<()> { + let err = run_wasmtime(&["run", P2_CLI_MANY_RESOURCES_COMPONENT]).unwrap_err(); + assert!( + err.to_string().contains("resource table has no free keys"), + "bad error message: {err}" + ); + Ok(()) + } + + #[test] + fn p1_cli_hostcall_fuel() -> Result<()> { + let dir = tempfile::tempdir()?; + run_wasmtime(&[ + "run", + &format!("--dir={}::.", dir.path().to_str().unwrap()), + "-Shostcall-fuel=1000", + P1_CLI_HOSTCALL_FUEL, + ])?; + Ok(()) + } + + #[test] + fn p2_cli_hostcall_fuel() -> Result<()> { + enum Exit { + Ok, + NoFuel, + TooManyZeroes, + BufferTooLarge, + } + + let dir = tempfile::tempdir()?; + let file = dir.path().join("1mb"); + std::fs::write(&file, vec![0; 1024 * 1024])?; + for (arg, exit) in [ + ("poll", Exit::NoFuel), + ("read", Exit::Ok), + ("write", Exit::NoFuel), + ("mkdir", Exit::NoFuel), + ("write-stream", Exit::NoFuel), + ("write-stream-blocking", Exit::NoFuel), + ("resolve", Exit::NoFuel), + ("udp-send-many", Exit::NoFuel), + ("udp-send-big", Exit::NoFuel), + ("write-zeroes", Exit::TooManyZeroes), + ("write-stream-buffer-too-large", Exit::BufferTooLarge), + ("write-zeroes-buffer-too-large", Exit::BufferTooLarge), + ("read-file-big", Exit::Ok), + ("read-tcp-big", Exit::Ok), + ] { + println!("test: {arg}"); + let result = run_wasmtime(&[ + "run", + "-Shostcall-fuel=5000", + "-Sinherit-network", + &format!("--dir={}::.", dir.path().to_str().unwrap()), + P2_CLI_HOSTCALL_FUEL_COMPONENT, + arg, + ]); + + match exit { + Exit::Ok => { + result.unwrap(); + } + Exit::NoFuel => { + let err = result.unwrap_err(); + assert!( + err.to_string() + .contains("fuel allocated for hostcalls has been exhausted"), + "bad error message: {err}" + ); + } + Exit::TooManyZeroes => { + let err = result.unwrap_err(); + assert!( + err.to_string() + .contains("cannot write more zeroes than `check_write` allows"), + "bad error message: {err}" + ); + } + Exit::BufferTooLarge => { + let err = result.unwrap_err(); + assert!( + err.to_string().contains("Buffer too large"), + "bad error message: {err}" + ); + } + } + } + Ok(()) + } } #[test]
tests/all/component_model/func.rs+43 −0 modified@@ -3730,3 +3730,46 @@ fn with_new_instance<T>( let instance = Linker::new(engine).instantiate(&mut store, component)?; fun(&mut store, instance) } + +#[tokio::test] +async fn drop_call_async_future() -> Result<()> { + let component = r#" +(component + (import "foo" (func $f)) + (core module $m + (func $f (import "" "foo")) + (func (export "foo") call $f) + ) + (core func $f (canon lower (func $f))) + (core instance $m (instantiate $m (with "" (instance + (export "foo" (func $f)) + )))) + (func (export "foo") (canon lift (core func $m "foo"))) +) +"#; + + let engine = &Engine::new(&Config::new())?; + let component = Component::new(&engine, component)?; + let mut store = Store::new(&engine, ()); + let mut linker = Linker::new(&engine); + linker.root().func_wrap_async("foo", |_, _: ()| { + Box::new(async { + tokio::task::yield_now().await; + Ok(()) + }) + })?; + let instance = linker.instantiate_async(&mut store, &component).await?; + let foo = instance.get_typed_func::<(), ()>(&mut store, "foo")?; + // Here we'll use `call_async` a few times but only poll each returned + // future once. This will put the instance in a weird state but shouldn't + // cause a panic. + for _ in 0..5 { + let mut future = std::pin::pin!(foo.call_async(&mut store, ())); + if let std::task::Poll::Ready(result) = + std::future::poll_fn(|cx| std::task::Poll::Ready(future.as_mut().poll(cx))).await + { + _ = result; + } + } + Ok(()) +}
tests/all/component_model/import.rs+43 −0 modified@@ -1216,3 +1216,46 @@ fn use_types_across_component_boundaries() -> Result<()> { Ok(()) } + +#[test] +fn hostcall_fuel_limits_val() -> Result<()> { + let engine = super::engine(); + let component = Component::new( + &engine, + r#"(component + (import "hi" (func $hi (param "x" (list u8)))) + (core module $libc + (memory (export "memory") 10) + ) + (core module $m + (import "libc" "memory" (memory 1)) + (import "" "hi" (func $hi (param i32 i32))) + + (func (export "run") + i32.const 0 + memory.size + i32.const 65536 + i32.mul + call $hi) + ) + (core instance $libc (instantiate $libc)) + (core func $hi (canon lower (func $hi) (memory $libc "memory"))) + (core instance $i (instantiate $m + (with "libc" (instance $libc)) + (with "" (instance (export "hi" (func $hi)))) + )) + (func (export "run") (canon lift (core func $i "run"))) + )"#, + )?; + let mut store = Store::new(&engine, 0); + let mut linker = Linker::new(&engine); + linker.root().func_new("hi", |_, _, _, _| Ok(()))?; + let instance = linker.instantiate(&mut store, &component)?; + let run = instance.get_func(&mut store, "run").unwrap(); + run.call(&mut store, &[], &mut [])?; + + store.set_hostcall_fuel(100); + assert!(run.call(&mut store, &[], &mut []).is_err()); + + Ok(()) +}
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
10- github.com/advisories/GHSA-243v-98vx-264hghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2026-27572ghsaADVISORY
- docs.rs/http/1.4.0/http/header/ghsax_refsource_MISCWEB
- github.com/bytecodealliance/wasmtime/commit/301dc7162cca51def19131019af1187f45901c0aghsax_refsource_MISCWEB
- github.com/bytecodealliance/wasmtime/releases/tag/v24.0.6ghsax_refsource_MISCWEB
- github.com/bytecodealliance/wasmtime/releases/tag/v36.0.6ghsax_refsource_MISCWEB
- github.com/bytecodealliance/wasmtime/releases/tag/v40.0.4ghsax_refsource_MISCWEB
- github.com/bytecodealliance/wasmtime/releases/tag/v41.0.4ghsax_refsource_MISCWEB
- github.com/bytecodealliance/wasmtime/security/advisories/GHSA-243v-98vx-264hghsax_refsource_CONFIRMWEB
- rustsec.org/advisories/RUSTSEC-2026-0021.htmlghsaWEB
News mentions
0No linked articles in our index yet.