Outbound network
What a function may reach: the per-function allowlist, the egress proxy and network placement.
Calling an outside API
A function reaches the outside world only for hosts it declares. Say it needs to email a digest through a provider’s HTTP API: declare the host, declare the key as a secret, and use fetch:
# function.yaml
mode: async
schedule: "@daily"
network: [api.mailer.example] # host or host:port, lower case; no scheme, path or wildcard
secrets: [MAILER_KEY]
const res = await fetch("https://api.mailer.example/v1/send", {
method: "POST",
headers: { "content-type": "application/json", authorization: `Bearer ${ctx.secrets.MAILER_KEY}` },
body: JSON.stringify({ to: "ops@example.com", subject: `Digest ${day}`, text: `${orders} orders, ${revenue / 100} in revenue` }),
});
if (!res.ok) throw ctx.error(424, "mailer_failed", `the mail provider answered ${res.status}`);
- A request to any host not in
networkfails, and so does one to a private, loopback or link-local address, even for a declared host name that resolves to one. - Without a
networklist, a function can only callbackditself (throughctx.db). - This is how the cookbook’s
daily_digestwould send its result by email instead of storing it. - Send an idempotency key to the provider when the call has side effects and the function can run twice (Async jobs).
Egress: the network allowlist, completed
Why this exists. A function runs code you may not have written yourself (an npm dependency, a library update). If its outbound network were left to a single hostname allowlist, a bug or a compromised dependency could still: connect to MongoDB or another private service directly; reach your cloud provider’s metadata endpoint (169.254.169.254) and steal the credentials your infrastructure runs as (SSRF); or reach the executor’s or backd’s own internal endpoints. A hostname allowlist alone doesn’t stop any of this, because it checks names, not the addresses they resolve to — an allowed name can resolve to a private or loopback address (by mistake, by DNS rebinding, or because an attacker controls that DNS record) — and a raw TCP connection (Deno.connect, used directly instead of fetch) never goes through an HTTP proxy at all, so a proxy-based check alone can’t see it either.
Because of that, the control is three layers, all required — each closes a gap the others leave open:
- Per-function allowlist.
network:infunction.yamlbecomes that process’s--allow-net, plusbackd’s own address; with no list, a function can only reachbackd. Enforced by Deno itself, before any network call is attempted. Doesn’t stop: an allowed name resolving somewhere it shouldn’t, or raw TCP to an address that was never allowed by name at all. backd egress. An HTTP forward/CONNECT proxy, set asHTTP_PROXY/HTTPS_PROXYfor every function process, with a token scoped to that invocation’s own allowed hosts (minted by the executor, verified by egress, so a compromised function can’t widen its own allowlist). It resolves every target itself — not trusting the name — and refuses loopback, private, link-local (cloud metadata), CGNAT, multicast and unspecified addresses;backd’s internal listener (EGRESS_ALLOW_PRIVATE) is its only exception. Run it asbackd egress(the main image; seebackd egress help). Doesn’t stop: raw TCP, which never consultsHTTP_PROXY.- Network placement. Deploy the executor on a network whose only reachable peers are
backd egressandbackd’s internal listener, so MongoDB and everything else stays unreachable at the network level — including from a raw TCP connection that bypasses the proxy entirely. The production reference wires this up: the executor’sfuncnetwork has no route todata(MongoDB) or the internet, only tobackdandbackd egress; its own test (deploy/production/test.sh) proves it from a real function call.docker/egress-test/in the repository is the same proof run in isolation, with every case from the table below (make egress-test). The local stack also runs an executor and egress, but without this network isolation — it’s a single-user, localhost-only convenience, not a hardened deployment. Assembling your own deployment from scratch: without this, a function can reach anything its container’s network reaches over raw TCP, and neither layer 1 nor layer 2 can see it.
One case layer 3 can’t close by itself: a raw TCP connection to the executor’s own loopback address always succeeds, because it’s the same container reaching itself — no network placement can prevent that. What stops it is that the executor’s /invoke endpoint requires a separate shared credential (BACKD_EXECUTOR_TOKEN) a function never has, so reaching the socket gets an unauthenticated request nowhere.
See the security model for the full threat model and what’s proven versus what’s still the operator’s job.