diff --git a/sandbox/platform/bubblewrap_translator_linux.go b/sandbox/platform/bubblewrap_translator_linux.go index be217ca..e3b49aa 100644 --- a/sandbox/platform/bubblewrap_translator_linux.go +++ b/sandbox/platform/bubblewrap_translator_linux.go @@ -126,6 +126,11 @@ func (t *bubblewrapPolicyTranslator) addIsolationNamespaces(policy *sandbox.Sand log.Debugf("Network allowed (no --unshare-net)") } + // Note: AllowNetworkBind and Network.AllowBind are not handled here because + // bwrap's --unshare-net creates a namespace with loopback available, so + // localhost binding already works. Non-localhost binding requires full host + // network (no --unshare-net), which is controlled by AllowOutbound rules. + // PID namespace isolation if t.config.unsharePID { args = append(args, "--unshare-pid") diff --git a/sandbox/platform/seatbelt_translator_darwin.go b/sandbox/platform/seatbelt_translator_darwin.go index cc0c3e5..272fbe0 100644 --- a/sandbox/platform/seatbelt_translator_darwin.go +++ b/sandbox/platform/seatbelt_translator_darwin.go @@ -555,6 +555,22 @@ func (t *seatbeltPolicyTranslator) translateNetwork(policy *sandbox.SandboxPolic sb.WriteString("\n") + // Network bind rules for local listening + if utils.SafelyGetValue(policy.AllowNetworkBind) { + sb.WriteString(";; Local network bind (localhost only)\n") + sb.WriteString("(allow network-bind (local ip \"localhost:*\"))\n") + sb.WriteString("(allow network* (local ip \"localhost:*\"))\n") + } + + for _, addr := range policy.Network.AllowBind { + sb.WriteString(fmt.Sprintf("(allow network-bind (local ip \"%s\"))\n", addr)) + sb.WriteString(fmt.Sprintf("(allow network* (local ip \"%s\"))\n", addr)) + } + + if utils.SafelyGetValue(policy.AllowNetworkBind) || len(policy.Network.AllowBind) > 0 { + sb.WriteString("\n") + } + return nil } diff --git a/sandbox/platform/seatbelt_translator_darwin_test.go b/sandbox/platform/seatbelt_translator_darwin_test.go index d251be2..637872b 100644 --- a/sandbox/platform/seatbelt_translator_darwin_test.go +++ b/sandbox/platform/seatbelt_translator_darwin_test.go @@ -572,6 +572,84 @@ func TestPTYSupport(t *testing.T) { }) } +func TestNetworkBindSupport(t *testing.T) { + t.Run("AllowNetworkBind true with no AllowBind generates localhost-only bind rule", func(t *testing.T) { + policy := &sandbox.SandboxPolicy{ + Name: "test", + Description: "test with network bind", + PackageManagers: []string{"npm"}, + AllowNetworkBind: utils.PtrTo(true), + } + + translator := newSeatbeltPolicyTranslator() + actual, err := translator.translate(policy) + assert.NoError(t, err) + + assert.Contains(t, actual, ";; Local network bind (localhost only)") + assert.Contains(t, actual, `(allow network-bind (local ip "localhost:*"))`) + assert.Contains(t, actual, `(allow network* (local ip "localhost:*"))`) + // Should not contain any explicit bind addresses + assert.NotContains(t, actual, `(allow network-bind (local ip "0.0.0.0:*"))`) + }) + + t.Run("AllowNetworkBind true with AllowBind generates both rules", func(t *testing.T) { + policy := &sandbox.SandboxPolicy{ + Name: "test", + Description: "test with network bind and explicit addresses", + PackageManagers: []string{"npm"}, + AllowNetworkBind: utils.PtrTo(true), + Network: sandbox.NetworkPolicy{ + AllowBind: []string{"0.0.0.0:*"}, + }, + } + + translator := newSeatbeltPolicyTranslator() + actual, err := translator.translate(policy) + assert.NoError(t, err) + + // Localhost bind from AllowNetworkBind + assert.Contains(t, actual, `(allow network-bind (local ip "localhost:*"))`) + // Explicit bind address + assert.Contains(t, actual, `(allow network-bind (local ip "0.0.0.0:*"))`) + }) + + t.Run("AllowNetworkBind false with AllowBind generates only explicit bind rule", func(t *testing.T) { + policy := &sandbox.SandboxPolicy{ + Name: "test", + Description: "test with explicit bind only", + PackageManagers: []string{"npm"}, + AllowNetworkBind: utils.PtrTo(false), + Network: sandbox.NetworkPolicy{ + AllowBind: []string{"0.0.0.0:*"}, + }, + } + + translator := newSeatbeltPolicyTranslator() + actual, err := translator.translate(policy) + assert.NoError(t, err) + + // Should NOT contain localhost bind + assert.NotContains(t, actual, ";; Local network bind (localhost only)") + assert.NotContains(t, actual, `(allow network-bind (local ip "localhost:*"))`) + // Should contain explicit bind address + assert.Contains(t, actual, `(allow network-bind (local ip "0.0.0.0:*"))`) + }) + + t.Run("AllowNetworkBind nil with no AllowBind generates no bind rules", func(t *testing.T) { + policy := &sandbox.SandboxPolicy{ + Name: "test", + Description: "test without network bind", + PackageManagers: []string{"npm"}, + } + + translator := newSeatbeltPolicyTranslator() + actual, err := translator.translate(policy) + assert.NoError(t, err) + + assert.NotContains(t, actual, "network-bind") + }) +} + func TestSeatbeltTranslatorDarwinLogTag(t *testing.T) { translator := newSeatbeltPolicyTranslator() assert.NotEmpty(t, translator.LogTag()) diff --git a/sandbox/policy.go b/sandbox/policy.go index 8aa1d16..7955703 100644 --- a/sandbox/policy.go +++ b/sandbox/policy.go @@ -34,6 +34,9 @@ type SandboxPolicy struct { // AllowPTY allows pseudo-terminal (PTY) operations. AllowPTY *bool `yaml:"allow_pty" json:"allow_pty"` + + // AllowNetworkBind allows binding to localhost (127.0.0.1 / ::1) for listening. + AllowNetworkBind *bool `yaml:"allow_network_bind" json:"allow_network_bind"` } // FilesystemPolicy defines allowed and denied filesystem access patterns. @@ -50,6 +53,7 @@ type FilesystemPolicy struct { type NetworkPolicy struct { AllowOutbound []string `yaml:"allow_outbound" json:"allow_outbound"` DenyOutbound []string `yaml:"deny_outbound" json:"deny_outbound"` + AllowBind []string `yaml:"allow_bind" json:"allow_bind"` } // ProcessPolicy defines allowed and denied process execution patterns. @@ -124,6 +128,7 @@ func (child *SandboxPolicy) MergeWithParent(parent *SandboxPolicy) { // Union network lists child.Network.AllowOutbound = unionStringSlices(parent.Network.AllowOutbound, child.Network.AllowOutbound) child.Network.DenyOutbound = unionStringSlices(parent.Network.DenyOutbound, child.Network.DenyOutbound) + child.Network.AllowBind = unionStringSlices(parent.Network.AllowBind, child.Network.AllowBind) // Union process lists child.Process.AllowExec = unionStringSlices(parent.Process.AllowExec, child.Process.AllowExec) @@ -137,6 +142,10 @@ func (child *SandboxPolicy) MergeWithParent(parent *SandboxPolicy) { if child.AllowGitConfig == nil { child.AllowGitConfig = utils.PtrTo(utils.SafelyGetValue(parent.AllowGitConfig)) } + + if child.AllowNetworkBind == nil { + child.AllowNetworkBind = utils.PtrTo(utils.SafelyGetValue(parent.AllowNetworkBind)) + } } // unionStringSlices returns a new slice containing all unique elements from both slices. diff --git a/sandbox/profiles/npx.yml b/sandbox/profiles/npx.yml index 8c110ef..1cd6977 100644 --- a/sandbox/profiles/npx.yml +++ b/sandbox/profiles/npx.yml @@ -13,6 +13,9 @@ package_managers: # in parent profiles in the future. allow_pty: true +# npx generators and dev servers frequently need to bind to localhost ports +allow_network_bind: true + filesystem: # Add write permissions for common generator outputs allow_write: