Skip to content

sockets: no network scoping — 5-use address predicate + protocol/name-lookup toggles (wasmtime SocketAddrUse shape) #200

Description

@lannbot

Summary

sockets() grants the host's network reach wholesale — no allowlist, no address check, no
protocol toggle — behind an API that reads like a feature toggle. That is strictly broader
authority than a filesystem preopen, exposed through a strictly less explicit API. This
issue records the design for closing it; the sibling http half (a name-level
allowRequest predicate) is separate because browsers can only ever support the name-level
form.

docs/security.md currently documents this gap ("No network scoping") rather than closing
it.

The check surface is 5 cells, not 6

The obvious guess is 3 operations × 2 protocols. Two corrections, both verified against
this tree:

listen carries no address. startListen()
(wasi/src/internal/sockets_02.ts:396) is a state transition on an already-bound socket —
the address arrived at bind. There is nothing to check at listen time; the policy
attaches upstream.

UDP has a cell TCP does not: per-datagram send-to. send(data, remoteAddress | undefined) (wasi/src/internal/sockets_03.ts:193) and the outgoing-datagram record on
the 0.2 side (wasi/src/internal/sockets_02.ts:166) carry a remote address per datagram
on an unconnected socket. A policy that checks only connect lets a guest reach anywhere
it likes, one datagram at a time. This is the cell that gets missed.

So the surface is:

use where
tcp-bind local address at bind
tcp-connect remote address
udp-bind local address at bind
udp-connect remote address (connected mode)
udp-outgoing-datagram remote address, per datagram

This is exactly wasmtime-wasi's SocketAddrUse enum. Matching it is deliberate: wasmtime
is the corroborating authority this project already tracks parity against
(docs/architecture.md §1), so an identical enum keeps the parity checkable instead of a
matter of taste.

A sixth surface that is not an address

ip-name-lookup is implemented on both tracks
(wasi/src/internal/sockets_02.ts:1084, wasi/src/internal/sockets_03.ts:1019).
wasmtime-wasi gates it with its own boolean (allow_ip_name_lookup) for good reason: a
guest with every socket denied but name resolution intact still has a DNS exfiltration
channel and a rebinding primitive.

Proposed API

Two layers rather than a grid of booleans — coarse toggles for the common case, one
predicate for the fine-grained one:

sockets({
  allowTcp: boolean,            // default true (today's behavior)
  allowUdp: boolean,            // default true
  allowIpNameLookup: boolean,   // default true
  checkAddress?: (addr: IpSocketAddress, use: SocketAddrUse) =>
    boolean | Promise<boolean>,
})

type SocketAddrUse =
  | "tcp-bind" | "tcp-connect"
  | "udp-bind" | "udp-connect" | "udp-outgoing-datagram";

One callback with a discriminant beats five callbacks: a policy that ignores the
discriminant still compiles, and a new operation extends the enum rather than the option
bag.

Defaults stay permissive. As with the http half, a deny-by-default that makes the fragment
inert is not a safe default but an off switch — everyone writes allowTcp: true in the
first five minutes without reading anything, which is the roadblock-you-paste-past pattern
this project rejected when it dropped the runtime permission gate (docs/security.md,
"Imposing a real boundary"). Contrast the filesystem's writable: false, where the safe
default leaves a genuinely useful mode working.

Refusal maps to access-denied, already in the fragment's vocabulary
(wasi/src/internal/sockets_02.ts:1130).

The obligation that decides whether this is real

An address check means something only if we connect to the address we checked.
net.connect({ host }) re-resolves, so a name-based flow re-enters DNS after the check
and the predicate becomes decoration — the classic rebinding gap. Implementing this
honestly means resolving first, checking the resolved address, then connecting to the IP
literal.

If we are not willing to take resolution into our own hands, checkAddress should not
exist and allowTcp/allowUdp/allowIpNameLookup are the honest extent of what this
fragment can offer.

This is also the line between the two fragments: sockets() is node-builtins only
(browsers get not-supportedwasi/src/sockets.ts:67), so we own the connect and the
address check is possible here. In a browser, fetch resolves and connects inside the
network stack, JS never sees the resolved address, and only a name-level check is
expressible — which is why the http half is a separate, weaker mechanism with a different
name and a different guarantee.

Acceptance

  • All five uses routed through one predicate, with the per-datagram case covered by a test
    that sends to a denied address on an unconnected socket.
  • ip-name-lookup gated independently.
  • Resolve-then-connect-to-literal, or checkAddress dropped from the design.
  • Both tracks (0.2 and 0.3) enforced from one site, as the read-only filesystem work did.
  • docs/security.md's "No network scoping" bullet updated to describe what is now
    scopable and what still is not.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or requestp2Minor bugs; desirable lower-priority featurespolicyDecision owed on deltic-owned semantics

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions