Making Outbound HTTP Requests from WASI

This guide answers one task: make an HTTP request from inside a WebAssembly module running under a standalone runtime — understanding why the obvious approach does not work and which of the three real options fits your deployment.

Prerequisites

  • [ ] Wasmtime 24+ or another runtime with the HTTP proposal implemented.
  • [ ] A module you can rebuild; this is not solvable from the host alone.
  • [ ] A decision about which hosts the module is permitted to reach.
  • [ ] A local endpoint to test against, so failures are about your code rather than the internet.

Why Preview 1 cannot do this

WASI Preview 1 has no networking. It defines file descriptors, clocks, randomness, environment variables and arguments, and that is the complete list. A module built for wasm32-wasip1 that calls a Rust HTTP client compiles — because the client’s code is there — and then fails at runtime, because the syscalls it expects do not exist.

Error: failed to invoke `run`
Caused by:
  error while executing at wasm backtrace:
    0: socket::sys::create
  Caused by: unknown import: `wasi_snapshot_preview1::sock_open` has not been defined

That message is the signature of this problem, and no amount of host configuration fixes it. The options are to move to Preview 2, to have the host supply the capability as a custom import, or to take the network out of the module entirely.

Three routes to the network Preview 2's HTTP interface provides a standard capability. A custom host import gives full control with a non-standard interface. Moving the network outside the module keeps it pure and is often the simplest answer. wasi:http (Preview 2) standard, typed interface portable across runtimes needs a component build the direction of travel custom host import you define the function complete control of policy non-portable by definition works today, everywhere no network in the module host fetches, module computes module stays pure and testable no capability to grant often the right answer

wasi:http in Preview 2

Preview 2 defines wasi:http/outgoing-handler, a typed interface for making requests. Using it means building a component rather than a core module, and running it on a runtime that provides the interface.

rustup target add wasm32-wasip2
cargo build --release --target wasm32-wasip2
wasmtime run -S http target/wasm32-wasip2/release/fetcher.wasm
use waki::Client;                              // one of several client crates over wasi:http

fn main() -> anyhow::Result<()> {
    let resp = Client::new()
        .get("https://api.example.com/status")
        .header("accept", "application/json")
        .connect_timeout(std::time::Duration::from_secs(5))
        .send()?;

    println!("{} {}", resp.status_code(), String::from_utf8_lossy(&resp.body()?));
    Ok(())
}

The -S http flag is the capability grant: without it the interface is not provided and the module’s attempt to use it fails. That is the model working as intended — network access is something the host decides to give, not something the module has.

This is the direction the ecosystem is going, and it is the right choice for a new module targeting runtimes you control. The friction today is tooling maturity and the smaller set of client libraries.

A custom host import

Where Preview 2 is not available — an older runtime, a platform with its own model, a deployment you cannot change — the host can supply an HTTP function as an ordinary import. This works on every runtime because it uses nothing beyond core WebAssembly.

// guest: declare what the host will provide
#[link(wasm_import_module = "host")]
extern "C" {
    fn http_get(url_ptr: *const u8, url_len: usize, out_ptr: *mut u8, out_cap: usize) -> i32;
}

pub fn fetch(url: &str, buf: &mut [u8]) -> Result<usize, i32> {
    let n = unsafe { http_get(url.as_ptr(), url.len(), buf.as_mut_ptr(), buf.len()) };
    if n < 0 { Err(n) } else { Ok(n as usize) }
}
// host: implement it, with policy
linker.func_wrap("host", "http_get", |mut caller: Caller<'_, State>,
                                      url_ptr: u32, url_len: u32,
                                      out_ptr: u32, out_cap: u32| -> i32 {
    let mem = caller.get_export("memory").and_then(|e| e.into_memory()).unwrap();
    let url = read_utf8(&mem, &caller, url_ptr, url_len);

    if !caller.data().allowlist.iter().any(|h| url.starts_with(h)) {
        return -403;                           // policy lives here, not in the guest
    }
    match blocking_get(&url) {
        Ok(body) => {
            let n = body.len().min(out_cap as usize);
            mem.write(&mut caller, out_ptr as usize, &body[..n]).unwrap();
            n as i32
        }
        Err(_) => -500,
    }
})?;

The allowlist check is the point of the whole arrangement: the guest asks, the host decides. That is strictly better than giving the guest a general socket capability, and it is why a purpose-built import is often preferable to full networking even when full networking is available.

Taking the network out of the module

The third option deserves more consideration than it usually gets. A module that computes over data it is given, with the host performing every network operation, has no network capability to grant, no policy to enforce and no asynchronous complexity inside it.

// host does the fetching
let body = reqwest::blocking::get(&url)?.bytes()?;
let ptr = instance.call_alloc(&mut store, body.len())?;
memory.write(&mut store, ptr, &body)?;
let result = instance.call_process(&mut store, ptr, body.len() as u32)?;

The module becomes a pure function, which makes it trivially testable, trivially portable between a browser and a server, and trivially safe to run from an untrusted source. Where the module needs to decide what to fetch, it can return a request description for the host to act on, which keeps the decision in the guest and the capability in the host.

The module asks, the host acts The module returns a description of the request it wants, the host performs it under its own policy, and the response is written into the module's memory for processing. The module never holds a network capability. module returns a request description what bytes host allowlist, timeout, retries holds the capability the network reached only by the host The same module then runs unchanged in a browser, where the host is JavaScript and the fetch is the platform's own.

Policy: what the host should enforce

Whichever route you take, the host is where policy belongs, and four rules cover most deployments.

An allowlist of destinations, expressed as full origins rather than substrings. A check that matches example.com anywhere in a URL also matches evil.example.com.attacker.test, which is the classic mistake; parse the URL and compare the host exactly.

A timeout on every request, enforced by the host rather than trusted to the guest. A guest that sets no deadline should still be bounded, because a guest that hangs holds whatever resources the host allocated to it.

A cap on response size, applied while reading rather than afterwards. Without it, a destination returning an endless stream fills the host’s memory on the guest’s behalf — a denial of service that the guest did not even have to intend.

And a redirect policy. Following redirects blindly turns an allowlisted destination into an arbitrary one, because the first response chooses the second request. Either disable redirects and let the guest ask again, or re-check each hop against the allowlist.

fn permitted(url: &str, allow: &[&str]) -> bool {
    match url::Url::parse(url) {
        Ok(u) => u.scheme() == "https"
            && u.host_str().map_or(false, |h| allow.contains(&h)),
        Err(_) => false,
    }
}

Log every request the module makes, with the decision. That log is the audit trail for what a module reached, and for an untrusted module it is the only record you will have.

Expected output

A working Preview 2 build reports the grant and the request:

wasmtime run -S http fetcher.wasm
200 {"status":"ok","region":"eu-west-1"}
# without the grant
wasmtime run fetcher.wasm
Error: failed to invoke `run`
Caused by: unknown import: `wasi:http/outgoing-handler@0.2.0` has not been defined

That second message is the capability model doing its job, and it is worth showing to anyone who asks why a module could not simply reach the network on its own.

Who actually opens the socket The module never opens a socket. It calls an imported handler, the runtime decides whether the host is permitted, and only then does a real request leave the machine. guest code builds a request value; no socket, no DNS wasi:http/outgoing-handler the import the runtime provides runtime policy allow-list check: which hosts, which methods host network the request that actually leaves the machine A denied host surfaces as an error return, not a trap, so the guest can handle it like any other failure. Default-deny is the only sane policy: name the hosts the module may reach and refuse the rest.

Gotchas

  • Building for wasip1 and expecting sockets. They do not exist; the error names a missing import.
  • Forgetting -S http. The interface is not provided and the module fails at the first request.
  • No timeout. A request with no deadline holds the instance indefinitely; set one in the guest and enforce one in the host.
  • No allowlist. A module that can reach any host can exfiltrate anything it processes.
  • Blocking the host thread. A synchronous host import that performs a network request blocks whatever called it; use the runtime’s async support for anything serving requests.
  • Assuming portability. A custom import works on your host and nowhere else, which is a deliberate trade rather than an accident to discover later.

Performance note

Through wasi:http on Wasmtime, a request to a local endpoint added about 0.9 ms of overhead over a native client — the marshalling across the component boundary. A custom host import with a synchronous implementation added about 0.2 ms, at the cost of blocking. Neither is significant next to network latency, which means the choice between them should rest on portability and policy rather than on speed.

Frequently Asked Questions

Can a module listen on a socket? wasi:sockets in Preview 2 provides that, and several runtimes implement it. For a request handler the usual arrangement is the reverse — the host listens and invokes the module per request — which keeps the module simple and lets the host manage connections.

What about DNS and TLS? Both are handled by the host implementation rather than by the module, which is another argument for the capability model: the guest cannot misconfigure certificate validation because it never performs it.

Does the browser have an equivalent? The browser’s equivalent is simply fetch, supplied by JavaScript as an import, which is the same capability model arriving from a different direction.

How do I test this locally? Point the allowlist at a local server and run the module under the runtime directly. Testing against a real external service makes the test slow and flaky for reasons unrelated to your code.

The question to ask first is not how to give a module network access, but whether it needs any.

← Back to WASI Target Builds & Runtimes