Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Compile-Time Egress Allowlist (perry.allowedHosts)

Perry can verify, at compile time, that every outbound network call in your binary targets a host you’ve explicitly approved. When the host application opts in via perry.allowedHosts in package.json, every literal URL/host in a fetch(...), net.connect(...), or net.createConnection(...) call must match one of the listed patterns — otherwise the build fails before producing a binary.

Zero runtime cost. The check runs at compile time over the lowered HIR. The resulting binary is the same size and shape as a build without the gate.

Why a compile-time check

Runtime allowlists are foot-shoots — a misconfiguration or a malicious dep can bypass them. A compile-time check gives a stronger property: grep-ing the binary’s egress is reliable. If a dep tries to add a new outbound host through a literal URL, the build fails and the review catches it; if it tries to hide the host behind a variable, the build still fails unless you’ve explicitly opted into dynamic hosts.

Configuration

In your host package.json:

{
  "perry": {
    "allowedHosts": [
      "api.example.com",
      "*.cdn.example.com",
      "https://api.acme.com/v1/*"
    ]
  }
}

Pattern syntax

  • Exact host"api.example.com" matches that hostname on any scheme/port/path.
  • Subdomain wildcard"*.cdn.example.com" matches every direct or transitive subdomain. The bare suffix does NOT match — *.foo.com does not match foo.com.
  • URL prefix"https://api.acme.com/v1/*" matches any URL starting with that literal prefix. Path-bound entries only gate path-bearing call sites — net.connect("api.acme.com") against a URL-prefix entry does NOT match (use a host-style entry for that).
  • Universal"*" matches everything (escape hatch for incremental migration; defeats the static guarantee).

Dynamic URLs / hosts

Non-literal arguments — fetch(someVar), net.connect(port, hostVar), template strings with substitutions — defeat the static grep-the-binary guarantee. They’re refused by default:

const url = "https://api.example.com/x";
const resp = await fetch(url); // refused unless allowDynamicHosts: true

To allow them, set perry.allowDynamicHosts: true:

{
  "perry": {
    "allowedHosts": ["api.example.com"],
    "allowDynamicHosts": true
  }
}

The code reviewer then has to trust the value of every variable that reaches fetch(...) — explicit acknowledgment that the static guarantee is being weakened.

Opt-in semantics

If perry.allowedHosts is not set, the entire pass is disabled and existing builds compile unchanged. The host opts in by setting the array; once set, the gate is strict.

This is intentionally not “default-deny on greenfield” — that would break every existing build that calls fetch(...). Migration path:

  1. Run the build once without the allowlist.
  2. Inspect .perry-cache/audit.json (the behavioral SBOM (#495)) and see what egress the binary currently performs.
  3. Populate allowedHosts with the surface you actually use.
  4. Re-build. The gate now catches future regressions.

Diagnostic shape

The build fails with one combined diagnostic naming every offending site at once (better UX than failing on the first one and asking the user to re-run):

Error: egress allowlist refused 2 call site(s):
  - /repo/main.ts: fetch → "https://evil.com/leak" (literal host not in `perry.allowedHosts`)
  - /repo/lib/foo.ts: net.connect → "x.evil.com" (literal host not in `perry.allowedHosts`)

`perry.allowedHosts` provides a static guarantee that this binary's
outbound network surface matches the declared list. Refusing the build. (#502)

Options:
- Add the offending host(s) to `perry.allowedHosts` ...
- Set `"*"` in `allowedHosts` to disable host gating ...
- For non-literal URLs, set `perry.allowDynamicHosts: true` ...

The list is capped at 12 entries so pathological builds don’t produce 60-line errors; trailing sites are summarised as ... and N more.

What’s covered now

This first cut covers the highest-volume egress shape: fetch(...) + net.connect(...) / net.createConnection(...). Other shapes — http.get(...), https.request(...), WebSocket(...) — lower through the general-shape NativeMethodCall HIR variant and will graft onto the same pass in a follow-up.

See also

  • #502 — design discussion.
  • perry audit --sbom (#495) — discover what egress your binary currently performs before populating the allowlist.
  • The wider supply-chain hardening series (#495#506).