Stream: git-wasmtime

Topic: wasmtime / issue #15020 wasi-http p3: forwarding a partly...


view this post on Zulip Wasmtime GitHub notifications bot (Oct 27 2026 at 00:17):

pchickey opened issue #15020:

wasmtime-wasi-http p3 can silently drop the buffered portion of the body if it reads part of the body as a stream, then forwards the body.

<details>
<summary>AI Report</summary>

p3: forwarding a partly read host body silently drops buffered bytes

Summary

A WASIp3 guest gets a body stream backed by the host
(HostBodyStreamProducer) from request.consume-body or
response.consume-body. If it reads part of the stream (or does a
zero-length read, the documented readiness probe) and then passes the same
stream to response.new or request.new, the host converts the stream
back into the underlying host body with StreamReader::try_into
(p3/body.rs:72-91, p3/response.rs:441-460). The try_into closure in
crates/wasmtime/src/runtime/component/concurrent/futures_and_streams.rs: 2790-2799 takes (producer, buffer) and drops buffer. The
HostBodyStreamProducer keeps leftover frame data in that buffer after a
short read (dst.set_buffer(...), p3/body.rs:534-545), and a whole frame
after a zero-length read (:567-580). Those bytes disappear. The response
is sent with the remaining bytes, a self-consistent framing, status 200, and
the guest's transmit future resolves Ok(()).

In the same short-cut, the guest's own trailers future is closed unread
(trailers.close(...), response.rs:445). A guest that forwards a body but
reports an error through its trailers future (which per WIT must abort the
response) instead produces a clean, complete 200.

Affected surfaces

p3 (response.new via wasmtime serve, and request.new with
client.send, which has the same root cause by reading but wasn't run). p2
doesn't have the short-cut.

Attacker and embedding

No attacker is needed for the data loss. Any p3 proxy-style guest that
peeks at a body before forwarding it corrupts the data. An adversarial guest
can use the trailers path to hide errors. Default wasmtime serve.

Root cause

Reproduction

Running the reproducer. Pass a wasmtime CLI built from the commit
under test (found on b229cb936b) as the script asks (WASMTIME, or an
argument). Set CARGO_TARGET_DIR to share builds. Guests need stable
wasm32-wasip2 and/or nightly wasm32-wasip3.
This finding's guests use the exact WIT of the Wasmtime under test: also
set WASMTIME_SRC to that Wasmtime checkout (link-wit.sh links it in).

cd untriaged/0146-wasi-http-p3-forwarded-body-drops-buffered-bytes
WASMTIME=<wasmtime> bash run.sh

It POSTs 200,000 bytes to a guest that echoes the request body as its
response body, with one of four modes: forward untouched, read 1 byte first
(and write it out separately), do a zero-length read first, or forward with a
trailers future that resolves to an error.

Observed output

Coordinator re-run on 2026-10-12, debug CLI. The lane observed the same on a
release CLI.

mode=none  expected=200000 got=200000 lost=0 OK
mode=peek1 expected=199999 got=191975 lost=8024 BUG: bytes silently dropped
mode=zero  expected=200000 got=191974 lost=8026 BUG: bytes silently dropped
mode=trailers-err status=200 got=200000 curl_exit=0
guest: trailers error delivered to host = false
guest: transmit result = Ok(())

Impact

Silent corruption of proxied HTTP bodies (truncation in the middle, with
consistent framing, so neither side notices), and the error-signalling
contract of the trailers future is bypassed. This is an integrity bug in
default, Tier 1 p3 wasi-http. No memory-safety impact. Severity: medium.

Suggested fix

In the try_into closure, only convert when the buffer is empty; otherwise
put (mine, buffer) back and return None so the generic path is used.
Or pass the buffer along so the host body can be prefixed with it. In the
wasi-http short-cut, don't drop the guest's trailers future: if the guest
supplied one, fall back to the generic path, or chain it after the host
body's trailers.

Request direction: confirmed by lane C2 (see 0154, "Other
results"). After a 1-byte peek the upstream got 191984 of 199999 bytes,
and the guest's trailers future is ignored there too.

Duplicate check

Not in the triage repository. The CM-async audit's notes examined
StreamReader::try_into only for lock contention and unwrap (round 3 and
round 4 host-api.md, streams-futures.md), not for the dropped buffer.
0002 involves the same splice path, but it's a resource leak. No upstream
fix.

Triage

Result: normal-bug. When a WASIp3 guest forwards a host body stream it
has partly read (or probed with a zero-length read), the host silently drops
the bytes buffered in the stream's producer, about 8 KiB, and ignores the
guest's trailers future. The result is corrupted proxied traffic with
consistent framing and an Ok result. That's an integrity bug for the
guest's own traffic, not a host-safety issue.

Reasoning

Evidence

Wasmtime checkout ~/src/wasmtime, git fetch origin on 2026-10-13,
origin/main = a59a82daf2e389e04badf436fc30d928b60241a4.

  1. The core try_into closure still drops buffer on success, and is
    unchanged since the audit:

    $ git show "a59a82daf2:crates/wasmtime/src/runtime/component/concurrent/futures_and_streams.rs" | grep -n -A9 'let try_into = Box::new' 2796: let try_into = Box::new(move |ty| { 2797: let (mine, buffer) = producer.try_lock().ok()?.take()?; 2798: match P::try_into(mine, ty) { 2799: Ok(value) => Some(value), 2800: Err(mine) => { 2801: *producer.try_lock().ok()? = Some((mine, buffer)); 2802: None $ diff <(b229cb936b's closure) <(a59a82daf2's closure) (no differences) -> "try_into closure identical b229cb936b vs main"

  2. The producer still leaves data in that buffer, and the short-cut still
    closes the guest's trailers future unread. On main the short-cut is in
    Body::new_guest (p3/body.rs), which response.new and request.new
    both use:

    $ git show "a59a82daf2:crates/wasi-http/src/p3/body.rs" | grep -n 'set_buffer' 569: dst.set_buffer(frame.split_off(cap)); 580: dst.set_buffer(frame); $ git show "a59a82daf2:crates/wasi-http/src/p3/body.rs" | sed -n 72,80p let contents = match contents .map(|rx| rx.try_into::<HostBodyStreamProducer>(store.as_context_mut())) { Some(Ok(mut producer)) => { trailers.close(store.as_context_mut())?; ... let body = Body::Host { body: mem::take(&mut producer.body), result_tx };

  3. Release branches have the same code:

    $ for b in release-48.0.0 release-49.0.0 release-50.0.0; do git show origin/$b:.../futures_and_streams.rs | grep -n 'let (mine, buffer) = producer.try_lock().ok()?.take()?;'; git show origin/$b:crates/wasi-http/src/p3/body.rs | grep -n 'try_into::<HostBodyStreamProducer'; done release-48.0.0: 2767 (closure) 63 (.map(|rx| rx.try_into::<HostBodyStreamProducer<T>>(&mut *store))) release-49.0.0: 2767 63 (same) release-50.0.0: 2767 67 (.map(|rx| rx.try_into::<HostBodyStreamProducer>(store.as_context_mut())))

  4. Upstream commits since the audit touch the concurrent runtime, but none
    changes this closure (point 1) or the short-cut:

    ```
    $ git log --oneline b229cb936b..a59a82daf2 -- crates/wasmtime/src/runtime/component/concurr
    [message truncated]

view this post on Zulip Wasmtime GitHub notifications bot (Oct 27 2026 at 00:17):

pchickey added the bug label to Issue #15020.

view this post on Zulip Wasmtime GitHub notifications bot (Oct 27 2026 at 00:34):

alexcrichton added the wasi-http label to Issue #15020.

view this post on Zulip Wasmtime GitHub notifications bot (Oct 28 2026 at 16:36):

rvolosatovs closed issue #15020:

wasmtime-wasi-http p3 can silently drop the buffered portion of the body if it reads part of the body as a stream, then forwards the body.

<details>
<summary>AI Report</summary>

p3: forwarding a partly read host body silently drops buffered bytes

Summary

A WASIp3 guest gets a body stream backed by the host
(HostBodyStreamProducer) from request.consume-body or
response.consume-body. If it reads part of the stream (or does a
zero-length read, the documented readiness probe) and then passes the same
stream to response.new or request.new, the host converts the stream
back into the underlying host body with StreamReader::try_into
(p3/body.rs:72-91, p3/response.rs:441-460). The try_into closure in
crates/wasmtime/src/runtime/component/concurrent/futures_and_streams.rs: 2790-2799 takes (producer, buffer) and drops buffer. The
HostBodyStreamProducer keeps leftover frame data in that buffer after a
short read (dst.set_buffer(...), p3/body.rs:534-545), and a whole frame
after a zero-length read (:567-580). Those bytes disappear. The response
is sent with the remaining bytes, a self-consistent framing, status 200, and
the guest's transmit future resolves Ok(()).

In the same short-cut, the guest's own trailers future is closed unread
(trailers.close(...), response.rs:445). A guest that forwards a body but
reports an error through its trailers future (which per WIT must abort the
response) instead produces a clean, complete 200.

Affected surfaces

p3 (response.new via wasmtime serve, and request.new with
client.send, which has the same root cause by reading but wasn't run). p2
doesn't have the short-cut.

Attacker and embedding

No attacker is needed for the data loss. Any p3 proxy-style guest that
peeks at a body before forwarding it corrupts the data. An adversarial guest
can use the trailers path to hide errors. Default wasmtime serve.

Root cause

Reproduction

Running the reproducer. Pass a wasmtime CLI built from the commit
under test (found on b229cb936b) as the script asks (WASMTIME, or an
argument). Set CARGO_TARGET_DIR to share builds. Guests need stable
wasm32-wasip2 and/or nightly wasm32-wasip3.
This finding's guests use the exact WIT of the Wasmtime under test: also
set WASMTIME_SRC to that Wasmtime checkout (link-wit.sh links it in).

cd untriaged/0146-wasi-http-p3-forwarded-body-drops-buffered-bytes
WASMTIME=<wasmtime> bash run.sh

It POSTs 200,000 bytes to a guest that echoes the request body as its
response body, with one of four modes: forward untouched, read 1 byte first
(and write it out separately), do a zero-length read first, or forward with a
trailers future that resolves to an error.

Observed output

Coordinator re-run on 2026-10-12, debug CLI. The lane observed the same on a
release CLI.

mode=none  expected=200000 got=200000 lost=0 OK
mode=peek1 expected=199999 got=191975 lost=8024 BUG: bytes silently dropped
mode=zero  expected=200000 got=191974 lost=8026 BUG: bytes silently dropped
mode=trailers-err status=200 got=200000 curl_exit=0
guest: trailers error delivered to host = false
guest: transmit result = Ok(())

Impact

Silent corruption of proxied HTTP bodies (truncation in the middle, with
consistent framing, so neither side notices), and the error-signalling
contract of the trailers future is bypassed. This is an integrity bug in
default, Tier 1 p3 wasi-http. No memory-safety impact. Severity: medium.

Suggested fix

In the try_into closure, only convert when the buffer is empty; otherwise
put (mine, buffer) back and return None so the generic path is used.
Or pass the buffer along so the host body can be prefixed with it. In the
wasi-http short-cut, don't drop the guest's trailers future: if the guest
supplied one, fall back to the generic path, or chain it after the host
body's trailers.

Request direction: confirmed by lane C2 (see 0154, "Other
results"). After a 1-byte peek the upstream got 191984 of 199999 bytes,
and the guest's trailers future is ignored there too.

Duplicate check

Not in the triage repository. The CM-async audit's notes examined
StreamReader::try_into only for lock contention and unwrap (round 3 and
round 4 host-api.md, streams-futures.md), not for the dropped buffer.
0002 involves the same splice path, but it's a resource leak. No upstream
fix.

Triage

Result: normal-bug. When a WASIp3 guest forwards a host body stream it
has partly read (or probed with a zero-length read), the host silently drops
the bytes buffered in the stream's producer, about 8 KiB, and ignores the
guest's trailers future. The result is corrupted proxied traffic with
consistent framing and an Ok result. That's an integrity bug for the
guest's own traffic, not a host-safety issue.

Reasoning

Evidence

Wasmtime checkout ~/src/wasmtime, git fetch origin on 2026-10-13,
origin/main = a59a82daf2e389e04badf436fc30d928b60241a4.

  1. The core try_into closure still drops buffer on success, and is
    unchanged since the audit:

    $ git show "a59a82daf2:crates/wasmtime/src/runtime/component/concurrent/futures_and_streams.rs" | grep -n -A9 'let try_into = Box::new' 2796: let try_into = Box::new(move |ty| { 2797: let (mine, buffer) = producer.try_lock().ok()?.take()?; 2798: match P::try_into(mine, ty) { 2799: Ok(value) => Some(value), 2800: Err(mine) => { 2801: *producer.try_lock().ok()? = Some((mine, buffer)); 2802: None $ diff <(b229cb936b's closure) <(a59a82daf2's closure) (no differences) -> "try_into closure identical b229cb936b vs main"

  2. The producer still leaves data in that buffer, and the short-cut still
    closes the guest's trailers future unread. On main the short-cut is in
    Body::new_guest (p3/body.rs), which response.new and request.new
    both use:

    $ git show "a59a82daf2:crates/wasi-http/src/p3/body.rs" | grep -n 'set_buffer' 569: dst.set_buffer(frame.split_off(cap)); 580: dst.set_buffer(frame); $ git show "a59a82daf2:crates/wasi-http/src/p3/body.rs" | sed -n 72,80p let contents = match contents .map(|rx| rx.try_into::<HostBodyStreamProducer>(store.as_context_mut())) { Some(Ok(mut producer)) => { trailers.close(store.as_context_mut())?; ... let body = Body::Host { body: mem::take(&mut producer.body), result_tx };

  3. Release branches have the same code:

    $ for b in release-48.0.0 release-49.0.0 release-50.0.0; do git show origin/$b:.../futures_and_streams.rs | grep -n 'let (mine, buffer) = producer.try_lock().ok()?.take()?;'; git show origin/$b:crates/wasi-http/src/p3/body.rs | grep -n 'try_into::<HostBodyStreamProducer'; done release-48.0.0: 2767 (closure) 63 (.map(|rx| rx.try_into::<HostBodyStreamProducer<T>>(&mut *store))) release-49.0.0: 2767 63 (same) release-50.0.0: 2767 67 (.map(|rx| rx.try_into::<HostBodyStreamProducer>(store.as_context_mut())))

  4. Upstream commits since the audit touch the concurrent runtime, but none
    changes this closure (point 1) or the short-cut:

    ```
    $ git log --oneline b229cb936b..a59a82daf2 -- crates/wasmtime/src/runtime/component/conc
    [message truncated]


Last updated: Oct 31 2026 at 23:03 UTC