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-supported — wasi/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.
Summary
sockets()grants the host's network reach wholesale — no allowlist, no address check, noprotocol 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
allowRequestpredicate) is separate because browsers can only ever support the name-levelform.
docs/security.mdcurrently documents this gap ("No network scoping") rather than closingit.
The check surface is 5 cells, not 6
The obvious guess is 3 operations × 2 protocols. Two corrections, both verified against
this tree:
listencarries 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 policyattaches upstream.
UDP has a cell TCP does not: per-datagram send-to.
send(data, remoteAddress | undefined)(wasi/src/internal/sockets_03.ts:193) and theoutgoing-datagramrecord onthe 0.2 side (
wasi/src/internal/sockets_02.ts:166) carry a remote address per datagramon an unconnected socket. A policy that checks only
connectlets a guest reach anywhereit likes, one datagram at a time. This is the cell that gets missed.
So the surface is:
bindbindThis is exactly wasmtime-wasi's
SocketAddrUseenum. Matching it is deliberate: wasmtimeis the corroborating authority this project already tracks parity against
(
docs/architecture.md§1), so an identical enum keeps the parity checkable instead of amatter of taste.
A sixth surface that is not an address
ip-name-lookupis 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: aguest 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:
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: truein thefirst 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 safedefault 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 checkand 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,
checkAddressshould notexist and
allowTcp/allowUdp/allowIpNameLookupare the honest extent of what thisfragment can offer.
This is also the line between the two fragments:
sockets()is node-builtins only(browsers get
not-supported—wasi/src/sockets.ts:67), so we own the connect and theaddress check is possible here. In a browser,
fetchresolves and connects inside thenetwork 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
that sends to a denied address on an unconnected socket.
ip-name-lookupgated independently.checkAddressdropped from the design.docs/security.md's "No network scoping" bullet updated to describe what is nowscopable and what still is not.