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
- System: Linux 6.8.0-137-generic x86_64 (Ubuntu)
- Wasmtime commit: b229cb936b35039bda5a04c9eb8e65d337dfade7
- Date: 2026-10-12
- Model: Claude Opus 5.5
- Lane: C (p3 streams). The coordinator re-ran the reproducer and wrote this
report.Summary
A WASIp3 guest gets a body stream backed by the host
(HostBodyStreamProducer) fromrequest.consume-bodyor
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 toresponse.neworrequest.new, the host converts the stream
back into the underlying host body withStreamReader::try_into
(p3/body.rs:72-91,p3/response.rs:441-460). Thetry_intoclosure in
crates/wasmtime/src/runtime/component/concurrent/futures_and_streams.rs: 2790-2799takes(producer, buffer)and dropsbuffer. The
HostBodyStreamProducerkeeps 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 resolvesOk(()).In the same short-cut, the guest's own
trailersfuture 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.newviawasmtime serve, andrequest.newwith
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. Defaultwasmtime serve.Root cause
crates/wasmtime/src/runtime/component/concurrent/futures_and_streams.rs: 2790-2799:try_intodiscards the producer's pending buffer.
crates/wasi-http/src/p3/body.rs:534-545, :567-580: the producer
legitimately leaves data in that buffer.
crates/wasi-http/src/p3/response.rs:441-460(andbody.rs:72-91): the
short-cut is used whenevertry_intosucceeds, and it also discards the
guest's trailers future.Reproduction
Running the reproducer. Pass a
wasmtimeCLI built from the commit
under test (found on b229cb936b) as the script asks (WASMTIME, or an
argument). SetCARGO_TARGET_DIRto share builds. Guests need stable
wasm32-wasip2and/or nightlywasm32-wasip3.
This finding's guests use the exact WIT of the Wasmtime under test: also
setWASMTIME_SRCto that Wasmtime checkout (link-wit.shlinks it in).cd untriaged/0146-wasi-http-p3-forwarded-body-drops-buffered-bytes WASMTIME=<wasmtime> bash run.shIt 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_intoclosure, only convert when the buffer is empty; otherwise
put(mine, buffer)back and returnNoneso 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_intoonly for lock contention andunwrap(round 3 and
round 4host-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 anOkresult. That's an integrity bug for the
guest's own traffic, not a host-safety issue.
- Triaged by Pat Hickey (agent-assisted, Claude Opus 5.5), 2026-10-13.
- Decision: normal-bug. This was the reviewer's call: Pat Hickey chose
the outcome, matching the pre-triage assessment's recommendation
(background/wasi-http-audit/assessment-2026-10-13.md, Till
Schneidereit: "normal-bug (medium functional) ... Silent corruption of
proxied bodies. The victim is the guest's own traffic"). Pat Hickey gave
no further reasons in this session. The reasoning below is the
assessment's and this triage's.Reasoning
Who can trigger it: the guest, by its own actions: peek-then-forward,
or forwarding with a trailers future that reports an error. No attacker
is needed for the data loss, and a benign proxy guest hits it. A client
only supplies the body.Release-build behaviour: release and debug alike (the lane observed a
release CLI, the coordinator a debug CLI). Bytes are lost with no error,
and there's no panic, no memory-safety impact, and no effect on other
guests or the host.Why not a vulnerability: the guest loses its own data, or chooses to
hide its own error. It gains no capability or resource it didn't have,
and nothing crosses a sandbox boundary. The trailers-error bypass lets a
guest send a "clean" response it could have sent anyway by not reporting
the error.Feature tiers:
wasi-http(Tier 1) with WASIp3
(component-model-async/p3, on by default in the 48, 49 and 50 CLIs;
the p3 module docs still say "experimental"). Part of the root cause is
in corewasmtime's component-model-async streams.Affected releases: 48.x, 49.x, 50.0.0-rc.1 and
main(code
reading). 36.x has no p3.Evidence
Wasmtime checkout
~/src/wasmtime,git fetch originon 2026-10-13,
origin/main=a59a82daf2e389e04badf436fc30d928b60241a4.
The core
try_intoclosure still dropsbufferon 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"The producer still leaves data in that buffer, and the short-cut still
closes the guest's trailers future unread. Onmainthe short-cut is in
Body::new_guest(p3/body.rs), whichresponse.newandrequest.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 };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())))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]
pchickey added the bug label to Issue #15020.
alexcrichton added the wasi-http label to Issue #15020.
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
- System: Linux 6.8.0-137-generic x86_64 (Ubuntu)
- Wasmtime commit: b229cb936b35039bda5a04c9eb8e65d337dfade7
- Date: 2026-10-12
- Model: Claude Opus 5.5
- Lane: C (p3 streams). The coordinator re-ran the reproducer and wrote this
report.Summary
A WASIp3 guest gets a body stream backed by the host
(HostBodyStreamProducer) fromrequest.consume-bodyor
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 toresponse.neworrequest.new, the host converts the stream
back into the underlying host body withStreamReader::try_into
(p3/body.rs:72-91,p3/response.rs:441-460). Thetry_intoclosure in
crates/wasmtime/src/runtime/component/concurrent/futures_and_streams.rs: 2790-2799takes(producer, buffer)and dropsbuffer. The
HostBodyStreamProducerkeeps 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 resolvesOk(()).In the same short-cut, the guest's own
trailersfuture 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.newviawasmtime serve, andrequest.newwith
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. Defaultwasmtime serve.Root cause
crates/wasmtime/src/runtime/component/concurrent/futures_and_streams.rs: 2790-2799:try_intodiscards the producer's pending buffer.
crates/wasi-http/src/p3/body.rs:534-545, :567-580: the producer
legitimately leaves data in that buffer.
crates/wasi-http/src/p3/response.rs:441-460(andbody.rs:72-91): the
short-cut is used whenevertry_intosucceeds, and it also discards the
guest's trailers future.Reproduction
Running the reproducer. Pass a
wasmtimeCLI built from the commit
under test (found on b229cb936b) as the script asks (WASMTIME, or an
argument). SetCARGO_TARGET_DIRto share builds. Guests need stable
wasm32-wasip2and/or nightlywasm32-wasip3.
This finding's guests use the exact WIT of the Wasmtime under test: also
setWASMTIME_SRCto that Wasmtime checkout (link-wit.shlinks it in).cd untriaged/0146-wasi-http-p3-forwarded-body-drops-buffered-bytes WASMTIME=<wasmtime> bash run.shIt 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_intoclosure, only convert when the buffer is empty; otherwise
put(mine, buffer)back and returnNoneso 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_intoonly for lock contention andunwrap(round 3 and
round 4host-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 anOkresult. That's an integrity bug for the
guest's own traffic, not a host-safety issue.
- Triaged by Pat Hickey (agent-assisted, Claude Opus 5.5), 2026-10-13.
- Decision: normal-bug. This was the reviewer's call: Pat Hickey chose
the outcome, matching the pre-triage assessment's recommendation
(background/wasi-http-audit/assessment-2026-10-13.md, Till
Schneidereit: "normal-bug (medium functional) ... Silent corruption of
proxied bodies. The victim is the guest's own traffic"). Pat Hickey gave
no further reasons in this session. The reasoning below is the
assessment's and this triage's.Reasoning
Who can trigger it: the guest, by its own actions: peek-then-forward,
or forwarding with a trailers future that reports an error. No attacker
is needed for the data loss, and a benign proxy guest hits it. A client
only supplies the body.Release-build behaviour: release and debug alike (the lane observed a
release CLI, the coordinator a debug CLI). Bytes are lost with no error,
and there's no panic, no memory-safety impact, and no effect on other
guests or the host.Why not a vulnerability: the guest loses its own data, or chooses to
hide its own error. It gains no capability or resource it didn't have,
and nothing crosses a sandbox boundary. The trailers-error bypass lets a
guest send a "clean" response it could have sent anyway by not reporting
the error.Feature tiers:
wasi-http(Tier 1) with WASIp3
(component-model-async/p3, on by default in the 48, 49 and 50 CLIs;
the p3 module docs still say "experimental"). Part of the root cause is
in corewasmtime's component-model-async streams.Affected releases: 48.x, 49.x, 50.0.0-rc.1 and
main(code
reading). 36.x has no p3.Evidence
Wasmtime checkout
~/src/wasmtime,git fetch originon 2026-10-13,
origin/main=a59a82daf2e389e04badf436fc30d928b60241a4.
The core
try_intoclosure still dropsbufferon 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"The producer still leaves data in that buffer, and the short-cut still
closes the guest's trailers future unread. Onmainthe short-cut is in
Body::new_guest(p3/body.rs), whichresponse.newandrequest.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 };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())))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