From 7559e90213acd79b6f2589deaecb560f2c1dff39 Mon Sep 17 00:00:00 2001 From: Rodrigo Delduca Date: Fri, 31 Jul 2026 16:44:16 -0300 Subject: [PATCH 1/2] feat(wasm): give core modules outbound TCP through wippy:sock/tcp MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit wasi:sockets and wasi:http are both ComponentOnly, so a core wasm module had no way to reach the network at all. A language runtime compiled to wasm32-wasip1 is a core module, which meant TLS was impossible inside it: WASI Preview 1 provides no outbound socket and no resolver, so an interpreter's own HTTP stack fails before TLS starts. Adds coresock, a host exposing connect/send/recv/close over raw integer signatures — what a core import can express — with the private-address policy the Lua http_client already applies, so a guest cannot use the host as a way into the local network. Every dial is additionally gated on wippy.sock.connect. TCP rather than HTTP deliberately. With a byte stream the guest keeps its own TLS and its own protocol code, so a standard library works unmodified instead of every client library needing a special case. Registered synchronously on purpose. Declaring the calls async makes the engine asyncify-transform the whole guest before compiling it, and instrumenting a 10 MB CPython pushes wazero's compile past the 30s entry-load timeout — which surfaces as "no listener responded" for kind function.wasm while wazero is still inside ssa.RunPasses. The guest call is synchronous anyway, so the host blocking on the socket is the required behaviour. Requires wasm-runtime 289ea70 (#13) for RegisterCoreFunc and for the host clock: wazero's default fake clock made TLS reject every certificate as "not yet valid". Verified end to end on the runtime with a CPython guest: requests, urllib and http.client each complete an HTTPS GET (200), a POST returns its JSON echoed, https://1.1.1.1 verifies against an IP SAN, pypi.org/simple/ streams 44 MB, and an expired certificate is rejected. --- boot/components/runtime/wasm/engine_test.go | 3 + boot/components/runtime/wasm/hosts.go | 47 +++ go.mod | 2 +- go.sum | 2 + .../host/wippy/hosts/coresock/coresock.go | 267 ++++++++++++++++++ 5 files changed, 320 insertions(+), 1 deletion(-) create mode 100644 runtime/wasm/host/wippy/hosts/coresock/coresock.go diff --git a/boot/components/runtime/wasm/engine_test.go b/boot/components/runtime/wasm/engine_test.go index 6a5df905a..65dcbf051 100644 --- a/boot/components/runtime/wasm/engine_test.go +++ b/boot/components/runtime/wasm/engine_test.go @@ -56,6 +56,9 @@ func TestDefaultHostProfiles(t *testing.T) { "funcs", "wasi1", "wasi:io", "wasi:poll", "wasi:clocks", "wasi:cli", "wasi:filesystem", "wasi:random", "wasi:sockets", "wasi:http", + // Core modules cannot import wasi:sockets or wasi:http, both of which are + // component-only, so they get outbound TCP through this one instead. + "wippy:sock/tcp", } if len(profiles) != len(want) { t.Fatalf("DefaultHostProfiles() len = %d, want %d", len(profiles), len(want)) diff --git a/boot/components/runtime/wasm/hosts.go b/boot/components/runtime/wasm/hosts.go index 8d96f3658..69a7f36fc 100644 --- a/boot/components/runtime/wasm/hosts.go +++ b/boot/components/runtime/wasm/hosts.go @@ -5,9 +5,12 @@ package wasm import ( "context" + "github.com/tetratelabs/wazero/api" + "github.com/wippyai/runtime/api/dispatcher" runtimewasm "github.com/wippyai/runtime/runtime/wasm" wasmcomponent "github.com/wippyai/runtime/runtime/wasm/component" + "github.com/wippyai/runtime/runtime/wasm/host/wippy/hosts/coresock" wasmrt "github.com/wippyai/wasm-runtime/runtime" "github.com/wippyai/wasm-runtime/wasi/preview2" "go.uber.org/zap" @@ -29,6 +32,7 @@ func DefaultHostProfiles(log *zap.Logger, disp dispatcher.Dispatcher) []wasmcomp wasiRandomProfile(log), wasiSocketsProfile(log), wasiHTTPProfile(disp, log), + coreSockProfile(log), } } @@ -151,3 +155,46 @@ func wasiHTTPProfile(d dispatcher.Dispatcher, log *zap.Logger) wasmcomponent.Hos }, } } + +// coreSockProfile exposes outbound TCP to core modules. +// +// Deliberately not ComponentOnly: wasi:sockets and wasi:http both are, so a core +// module has no way to reach the network at all. The signatures are raw integers +// because that is what a core import can express, and the calls are async so the +// guest yields while the host waits on the connection. +func coreSockProfile(log *zap.Logger) wasmcomponent.HostProfile { + return wasmcomponent.HostProfile{ + Name: coresock.Namespace, + Aliases: []string{coresock.Namespace, "wippy:sock"}, + Register: func(_ context.Context, rt *wasmrt.Runtime) error { + host := coresock.New(log) + i32, i64 := api.ValueTypeI32, api.ValueTypeI64 + specs := []struct { + name string + params []api.ValueType + results []api.ValueType + fn api.GoModuleFunc + async bool + }{ + // Registered synchronous on purpose. Declaring them async makes the + // engine asyncify-transform the whole guest, and instrumenting a + // 10 MB CPython pushes wazero's compile past the runtime's 30s + // entry-load timeout. The guest call is synchronous anyway, so the + // host blocking on the socket is exactly the required behaviour. + {"connect", []api.ValueType{i32, i32, i32, i32}, []api.ValueType{i64}, host.Connect, false}, + {"send", []api.ValueType{i32, i32, i32}, []api.ValueType{i64}, host.Send, false}, + {"recv", []api.ValueType{i32, i32, i32}, []api.ValueType{i64}, host.Recv, false}, + {"close", []api.ValueType{i32}, []api.ValueType{i32}, host.Close, false}, + } + for _, spec := range specs { + if err := rt.RegisterCoreFunc( + coresock.Namespace, spec.name, spec.params, spec.results, spec.fn, spec.async, + ); err != nil { + return runtimewasm.NewRegisterHostError(coresock.Namespace, err) + } + } + log.Info("wasm host profile registered", zap.String("profile", coresock.Namespace)) + return nil + }, + } +} diff --git a/go.mod b/go.mod index 5b2c1197a..c5dbe45c1 100644 --- a/go.mod +++ b/go.mod @@ -68,7 +68,7 @@ require ( github.com/wippyai/tree-sitter-markdown v0.0.3 github.com/wippyai/tree-sitter-sql v0.0.4 github.com/wippyai/wapp v0.1.2 - github.com/wippyai/wasm-runtime v0.0.0-20260719152804-ec6962b31c64 + github.com/wippyai/wasm-runtime v0.0.0-20260731193643-289ea709d8e8 github.com/xuri/excelize/v2 v2.11.0 go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 go.opentelemetry.io/otel v1.44.0 diff --git a/go.sum b/go.sum index c4be0eaf8..b079c05fc 100644 --- a/go.sum +++ b/go.sum @@ -562,6 +562,8 @@ github.com/wippyai/wapp v0.1.2 h1:fCoxKr9s3gk+pWx4XcnIQmOVgPiuimXf3QcE9mHbdmw= github.com/wippyai/wapp v0.1.2/go.mod h1:ndCkYR80+osLGbd7AFWlP+3DxwooR+R6cxQYPZhksg4= github.com/wippyai/wasm-runtime v0.0.0-20260719152804-ec6962b31c64 h1:ETP3ZooZzg4hUZYnfz/ZPdqvpWAduQ9nHK+XqkOBkj8= github.com/wippyai/wasm-runtime v0.0.0-20260719152804-ec6962b31c64/go.mod h1:81IhzsGQBNRyLqynOAYK9WLsMx9CoqtwsEf0B6PwL5k= +github.com/wippyai/wasm-runtime v0.0.0-20260731193643-289ea709d8e8 h1:ELbGKx6QgYh1TUw7rLEv7AqndO9deBbGzmXhBb6Vvpw= +github.com/wippyai/wasm-runtime v0.0.0-20260731193643-289ea709d8e8/go.mod h1:81IhzsGQBNRyLqynOAYK9WLsMx9CoqtwsEf0B6PwL5k= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= diff --git a/runtime/wasm/host/wippy/hosts/coresock/coresock.go b/runtime/wasm/host/wippy/hosts/coresock/coresock.go new file mode 100644 index 000000000..4b4d9a712 --- /dev/null +++ b/runtime/wasm/host/wippy/hosts/coresock/coresock.go @@ -0,0 +1,267 @@ +// SPDX-License-Identifier: MPL-2.0 + +// Package coresock gives core wasm modules an outbound TCP capability. +// +// WASI Preview 1 has no outbound socket and no resolver, so a guest built against it +// cannot connect however it is linked. wasi:sockets answers this for components, but +// a core module cannot import it. This host is the core-module equivalent: the host +// owns the connection and the guest moves bytes through its own memory. +// +// TCP rather than HTTP on purpose. With a byte stream the guest keeps its own TLS and +// its own protocol code, so an interpreter's standard library works unmodified +// instead of every HTTP client needing a special case. +// +// Integer-only signatures, because that is all a core import can express: +// +// connect(host_ptr, host_len, port, timeout_ms) -> i64 status<<32 | handle +// send(handle, buf_ptr, buf_len) -> i64 status<<32 | written +// recv(handle, out_ptr, out_cap) -> i64 status<<32 | read (0=EOF) +// close(handle) -> i32 status +package coresock + +import ( + "context" + "fmt" + "net" + "sync" + "time" + + "github.com/tetratelabs/wazero/api" + "go.uber.org/zap" + + "github.com/wippyai/runtime/runtime/security" +) + +// Namespace is the import module name a core guest declares. +const Namespace = "wippy:sock/tcp" + +// Status codes returned in the high half of the i64 result. +const ( + StatusOK uint32 = 0 + StatusBadRequest uint32 = 1 + StatusDenied uint32 = 2 + StatusTransport uint32 = 3 + StatusUnknownHandle uint32 = 4 + StatusTooMany uint32 = 5 +) + +const ( + maxHostLen = 253 + maxChunkBytes = 1 << 20 + maxConnections = 16 + defaultTimeout = 30 * time.Second + // connectAction gates every dial, so a guest that was not granted it cannot + // reach the network even though the host can. + connectAction = "wippy.sock.connect" +) + +// Host serves TCP calls for core modules. +type Host struct { + log *zap.Logger + conns map[uint32]net.Conn + mu sync.Mutex + next uint32 +} + +// New builds a TCP host. +func New(log *zap.Logger) *Host { + return &Host{log: log, conns: make(map[uint32]net.Conn)} +} + +func pack(status, value uint32) uint64 { + return uint64(status)<<32 | uint64(value) +} + +func isPrivateIP(ip net.IP) bool { + if ip == nil { + return false + } + return ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || + ip.IsLinkLocalMulticast() || ip.IsUnspecified() +} + +// checkTarget mirrors the Lua http_client policy: a private address needs its own +// permission, so a guest cannot use the host as a way into the local network. +func checkTarget(ctx context.Context, host string) error { + if host == "" || len(host) > maxHostLen { + return fmt.Errorf("host is empty or too long") + } + if ip := net.ParseIP(host); ip != nil { + if isPrivateIP(ip) && !security.IsAllowed(ctx, "http_client.private_ip", host, nil) { + return fmt.Errorf("not allowed: private IP %s", host) + } + return nil + } + ips, err := net.DefaultResolver.LookupIP(ctx, "ip", host) + if err != nil { + return fmt.Errorf("cannot resolve %s", host) + } + for _, ip := range ips { + if isPrivateIP(ip) && !security.IsAllowed(ctx, "http_client.private_ip", ip.String(), nil) { + return fmt.Errorf("not allowed: private IP %s", ip.String()) + } + } + return nil +} + +// Connect dials a TCP connection and retains it behind a handle. +func (h *Host) Connect(ctx context.Context, mod api.Module, stack []uint64) { + hostPtr, hostLen := uint32(stack[0]), uint32(stack[1]) + port, timeoutMS := uint32(stack[2]), uint32(stack[3]) + + if hostLen == 0 || hostLen > maxHostLen || port == 0 || port > 65535 { + stack[0] = pack(StatusBadRequest, 0) + return + } + raw, ok := mod.Memory().Read(hostPtr, hostLen) + if !ok { + stack[0] = pack(StatusBadRequest, 0) + return + } + host := string(raw) + target := net.JoinHostPort(host, fmt.Sprintf("%d", port)) + + if !security.IsAllowed(ctx, connectAction, target, nil) { + stack[0] = pack(StatusDenied, 0) + return + } + if err := checkTarget(ctx, host); err != nil { + if h.log != nil { + h.log.Debug("core socket connect denied", zap.String("target", target), zap.Error(err)) + } + stack[0] = pack(StatusDenied, 0) + return + } + + h.mu.Lock() + live := len(h.conns) + h.mu.Unlock() + if live >= maxConnections { + stack[0] = pack(StatusTooMany, 0) + return + } + + timeout := defaultTimeout + if timeoutMS > 0 { + timeout = time.Duration(timeoutMS) * time.Millisecond + } + dialer := net.Dialer{Timeout: timeout} + conn, err := dialer.DialContext(ctx, "tcp", target) + if err != nil { + if h.log != nil { + h.log.Debug("core socket connect failed", zap.String("target", target), zap.Error(err)) + } + stack[0] = pack(StatusTransport, 0) + return + } + + h.mu.Lock() + h.next++ + if h.next == 0 { + h.next = 1 + } + handle := h.next + h.conns[handle] = conn + h.mu.Unlock() + + stack[0] = pack(StatusOK, handle) +} + +func (h *Host) conn(handle uint32) (net.Conn, bool) { + h.mu.Lock() + defer h.mu.Unlock() + c, ok := h.conns[handle] + return c, ok +} + +// Send writes one chunk from guest memory. +func (h *Host) Send(_ context.Context, mod api.Module, stack []uint64) { + handle, bufPtr, bufLen := uint32(stack[0]), uint32(stack[1]), uint32(stack[2]) + + conn, ok := h.conn(handle) + if !ok { + stack[0] = pack(StatusUnknownHandle, 0) + return + } + if bufLen > maxChunkBytes { + stack[0] = pack(StatusBadRequest, 0) + return + } + payload, ok := mod.Memory().Read(bufPtr, bufLen) + if !ok { + stack[0] = pack(StatusBadRequest, 0) + return + } + + written, err := conn.Write(payload) + if err != nil { + stack[0] = pack(StatusTransport, uint32(written)) + return + } + stack[0] = pack(StatusOK, uint32(written)) +} + +// Recv reads one chunk into guest memory. A read of zero means the peer closed. +func (h *Host) Recv(_ context.Context, mod api.Module, stack []uint64) { + handle, outPtr, outCap := uint32(stack[0]), uint32(stack[1]), uint32(stack[2]) + + conn, ok := h.conn(handle) + if !ok { + stack[0] = pack(StatusUnknownHandle, 0) + return + } + if outCap == 0 || outCap > maxChunkBytes { + stack[0] = pack(StatusBadRequest, 0) + return + } + + buf := make([]byte, outCap) + n, err := conn.Read(buf) + if n > 0 { + if !mod.Memory().Write(outPtr, buf[:n]) { + stack[0] = pack(StatusBadRequest, 0) + return + } + } + if err != nil && n == 0 { + // EOF is not a transport failure: it is how the peer says it is done. + stack[0] = pack(StatusOK, 0) + return + } + stack[0] = pack(StatusOK, uint32(n)) +} + +// Close releases a connection. +func (h *Host) Close(_ context.Context, _ api.Module, stack []uint64) { + handle := uint32(stack[0]) + + h.mu.Lock() + conn, ok := h.conns[handle] + delete(h.conns, handle) + h.mu.Unlock() + + if !ok { + stack[0] = uint64(StatusUnknownHandle) + return + } + _ = conn.Close() + stack[0] = uint64(StatusOK) +} + +// CloseAll drops every retained connection; called when an instance goes away. +func (h *Host) CloseAll() { + h.mu.Lock() + conns := h.conns + h.conns = make(map[uint32]net.Conn) + h.mu.Unlock() + for _, c := range conns { + _ = c.Close() + } +} + +// LiveConnections reports retained connections, for tests and diagnostics. +func (h *Host) LiveConnections() int { + h.mu.Lock() + defer h.mu.Unlock() + return len(h.conns) +} From 82a276c7789783e5b4c1818fa5d00f0cf3edc984 Mon Sep 17 00:00:00 2001 From: Rodrigo Delduca Date: Fri, 31 Jul 2026 17:03:36 -0300 Subject: [PATCH 2/2] feat(wasm): route guest sockets through the selected overlay network A guest was dialed with a plain net.Dialer, so it ignored the network options every host-side caller obeys. In an application configured to route through an overlay that is a leak: the guest would reach clearnet, exposing DNS and the target address the overlay exists to hide. The dial now resolves the same way the Lua http_client and the HTTP dispatcher do: netapi.GetDefaultNetwork on the frame, which already carries the effective value after per-call options, per-entry meta.options.network and the app default have been merged. The network registry is read from the call context rather than captured at boot, so a guest follows the network its frame selected. Matching the host-side rules exactly: - network.select gates overlay use; without it the dial is refused, not downgraded. - An overlay requested with no registry configured is refused rather than dialed over clearnet, because a silent downgrade defeats the overlay. - No local DNS lookup happens for an overlay dial: the overlay resolves at its far end, and a local lookup would leak the hostname to the system resolver. - The private-address policy still applies, since an overlay is not a way around it. Four tests assert those, including that the overlay dialer is the one actually called and that a denied permission leaves it uncalled. --- boot/components/runtime/wasm/hosts.go | 48 +++--- .../host/wippy/hosts/coresock/coresock.go | 61 ++++++-- .../wippy/hosts/coresock/coresock_test.go | 138 ++++++++++++++++++ 3 files changed, 215 insertions(+), 32 deletions(-) create mode 100644 runtime/wasm/host/wippy/hosts/coresock/coresock_test.go diff --git a/boot/components/runtime/wasm/hosts.go b/boot/components/runtime/wasm/hosts.go index 69a7f36fc..c55811851 100644 --- a/boot/components/runtime/wasm/hosts.go +++ b/boot/components/runtime/wasm/hosts.go @@ -169,30 +169,36 @@ func coreSockProfile(log *zap.Logger) wasmcomponent.HostProfile { Register: func(_ context.Context, rt *wasmrt.Runtime) error { host := coresock.New(log) i32, i64 := api.ValueTypeI32, api.ValueTypeI64 - specs := []struct { - name string - params []api.ValueType - results []api.ValueType - fn api.GoModuleFunc - async bool - }{ - // Registered synchronous on purpose. Declaring them async makes the - // engine asyncify-transform the whole guest, and instrumenting a - // 10 MB CPython pushes wazero's compile past the runtime's 30s - // entry-load timeout. The guest call is synchronous anyway, so the - // host blocking on the socket is exactly the required behaviour. - {"connect", []api.ValueType{i32, i32, i32, i32}, []api.ValueType{i64}, host.Connect, false}, - {"send", []api.ValueType{i32, i32, i32}, []api.ValueType{i64}, host.Send, false}, - {"recv", []api.ValueType{i32, i32, i32}, []api.ValueType{i64}, host.Recv, false}, - {"close", []api.ValueType{i32}, []api.ValueType{i32}, host.Close, false}, - } - for _, spec := range specs { - if err := rt.RegisterCoreFunc( - coresock.Namespace, spec.name, spec.params, spec.results, spec.fn, spec.async, - ); err != nil { + + // Registered synchronous on purpose. Declaring them async makes the + // engine asyncify-transform the whole guest, and instrumenting a 10 MB + // interpreter pushes wazero's compile past the runtime's 30s entry-load + // timeout. The guest call is synchronous anyway, so the host blocking on + // the socket is exactly the required behavior. + register := func(name string, params, results []api.ValueType, fn api.GoModuleFunc) error { + if err := rt.RegisterCoreFunc(coresock.Namespace, name, params, results, fn, false); err != nil { return runtimewasm.NewRegisterHostError(coresock.Namespace, err) } + return nil + } + + if err := register("connect", + []api.ValueType{i32, i32, i32, i32}, []api.ValueType{i64}, host.Connect); err != nil { + return err } + if err := register("send", + []api.ValueType{i32, i32, i32}, []api.ValueType{i64}, host.Send); err != nil { + return err + } + if err := register("recv", + []api.ValueType{i32, i32, i32}, []api.ValueType{i64}, host.Recv); err != nil { + return err + } + if err := register("close", + []api.ValueType{i32}, []api.ValueType{i32}, host.Close); err != nil { + return err + } + log.Info("wasm host profile registered", zap.String("profile", coresock.Namespace)) return nil }, diff --git a/runtime/wasm/host/wippy/hosts/coresock/coresock.go b/runtime/wasm/host/wippy/hosts/coresock/coresock.go index 4b4d9a712..bed99fdbc 100644 --- a/runtime/wasm/host/wippy/hosts/coresock/coresock.go +++ b/runtime/wasm/host/wippy/hosts/coresock/coresock.go @@ -23,12 +23,15 @@ import ( "context" "fmt" "net" + "strings" "sync" "time" "github.com/tetratelabs/wazero/api" "go.uber.org/zap" + netapi "github.com/wippyai/runtime/api/net" + "github.com/wippyai/runtime/api/registry" "github.com/wippyai/runtime/runtime/security" ) @@ -68,6 +71,47 @@ func New(log *zap.Logger) *Host { return &Host{log: log, conns: make(map[uint32]net.Conn)} } +// dial resolves the same network options the Lua http_client honors, so a guest is +// routed exactly like host-side code in the same application. +// +// An overlay resolves DNS at its far end, so no local lookup happens for it: a local +// lookup would leak the target to the system resolver and defeat the overlay. The +// private-address policy still applies, because an overlay is not a way around it. +func (h *Host) dial(ctx context.Context, host, target string, timeout time.Duration) (net.Conn, error) { + overlayID := netapi.GetDefaultNetwork(ctx) + if overlayID == "" { + if err := checkTarget(ctx, host); err != nil { + return nil, err + } + dialer := net.Dialer{Timeout: timeout} + return dialer.DialContext(ctx, "tcp", target) + } + + if !security.IsAllowed(ctx, "network.select", overlayID, nil) { + return nil, fmt.Errorf("not allowed: network %s", overlayID) + } + // Refuse to fall back to clearnet when an overlay was asked for: falling back + // would leak DNS and the target address to the local network. + // Resolved per call, like the HTTP dispatcher does, so the guest follows the + // network the surrounding frame selected rather than one fixed at boot. + networkReg := netapi.GetNetworkRegistry(ctx) + if networkReg == nil { + return nil, fmt.Errorf("overlay network %q requested but no network registry is configured", overlayID) + } + svc, err := networkReg.GetNetwork(registry.ParseID(overlayID)) + if err != nil { + return nil, fmt.Errorf("overlay network %q: %w", overlayID, err) + } + if ip := net.ParseIP(host); ip != nil && isPrivateIP(ip) && + !security.IsAllowed(ctx, "http_client.private_ip", host, nil) { + return nil, fmt.Errorf("not allowed: private IP %s", host) + } + + dialCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + return svc.DialContext(dialCtx, "tcp", target) +} + func pack(status, value uint32) uint64 { return uint64(status)<<32 | uint64(value) } @@ -125,14 +169,6 @@ func (h *Host) Connect(ctx context.Context, mod api.Module, stack []uint64) { stack[0] = pack(StatusDenied, 0) return } - if err := checkTarget(ctx, host); err != nil { - if h.log != nil { - h.log.Debug("core socket connect denied", zap.String("target", target), zap.Error(err)) - } - stack[0] = pack(StatusDenied, 0) - return - } - h.mu.Lock() live := len(h.conns) h.mu.Unlock() @@ -145,11 +181,14 @@ func (h *Host) Connect(ctx context.Context, mod api.Module, stack []uint64) { if timeoutMS > 0 { timeout = time.Duration(timeoutMS) * time.Millisecond } - dialer := net.Dialer{Timeout: timeout} - conn, err := dialer.DialContext(ctx, "tcp", target) + conn, err := h.dial(ctx, host, target, timeout) if err != nil { if h.log != nil { - h.log.Debug("core socket connect failed", zap.String("target", target), zap.Error(err)) + h.log.Debug("core socket connect refused", zap.String("target", target), zap.Error(err)) + } + if strings.Contains(err.Error(), "not allowed") || strings.Contains(err.Error(), "overlay network") { + stack[0] = pack(StatusDenied, 0) + return } stack[0] = pack(StatusTransport, 0) return diff --git a/runtime/wasm/host/wippy/hosts/coresock/coresock_test.go b/runtime/wasm/host/wippy/hosts/coresock/coresock_test.go new file mode 100644 index 000000000..291a1cbbf --- /dev/null +++ b/runtime/wasm/host/wippy/hosts/coresock/coresock_test.go @@ -0,0 +1,138 @@ +// SPDX-License-Identifier: MPL-2.0 + +package coresock + +import ( + "context" + "errors" + "net" + "testing" + "time" + + ctxapi "github.com/wippyai/runtime/api/context" + netapi "github.com/wippyai/runtime/api/net" + "github.com/wippyai/runtime/api/registry" + securityapi "github.com/wippyai/runtime/api/security" +) + +// overlayContext selects an overlay network the way the runtime does: the effective +// ID lives on the FrameContext, written there by the executor from per-call options, +// per-entry meta.options.network, or the app default. +func overlayContext(t *testing.T, networkID string, permitted bool) context.Context { + t.Helper() + ctx := ctxapi.NewRootContext() + // Strict mode denies when the security context is incomplete, which is the + // behavior a guest sees by default. Relaxing it stands in for a grant. + ctx = securityapi.SetStrictMode(ctx, !permitted) + ctx, fc := ctxapi.OpenFrameContext(ctx) + t.Cleanup(func() { ctxapi.ReleaseFrameContext(fc) }) + if err := fc.SetMultiple(netapi.DefaultNetworkPair(networkID)); err != nil { + t.Fatalf("select overlay network: %v", err) + } + return ctx +} + +var errNotUsed = errors.New("not used by these tests") + +type stubRegistry struct { + svc netapi.Service + err error +} + +func (s stubRegistry) GetNetwork(registry.ID) (netapi.Service, error) { return s.svc, s.err } +func (s stubRegistry) HasNetwork(registry.ID) bool { return s.svc != nil } +func (s stubRegistry) NetworkKind(registry.ID) string { return "net.overlay" } + +type stubService struct { + dialed chan string +} + +// Only DialContext matters here; the rest of netapi.Service exists so the stub +// satisfies the interface the registry hands back. +func (s stubService) Listen(context.Context, string, string) (net.Listener, error) { + return nil, errNotUsed +} + +func (s stubService) ListenPacket(context.Context, string, string) (net.PacketConn, error) { + return nil, errNotUsed +} + +func (s stubService) LookupHost(context.Context, string) ([]string, error) { + return nil, errNotUsed +} + +func (s stubService) DialContext(_ context.Context, _, address string) (net.Conn, error) { + select { + case s.dialed <- address: + default: + } + client, server := net.Pipe() + go func() { _ = server.Close() }() + return client, nil +} + +// A guest must be routed by the network the application selected, exactly as +// host-side code is. Dialing clearnet instead would leak DNS and the target address. +func TestOverlayNetworkIsUsedWhenSelected(t *testing.T) { + dialed := make(chan string, 1) + ctx := netapi.WithNetworkRegistry( + overlayContext(t, "network:tor", true), stubRegistry{svc: stubService{dialed: dialed}}) + + host := New(nil) + conn, err := host.dial(ctx, "example.com", "example.com:443", 5*time.Second) + if err != nil { + t.Fatalf("dial through the overlay failed: %v", err) + } + defer conn.Close() + + select { + case address := <-dialed: + if address != "example.com:443" { + t.Errorf("overlay dialed %q, want example.com:443", address) + } + case <-time.After(time.Second): + t.Fatal("the overlay dialer was never called, so the guest bypassed it") + } +} + +// Falling back to clearnet when an overlay was asked for would defeat the overlay +// silently, which is worse than refusing. +func TestOverlayRequestedWithoutRegistryIsRefused(t *testing.T) { + ctx := overlayContext(t, "network:tor", true) + + host := New(nil) + conn, err := host.dial(ctx, "example.com", "example.com:443", time.Second) + if err == nil { + conn.Close() + t.Fatal("an overlay request with no registry must be refused, not dialed over clearnet") + } + t.Logf("refused: %v", err) +} + +func TestPrivateAddressNeedsPermission(t *testing.T) { + host := New(nil) + _, err := host.dial(context.Background(), "127.0.0.1", "127.0.0.1:9", time.Second) + if err == nil { + t.Fatal("a loopback address must require the private-IP permission") + } + t.Logf("refused: %v", err) +} + +// network.select is what gates overlay use for Lua, and it gates the guest the same +// way: without the permission the dial is refused rather than downgraded. +func TestOverlaySelectionNeedsPermission(t *testing.T) { + dialed := make(chan string, 1) + ctx := netapi.WithNetworkRegistry( + overlayContext(t, "network:tor", false), stubRegistry{svc: stubService{dialed: dialed}}) + + host := New(nil) + conn, err := host.dial(ctx, "example.com", "example.com:443", time.Second) + if err == nil { + conn.Close() + t.Fatal("network.select was not granted, so the dial must be refused") + } + if len(dialed) != 0 { + t.Fatal("the overlay was dialed despite the permission being denied") + } + t.Logf("refused: %v", err) +}