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.
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.
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.
Gotchas
- Building for
wasip1and 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.
Related
- WASI Preview 1 vs Preview 2 — what changed and why.
- Granting filesystem access with WASI preopens — the same capability model for files.
- Sandboxing untrusted code with Wasm — why withholding the capability is the default.
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