feat: native allowedDomains/deniedDomains network filtering #1
@@ -43,10 +43,10 @@ jobs:
|
||||
run: go mod download
|
||||
|
||||
- name: Lint
|
||||
uses: golangci/golangci-lint-action@v6
|
||||
uses: golangci/golangci-lint-action@v7
|
||||
with:
|
||||
install-mode: goinstall
|
||||
version: v1.64.8
|
||||
install-mode: binary
|
||||
version: v2.1.6
|
||||
|
||||
test-linux:
|
||||
name: Test (Linux)
|
||||
@@ -105,6 +105,9 @@ jobs:
|
||||
echo "=== Testing bwrap with user namespace ==="
|
||||
bwrap --ro-bind / / --unshare-user --uid 0 --gid 0 -- /bin/echo "bwrap user namespace works!"
|
||||
|
||||
- name: Download tun2socks binaries
|
||||
run: make download-tun2socks
|
||||
|
||||
- name: Run unit and integration tests
|
||||
run: make test-ci
|
||||
|
||||
|
||||
@@ -233,13 +233,15 @@ func runCommand(cmd *cobra.Command, args []string) error {
|
||||
|
||||
// GreyHaven defaults: when no proxy or DNS is configured (neither via CLI
|
||||
// nor config file), use the standard GreyHaven infrastructure ports.
|
||||
if cfg.Network.ProxyURL == "" {
|
||||
// Skip GreyHaven proxy default when domain filtering is configured —
|
||||
// the filtering proxy handles outbound instead.
|
||||
if cfg.Network.ProxyURL == "" && !cfg.Network.HasDomainFiltering() {
|
||||
cfg.Network.ProxyURL = "socks5://localhost:42052"
|
||||
if debug {
|
||||
fmt.Fprintf(os.Stderr, "[greywall] Defaulting proxy to socks5://localhost:42052\n")
|
||||
}
|
||||
}
|
||||
if cfg.Network.DnsAddr == "" {
|
||||
if cfg.Network.DnsAddr == "" && !cfg.Network.HasDomainFiltering() {
|
||||
cfg.Network.DnsAddr = "localhost:42053"
|
||||
if debug {
|
||||
fmt.Fprintf(os.Stderr, "[greywall] Defaulting DNS to localhost:42053\n")
|
||||
|
||||
@@ -28,6 +28,8 @@ type Config struct {
|
||||
type NetworkConfig struct {
|
||||
ProxyURL string `json:"proxyUrl,omitempty"` // External SOCKS5 proxy (e.g. socks5://host:1080)
|
||||
DnsAddr string `json:"dnsAddr,omitempty"` // DNS server address on host (e.g. localhost:3153)
|
||||
AllowedDomains []string `json:"allowedDomains,omitempty"` // Domains to allow outbound connections to (supports wildcards)
|
||||
DeniedDomains []string `json:"deniedDomains,omitempty"` // Domains to deny outbound connections to (checked before allowed)
|
||||
AllowUnixSockets []string `json:"allowUnixSockets,omitempty"`
|
||||
AllowAllUnixSockets bool `json:"allowAllUnixSockets,omitempty"`
|
||||
AllowLocalBinding bool `json:"allowLocalBinding,omitempty"`
|
||||
@@ -209,6 +211,17 @@ func (c *Config) Validate() error {
|
||||
}
|
||||
}
|
||||
|
||||
for _, domain := range c.Network.AllowedDomains {
|
||||
if err := validateDomainPattern(domain); err != nil {
|
||||
return fmt.Errorf("invalid network.allowedDomains %q: %w", domain, err)
|
||||
}
|
||||
}
|
||||
for _, domain := range c.Network.DeniedDomains {
|
||||
if err := validateDomainPattern(domain); err != nil {
|
||||
return fmt.Errorf("invalid network.deniedDomains %q: %w", domain, err)
|
||||
}
|
||||
}
|
||||
|
||||
if slices.Contains(c.Filesystem.AllowRead, "") {
|
||||
return errors.New("filesystem.allowRead contains empty path")
|
||||
}
|
||||
@@ -384,6 +397,84 @@ func matchGlob(s, pattern string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// validateDomainPattern validates a domain pattern for allowedDomains/deniedDomains.
|
||||
// Rejects patterns with protocol, path, port, or empty strings.
|
||||
func validateDomainPattern(pattern string) error {
|
||||
if pattern == "" {
|
||||
return errors.New("empty domain pattern")
|
||||
}
|
||||
|
||||
// Allow wildcard-all
|
||||
if pattern == "*" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Reject patterns with protocol
|
||||
if strings.Contains(pattern, "://") {
|
||||
return errors.New("domain pattern cannot contain protocol (remove http:// or https://)")
|
||||
}
|
||||
|
||||
// Reject patterns with path
|
||||
if strings.Contains(pattern, "/") {
|
||||
return errors.New("domain pattern cannot contain path")
|
||||
}
|
||||
|
||||
// Reject patterns with port
|
||||
if strings.Contains(pattern, ":") {
|
||||
return errors.New("domain pattern cannot contain port")
|
||||
}
|
||||
|
||||
// Reject patterns with @
|
||||
if strings.Contains(pattern, "@") {
|
||||
return errors.New("domain pattern cannot contain username")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsDomainAllowed checks if a hostname is allowed by the domain filtering rules.
|
||||
// Strips port from hostname. Deny rules are checked first (deny wins).
|
||||
// If AllowedDomains is set, the domain must match at least one allowed pattern.
|
||||
// If only DeniedDomains is set, all domains except denied ones are allowed.
|
||||
func (n *NetworkConfig) IsDomainAllowed(hostname string) bool {
|
||||
// Strip port if present
|
||||
if host, _, found := strings.Cut(hostname, ":"); found {
|
||||
hostname = host
|
||||
}
|
||||
|
||||
// Check denied domains first (deny wins)
|
||||
for _, pattern := range n.DeniedDomains {
|
||||
if MatchesHost(hostname, pattern) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// If no allowed domains configured, allow all (only deny list is active)
|
||||
if len(n.AllowedDomains) == 0 {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check allowed domains
|
||||
for _, pattern := range n.AllowedDomains {
|
||||
if MatchesHost(hostname, pattern) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Not in allow list
|
||||
return false
|
||||
}
|
||||
|
||||
// HasDomainFiltering returns true when domain filtering is configured.
|
||||
func (n *NetworkConfig) HasDomainFiltering() bool {
|
||||
return len(n.AllowedDomains) > 0 || len(n.DeniedDomains) > 0
|
||||
}
|
||||
|
||||
// IsWildcardAllow returns true when AllowedDomains contains "*" (allow all).
|
||||
func (n *NetworkConfig) IsWildcardAllow() bool {
|
||||
return slices.Contains(n.AllowedDomains, "*")
|
||||
}
|
||||
|
||||
// Merge combines a base config with an override config.
|
||||
// Values in override take precedence. Slice fields are appended (base + override).
|
||||
// The Extends field is cleared in the result since inheritance has been resolved.
|
||||
@@ -411,6 +502,10 @@ func Merge(base, override *Config) *Config {
|
||||
ProxyURL: mergeString(base.Network.ProxyURL, override.Network.ProxyURL),
|
||||
DnsAddr: mergeString(base.Network.DnsAddr, override.Network.DnsAddr),
|
||||
|
||||
// Append domain slices
|
||||
AllowedDomains: mergeStrings(base.Network.AllowedDomains, override.Network.AllowedDomains),
|
||||
DeniedDomains: mergeStrings(base.Network.DeniedDomains, override.Network.DeniedDomains),
|
||||
|
||||
// Append slices (base first, then override additions)
|
||||
AllowUnixSockets: mergeStrings(base.Network.AllowUnixSockets, override.Network.AllowUnixSockets),
|
||||
|
||||
|
||||
@@ -668,6 +668,265 @@ func TestMergeSSHConfig(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestValidateDomainPattern(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
pattern string
|
||||
wantErr bool
|
||||
}{
|
||||
// Valid patterns
|
||||
{"simple domain", "example.com", false},
|
||||
{"subdomain", "api.example.com", false},
|
||||
{"wildcard prefix", "*.example.com", false},
|
||||
{"wildcard all", "*", false},
|
||||
{"wildcard middle", "api-*.example.com", false},
|
||||
{"localhost", "localhost", false},
|
||||
|
||||
// Invalid patterns
|
||||
{"empty", "", true},
|
||||
{"with protocol http", "http://example.com", true},
|
||||
{"with protocol https", "https://example.com", true},
|
||||
{"with path", "example.com/path", true},
|
||||
{"with port", "example.com:443", true},
|
||||
{"with username", "user@example.com", true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := validateDomainPattern(tt.pattern)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("validateDomainPattern(%q) error = %v, wantErr %v", tt.pattern, err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsDomainAllowed(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
allowedDomains []string
|
||||
deniedDomains []string
|
||||
hostname string
|
||||
want bool
|
||||
}{
|
||||
// Allow list only
|
||||
{"allowed exact match", []string{"api.example.com"}, nil, "api.example.com", true},
|
||||
{"allowed wildcard match", []string{"*.example.com"}, nil, "api.example.com", true},
|
||||
{"allowed no match", []string{"api.example.com"}, nil, "other.com", false},
|
||||
{"allowed wildcard all", []string{"*"}, nil, "anything.com", true},
|
||||
{"allowed multiple", []string{"api.example.com", "cdn.example.com"}, nil, "cdn.example.com", true},
|
||||
|
||||
// Deny list only (no allow list = allow all except denied)
|
||||
{"denied only - not denied", nil, []string{"evil.com"}, "good.com", true},
|
||||
{"denied only - denied match", nil, []string{"evil.com"}, "evil.com", false},
|
||||
{"denied only - wildcard deny", nil, []string{"*.evil.com"}, "sub.evil.com", false},
|
||||
|
||||
// Both allow and deny (deny wins)
|
||||
{"deny wins over allow", []string{"*.example.com"}, []string{"secret.example.com"}, "secret.example.com", false},
|
||||
{"allow works when not denied", []string{"*.example.com"}, []string{"secret.example.com"}, "api.example.com", true},
|
||||
{"not in allow after deny check", []string{"*.example.com"}, []string{"evil.com"}, "other.com", false},
|
||||
|
||||
// Port stripping
|
||||
{"strips port", []string{"api.example.com"}, nil, "api.example.com:443", true},
|
||||
{"strips port denied", nil, []string{"evil.com"}, "evil.com:8080", false},
|
||||
|
||||
// Empty allow list = allow all (only deny active)
|
||||
{"empty allow list allows all", nil, nil, "anything.com", true},
|
||||
{"empty both allows all", []string{}, []string{}, "anything.com", true},
|
||||
|
||||
// Case insensitive
|
||||
{"case insensitive", []string{"API.Example.COM"}, nil, "api.example.com", true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
nc := &NetworkConfig{
|
||||
AllowedDomains: tt.allowedDomains,
|
||||
DeniedDomains: tt.deniedDomains,
|
||||
}
|
||||
got := nc.IsDomainAllowed(tt.hostname)
|
||||
if got != tt.want {
|
||||
t.Errorf("IsDomainAllowed(%q) = %v, want %v (allowed=%v, denied=%v)",
|
||||
tt.hostname, got, tt.want, tt.allowedDomains, tt.deniedDomains)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasDomainFiltering(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
allowedDomains []string
|
||||
deniedDomains []string
|
||||
want bool
|
||||
}{
|
||||
{"no domains", nil, nil, false},
|
||||
{"empty slices", []string{}, []string{}, false},
|
||||
{"allowed only", []string{"example.com"}, nil, true},
|
||||
{"denied only", nil, []string{"evil.com"}, true},
|
||||
{"both", []string{"example.com"}, []string{"evil.com"}, true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
nc := &NetworkConfig{
|
||||
AllowedDomains: tt.allowedDomains,
|
||||
DeniedDomains: tt.deniedDomains,
|
||||
}
|
||||
got := nc.HasDomainFiltering()
|
||||
if got != tt.want {
|
||||
t.Errorf("HasDomainFiltering() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsWildcardAllow(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
allowedDomains []string
|
||||
want bool
|
||||
}{
|
||||
{"nil", nil, false},
|
||||
{"empty", []string{}, false},
|
||||
{"specific domain", []string{"example.com"}, false},
|
||||
{"wildcard", []string{"*"}, true},
|
||||
{"wildcard among others", []string{"example.com", "*"}, true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
nc := &NetworkConfig{AllowedDomains: tt.allowedDomains}
|
||||
got := nc.IsWildcardAllow()
|
||||
if got != tt.want {
|
||||
t.Errorf("IsWildcardAllow() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigValidateDomains(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
config Config
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "valid allowed domains",
|
||||
config: Config{
|
||||
Network: NetworkConfig{
|
||||
AllowedDomains: []string{"api.example.com", "*.cdn.example.com"},
|
||||
},
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid denied domains",
|
||||
config: Config{
|
||||
Network: NetworkConfig{
|
||||
DeniedDomains: []string{"evil.com", "*.malware.com"},
|
||||
},
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "invalid allowed domain with protocol",
|
||||
config: Config{
|
||||
Network: NetworkConfig{
|
||||
AllowedDomains: []string{"https://example.com"},
|
||||
},
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "invalid denied domain with port",
|
||||
config: Config{
|
||||
Network: NetworkConfig{
|
||||
DeniedDomains: []string{"example.com:443"},
|
||||
},
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "invalid allowed domain empty",
|
||||
config: Config{
|
||||
Network: NetworkConfig{
|
||||
AllowedDomains: []string{""},
|
||||
},
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := tt.config.Validate()
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("Config.Validate() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeDomainConfig(t *testing.T) {
|
||||
t.Run("merge allowed domains", func(t *testing.T) {
|
||||
base := &Config{
|
||||
Network: NetworkConfig{
|
||||
AllowedDomains: []string{"api.example.com"},
|
||||
},
|
||||
}
|
||||
override := &Config{
|
||||
Network: NetworkConfig{
|
||||
AllowedDomains: []string{"cdn.example.com"},
|
||||
},
|
||||
}
|
||||
result := Merge(base, override)
|
||||
|
||||
if len(result.Network.AllowedDomains) != 2 {
|
||||
t.Errorf("expected 2 allowed domains, got %d: %v",
|
||||
len(result.Network.AllowedDomains), result.Network.AllowedDomains)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("merge denied domains", func(t *testing.T) {
|
||||
base := &Config{
|
||||
Network: NetworkConfig{
|
||||
DeniedDomains: []string{"evil.com"},
|
||||
},
|
||||
}
|
||||
override := &Config{
|
||||
Network: NetworkConfig{
|
||||
DeniedDomains: []string{"malware.com"},
|
||||
},
|
||||
}
|
||||
result := Merge(base, override)
|
||||
|
||||
if len(result.Network.DeniedDomains) != 2 {
|
||||
t.Errorf("expected 2 denied domains, got %d: %v",
|
||||
len(result.Network.DeniedDomains), result.Network.DeniedDomains)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("merge deduplicates domains", func(t *testing.T) {
|
||||
base := &Config{
|
||||
Network: NetworkConfig{
|
||||
AllowedDomains: []string{"api.example.com"},
|
||||
},
|
||||
}
|
||||
override := &Config{
|
||||
Network: NetworkConfig{
|
||||
AllowedDomains: []string{"api.example.com"},
|
||||
},
|
||||
}
|
||||
result := Merge(base, override)
|
||||
|
||||
if len(result.Network.AllowedDomains) != 1 {
|
||||
t.Errorf("expected 1 allowed domain (deduped), got %d: %v",
|
||||
len(result.Network.AllowedDomains), result.Network.AllowedDomains)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestValidateProxyURL(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||
@@ -594,8 +594,8 @@ func isSystemMountPoint(path string) bool {
|
||||
|
||||
// WrapCommandLinux wraps a command with Linux bubblewrap sandbox.
|
||||
// It uses available security features (Landlock, seccomp) with graceful fallback.
|
||||
func WrapCommandLinux(cfg *config.Config, command string, proxyBridge *ProxyBridge, dnsBridge *DnsBridge, reverseBridge *ReverseBridge, tun2socksPath string, debug bool) (string, error) {
|
||||
return WrapCommandLinuxWithOptions(cfg, command, proxyBridge, dnsBridge, reverseBridge, tun2socksPath, LinuxSandboxOptions{
|
||||
func WrapCommandLinux(cfg *config.Config, command string, proxyBridge *ProxyBridge, dnsBridge *DnsBridge, reverseBridge *ReverseBridge, tun2socksPath string, filterProxy *FilteringProxy, debug bool) (string, error) {
|
||||
return WrapCommandLinuxWithOptions(cfg, command, proxyBridge, dnsBridge, reverseBridge, tun2socksPath, filterProxy, LinuxSandboxOptions{
|
||||
UseLandlock: true, // Enabled by default, will fall back if not available
|
||||
UseSeccomp: true, // Enabled by default
|
||||
UseEBPF: true, // Enabled by default if available
|
||||
@@ -604,7 +604,7 @@ func WrapCommandLinux(cfg *config.Config, command string, proxyBridge *ProxyBrid
|
||||
}
|
||||
|
||||
// WrapCommandLinuxWithOptions wraps a command with configurable sandbox options.
|
||||
func WrapCommandLinuxWithOptions(cfg *config.Config, command string, proxyBridge *ProxyBridge, dnsBridge *DnsBridge, reverseBridge *ReverseBridge, tun2socksPath string, opts LinuxSandboxOptions) (string, error) {
|
||||
func WrapCommandLinuxWithOptions(cfg *config.Config, command string, proxyBridge *ProxyBridge, dnsBridge *DnsBridge, reverseBridge *ReverseBridge, tun2socksPath string, filterProxy *FilteringProxy, opts LinuxSandboxOptions) (string, error) {
|
||||
if _, err := exec.LookPath("bwrap"); err != nil {
|
||||
return "", fmt.Errorf("bubblewrap (bwrap) is required on Linux but not found: %w", err)
|
||||
}
|
||||
@@ -636,12 +636,20 @@ func WrapCommandLinuxWithOptions(cfg *config.Config, command string, proxyBridge
|
||||
bwrapArgs = append(bwrapArgs, "--die-with-parent")
|
||||
|
||||
// Always use --unshare-net when available (network namespace isolation)
|
||||
// Inside the namespace, tun2socks will provide transparent proxy access
|
||||
if features.CanUnshareNet {
|
||||
// Inside the namespace, tun2socks will provide transparent proxy access.
|
||||
// Skip network namespace when domain filtering proxy is active — the proxy
|
||||
// listens on the host's 127.0.0.1 and uses env-var-based proxying, which
|
||||
// requires the sandboxed process to reach the host network.
|
||||
skipUnshareNet := filterProxy != nil
|
||||
if features.CanUnshareNet && !skipUnshareNet {
|
||||
bwrapArgs = append(bwrapArgs, "--unshare-net") // Network namespace isolation
|
||||
} else if opts.Debug {
|
||||
if skipUnshareNet {
|
||||
fmt.Fprintf(os.Stderr, "[greywall:linux] Skipping --unshare-net (domain filtering proxy active)\n")
|
||||
} else {
|
||||
fmt.Fprintf(os.Stderr, "[greywall:linux] Skipping --unshare-net (network namespace unavailable in this environment)\n")
|
||||
}
|
||||
}
|
||||
|
||||
bwrapArgs = append(bwrapArgs, "--unshare-pid") // PID namespace isolation
|
||||
|
||||
@@ -1042,6 +1050,23 @@ export no_proxy=localhost,127.0.0.1
|
||||
`, proxyBridge.SocketPath))
|
||||
}
|
||||
|
||||
// Set up domain filtering proxy env vars inside the sandbox.
|
||||
// When filterProxy is active, skip tun2socks and use env-var-based proxying
|
||||
// through a socat bridge to the host-side filtering proxy.
|
||||
if filterProxy != nil && proxyBridge == nil {
|
||||
filterProxyAddr := filterProxy.Addr()
|
||||
innerScript.WriteString(fmt.Sprintf(`
|
||||
# Domain filtering proxy: bridge to host-side filtering proxy
|
||||
export HTTP_PROXY=http://%s
|
||||
export HTTPS_PROXY=http://%s
|
||||
export http_proxy=http://%s
|
||||
export https_proxy=http://%s
|
||||
export NO_PROXY=localhost,127.0.0.1
|
||||
export no_proxy=localhost,127.0.0.1
|
||||
|
||||
`, filterProxyAddr, filterProxyAddr, filterProxyAddr, filterProxyAddr))
|
||||
}
|
||||
|
||||
// Set up reverse (inbound) socat listeners inside the sandbox
|
||||
if reverseBridge != nil && len(reverseBridge.Ports) > 0 {
|
||||
innerScript.WriteString("\n# Start reverse bridge listeners for inbound connections\n")
|
||||
|
||||
@@ -63,12 +63,12 @@ func NewReverseBridge(ports []int, debug bool) (*ReverseBridge, error) {
|
||||
func (b *ReverseBridge) Cleanup() {}
|
||||
|
||||
// WrapCommandLinux returns an error on non-Linux platforms.
|
||||
func WrapCommandLinux(cfg *config.Config, command string, proxyBridge *ProxyBridge, dnsBridge *DnsBridge, reverseBridge *ReverseBridge, tun2socksPath string, debug bool) (string, error) {
|
||||
func WrapCommandLinux(cfg *config.Config, command string, proxyBridge *ProxyBridge, dnsBridge *DnsBridge, reverseBridge *ReverseBridge, tun2socksPath string, filterProxy *FilteringProxy, debug bool) (string, error) {
|
||||
return "", fmt.Errorf("Linux sandbox not available on this platform")
|
||||
}
|
||||
|
||||
// WrapCommandLinuxWithOptions returns an error on non-Linux platforms.
|
||||
func WrapCommandLinuxWithOptions(cfg *config.Config, command string, proxyBridge *ProxyBridge, dnsBridge *DnsBridge, reverseBridge *ReverseBridge, tun2socksPath string, opts LinuxSandboxOptions) (string, error) {
|
||||
func WrapCommandLinuxWithOptions(cfg *config.Config, command string, proxyBridge *ProxyBridge, dnsBridge *DnsBridge, reverseBridge *ReverseBridge, tun2socksPath string, filterProxy *FilteringProxy, opts LinuxSandboxOptions) (string, error) {
|
||||
return "", fmt.Errorf("Linux sandbox not available on this platform")
|
||||
}
|
||||
|
||||
|
||||
@@ -631,7 +631,7 @@ func GenerateSandboxProfile(params MacOSSandboxParams) string {
|
||||
}
|
||||
|
||||
// WrapCommandMacOS wraps a command with macOS sandbox restrictions.
|
||||
func WrapCommandMacOS(cfg *config.Config, command string, exposedPorts []int, debug bool) (string, error) {
|
||||
func WrapCommandMacOS(cfg *config.Config, command string, exposedPorts []int, filterProxy *FilteringProxy, debug bool) (string, error) {
|
||||
cwd, _ := os.Getwd()
|
||||
|
||||
// Build allow paths: default + configured
|
||||
@@ -650,7 +650,12 @@ func WrapCommandMacOS(cfg *config.Config, command string, exposedPorts []int, de
|
||||
|
||||
// Parse proxy URL for network rules
|
||||
var proxyHost, proxyPort string
|
||||
if cfg.Network.ProxyURL != "" {
|
||||
if filterProxy != nil {
|
||||
// Domain filtering proxy: point at the local filtering proxy.
|
||||
// Seatbelt only accepts "localhost" or "*" in (remote ip ...) filters.
|
||||
proxyHost = "localhost"
|
||||
proxyPort = filterProxy.Port()
|
||||
} else if cfg.Network.ProxyURL != "" {
|
||||
if u, err := url.Parse(cfg.Network.ProxyURL); err == nil {
|
||||
proxyHost = u.Hostname()
|
||||
proxyPort = u.Port()
|
||||
@@ -659,7 +664,11 @@ func WrapCommandMacOS(cfg *config.Config, command string, exposedPorts []int, de
|
||||
|
||||
// Restrict network unless proxy is configured to an external host
|
||||
// If no proxy: block all outbound. If proxy: allow outbound only to proxy.
|
||||
// When wildcard allow with domain filtering, allow direct outbound (proxy enforces via env vars)
|
||||
needsNetworkRestriction := true
|
||||
if filterProxy != nil && cfg.Network.IsWildcardAllow() {
|
||||
needsNetworkRestriction = false
|
||||
}
|
||||
|
||||
params := MacOSSandboxParams{
|
||||
Command: command,
|
||||
@@ -700,7 +709,12 @@ func WrapCommandMacOS(cfg *config.Config, command string, exposedPorts []int, de
|
||||
return "", fmt.Errorf("shell %q not found: %w", shell, err)
|
||||
}
|
||||
|
||||
proxyEnvs := GenerateProxyEnvVars(cfg.Network.ProxyURL)
|
||||
var proxyEnvs []string
|
||||
if filterProxy != nil {
|
||||
proxyEnvs = GenerateHTTPProxyEnvVars(fmt.Sprintf("http://127.0.0.1:%s", filterProxy.Port()))
|
||||
} else {
|
||||
proxyEnvs = GenerateProxyEnvVars(cfg.Network.ProxyURL)
|
||||
}
|
||||
|
||||
// Build the command
|
||||
// env VAR1=val1 VAR2=val2 sandbox-exec -p 'profile' shell -c 'command'
|
||||
|
||||
@@ -14,6 +14,7 @@ type Manager struct {
|
||||
proxyBridge *ProxyBridge
|
||||
dnsBridge *DnsBridge
|
||||
reverseBridge *ReverseBridge
|
||||
filterProxy *FilteringProxy
|
||||
tun2socksPath string // path to extracted tun2socks binary on host
|
||||
exposedPorts []int
|
||||
debug bool
|
||||
@@ -118,6 +119,36 @@ func (m *Manager) Initialize() error {
|
||||
}
|
||||
}
|
||||
|
||||
// Start domain filtering proxy if allowedDomains/deniedDomains are configured
|
||||
if m.config.Network.HasDomainFiltering() {
|
||||
fp, err := NewFilteringProxy(&m.config.Network, m.debug)
|
||||
if err != nil {
|
||||
// Clean up any bridges that were already started
|
||||
if m.reverseBridge != nil {
|
||||
m.reverseBridge.Cleanup()
|
||||
}
|
||||
if m.dnsBridge != nil {
|
||||
m.dnsBridge.Cleanup()
|
||||
}
|
||||
if m.proxyBridge != nil {
|
||||
m.proxyBridge.Cleanup()
|
||||
}
|
||||
if m.tun2socksPath != "" {
|
||||
_ = os.Remove(m.tun2socksPath)
|
||||
}
|
||||
return fmt.Errorf("failed to start filtering proxy: %w", err)
|
||||
}
|
||||
m.filterProxy = fp
|
||||
m.logDebug("Domain filtering proxy started on %s", fp.Addr())
|
||||
|
||||
// Write Node.js proxy bootstrap script so fetch() honors HTTP_PROXY
|
||||
if bootstrapPath, err := WriteNodeProxyBootstrap(); err != nil {
|
||||
m.logDebug("Warning: failed to write Node.js proxy bootstrap: %v", err)
|
||||
} else {
|
||||
m.logDebug("Node.js proxy bootstrap written to %s", bootstrapPath)
|
||||
}
|
||||
}
|
||||
|
||||
m.initialized = true
|
||||
if m.config.Network.ProxyURL != "" {
|
||||
dnsInfo := "none"
|
||||
@@ -148,12 +179,12 @@ func (m *Manager) WrapCommand(command string) (string, error) {
|
||||
plat := platform.Detect()
|
||||
switch plat {
|
||||
case platform.MacOS:
|
||||
return WrapCommandMacOS(m.config, command, m.exposedPorts, m.debug)
|
||||
return WrapCommandMacOS(m.config, command, m.exposedPorts, m.filterProxy, m.debug)
|
||||
case platform.Linux:
|
||||
if m.learning {
|
||||
return m.wrapCommandLearning(command)
|
||||
}
|
||||
return WrapCommandLinux(m.config, command, m.proxyBridge, m.dnsBridge, m.reverseBridge, m.tun2socksPath, m.debug)
|
||||
return WrapCommandLinux(m.config, command, m.proxyBridge, m.dnsBridge, m.reverseBridge, m.tun2socksPath, m.filterProxy, m.debug)
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported platform: %s", plat)
|
||||
}
|
||||
@@ -171,7 +202,7 @@ func (m *Manager) wrapCommandLearning(command string) (string, error) {
|
||||
|
||||
m.logDebug("Strace log file: %s", m.straceLogPath)
|
||||
|
||||
return WrapCommandLinuxWithOptions(m.config, command, m.proxyBridge, m.dnsBridge, m.reverseBridge, m.tun2socksPath, LinuxSandboxOptions{
|
||||
return WrapCommandLinuxWithOptions(m.config, command, m.proxyBridge, m.dnsBridge, m.reverseBridge, m.tun2socksPath, m.filterProxy, LinuxSandboxOptions{
|
||||
UseLandlock: false, // Disabled: seccomp blocks ptrace which strace needs
|
||||
UseSeccomp: false, // Disabled: conflicts with strace
|
||||
UseEBPF: false,
|
||||
@@ -201,6 +232,9 @@ func (m *Manager) GenerateLearnedTemplate(cmdName string) (string, error) {
|
||||
|
||||
// Cleanup stops the proxies and cleans up resources.
|
||||
func (m *Manager) Cleanup() {
|
||||
if m.filterProxy != nil {
|
||||
m.filterProxy.Shutdown()
|
||||
}
|
||||
if m.reverseBridge != nil {
|
||||
m.reverseBridge.Cleanup()
|
||||
}
|
||||
|
||||
@@ -138,9 +138,9 @@ func parseViolation(line string) string {
|
||||
timestamp := time.Now().Format("15:04:05")
|
||||
|
||||
if details != "" {
|
||||
return fmt.Sprintf("[greywall:logstream] %s ✗ %s %s (%s:%s)", timestamp, operation, details, process, pid)
|
||||
return fmt.Sprintf("\033[31m[greywall:logstream] %s ✗ %s %s (%s:%s)\033[0m", timestamp, operation, details, process, pid)
|
||||
}
|
||||
return fmt.Sprintf("[greywall:logstream] %s ✗ %s (%s:%s)", timestamp, operation, process, pid)
|
||||
return fmt.Sprintf("\033[31m[greywall:logstream] %s ✗ %s (%s:%s)\033[0m", timestamp, operation, process, pid)
|
||||
}
|
||||
|
||||
// shouldShowViolation returns true if this violation type should be displayed.
|
||||
|
||||
245
internal/sandbox/proxy.go
Normal file
245
internal/sandbox/proxy.go
Normal file
@@ -0,0 +1,245 @@
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gitea.app.monadical.io/monadical/greywall/internal/config"
|
||||
)
|
||||
|
||||
// FilteringProxy is an HTTP CONNECT proxy that filters outbound connections by domain.
|
||||
// It runs on the host and is the only outbound target the sandbox allows.
|
||||
type FilteringProxy struct {
|
||||
listener net.Listener
|
||||
server *http.Server
|
||||
network *config.NetworkConfig
|
||||
debug bool
|
||||
mu sync.Mutex
|
||||
closed bool
|
||||
}
|
||||
|
||||
// NewFilteringProxy creates and starts a new domain-filtering HTTP proxy.
|
||||
// It listens on 127.0.0.1 with a random available port.
|
||||
func NewFilteringProxy(network *config.NetworkConfig, debug bool) (*FilteringProxy, error) {
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to listen: %w", err)
|
||||
}
|
||||
|
||||
fp := &FilteringProxy{
|
||||
listener: listener,
|
||||
network: network,
|
||||
debug: debug,
|
||||
}
|
||||
|
||||
fp.server = &http.Server{
|
||||
Handler: http.HandlerFunc(fp.serveHTTP),
|
||||
ReadHeaderTimeout: 30 * time.Second,
|
||||
}
|
||||
|
||||
go func() {
|
||||
if err := fp.server.Serve(listener); err != nil && err != http.ErrServerClosed {
|
||||
fp.logDebug("Proxy server error: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
if debug {
|
||||
fmt.Fprintf(os.Stderr, "[greywall:proxy] Filtering proxy started on %s\n", listener.Addr().String())
|
||||
}
|
||||
|
||||
return fp, nil
|
||||
}
|
||||
|
||||
func (fp *FilteringProxy) serveHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodConnect {
|
||||
fp.handleConnect(w, r)
|
||||
} else if r.Method == http.MethodGet && r.URL.Path == "/__greywall_dns" {
|
||||
fp.handleDNS(w, r)
|
||||
} else {
|
||||
fp.handleHTTP(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
// handleDNS resolves a hostname and returns the IP addresses as JSON.
|
||||
// Used by the Node.js bootstrap to patch dns.lookup inside the sandbox.
|
||||
func (fp *FilteringProxy) handleDNS(w http.ResponseWriter, r *http.Request) {
|
||||
host := r.URL.Query().Get("host")
|
||||
if host == "" {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
http.Error(w, `{"error":"missing host parameter"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if !fp.network.IsDomainAllowed(host) {
|
||||
fp.logDenied(host)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
fmt.Fprintf(w, `{"error":"domain denied: %s"}`, host)
|
||||
return
|
||||
}
|
||||
|
||||
addrs, err := net.LookupHost(host)
|
||||
if err != nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
fmt.Fprintf(w, `{"error":"%s"}`, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
type addrEntry struct {
|
||||
Address string `json:"address"`
|
||||
Family int `json:"family"`
|
||||
}
|
||||
|
||||
var entries []addrEntry
|
||||
for _, addr := range addrs {
|
||||
family := 4
|
||||
if strings.Contains(addr, ":") {
|
||||
family = 6
|
||||
}
|
||||
entries = append(entries, addrEntry{Address: addr, Family: family})
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"addresses": entries,
|
||||
})
|
||||
}
|
||||
|
||||
func (fp *FilteringProxy) handleConnect(w http.ResponseWriter, r *http.Request) {
|
||||
host := extractHost(r.Host)
|
||||
|
||||
if !fp.network.IsDomainAllowed(host) {
|
||||
fp.logDenied(host)
|
||||
http.Error(w, fmt.Sprintf("[greywall] domain denied: %s", host), http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
// Dial the target
|
||||
target := r.Host
|
||||
if !strings.Contains(target, ":") {
|
||||
target = target + ":443"
|
||||
}
|
||||
|
||||
destConn, err := net.DialTimeout("tcp", target, 10*time.Second)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("[greywall] failed to connect to %s: %v", target, err), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
|
||||
// Hijack the client connection
|
||||
hijacker, ok := w.(http.Hijacker)
|
||||
if !ok {
|
||||
destConn.Close()
|
||||
http.Error(w, "[greywall] hijacking not supported", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
clientConn, _, err := hijacker.Hijack()
|
||||
if err != nil {
|
||||
destConn.Close()
|
||||
return
|
||||
}
|
||||
|
||||
// Bidirectional copy
|
||||
go func() {
|
||||
defer destConn.Close()
|
||||
defer clientConn.Close()
|
||||
_, _ = io.Copy(destConn, clientConn)
|
||||
}()
|
||||
go func() {
|
||||
defer destConn.Close()
|
||||
defer clientConn.Close()
|
||||
_, _ = io.Copy(clientConn, destConn)
|
||||
}()
|
||||
}
|
||||
|
||||
func (fp *FilteringProxy) handleHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
host := extractHost(r.Host)
|
||||
|
||||
if !fp.network.IsDomainAllowed(host) {
|
||||
fp.logDenied(host)
|
||||
http.Error(w, fmt.Sprintf("[greywall] domain denied: %s", host), http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
// Forward the request
|
||||
r.RequestURI = ""
|
||||
|
||||
resp, err := http.DefaultTransport.RoundTrip(r)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("[greywall] failed to forward request: %v", err), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Copy response headers
|
||||
for key, values := range resp.Header {
|
||||
for _, value := range values {
|
||||
w.Header().Add(key, value)
|
||||
}
|
||||
}
|
||||
w.WriteHeader(resp.StatusCode)
|
||||
_, _ = io.Copy(w, resp.Body)
|
||||
}
|
||||
|
||||
// Addr returns the listener address as a string (e.g. "127.0.0.1:12345").
|
||||
func (fp *FilteringProxy) Addr() string {
|
||||
return fp.listener.Addr().String()
|
||||
}
|
||||
|
||||
// Port returns the listener port as a string.
|
||||
func (fp *FilteringProxy) Port() string {
|
||||
_, port, _ := net.SplitHostPort(fp.listener.Addr().String())
|
||||
return port
|
||||
}
|
||||
|
||||
// Shutdown gracefully stops the proxy.
|
||||
func (fp *FilteringProxy) Shutdown() {
|
||||
fp.mu.Lock()
|
||||
defer fp.mu.Unlock()
|
||||
|
||||
if fp.closed {
|
||||
return
|
||||
}
|
||||
fp.closed = true
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
_ = fp.server.Shutdown(ctx)
|
||||
|
||||
if fp.debug {
|
||||
fmt.Fprintf(os.Stderr, "[greywall:proxy] Filtering proxy stopped\n")
|
||||
}
|
||||
}
|
||||
|
||||
func (fp *FilteringProxy) logDenied(host string) {
|
||||
fmt.Fprintf(os.Stderr, "\033[31m[greywall:proxy] domain denied: %s\033[0m\n", host)
|
||||
}
|
||||
|
||||
func (fp *FilteringProxy) logDebug(format string, args ...interface{}) {
|
||||
if fp.debug {
|
||||
fmt.Fprintf(os.Stderr, "[greywall:proxy] "+format+"\n", args...)
|
||||
}
|
||||
}
|
||||
|
||||
// extractHost extracts the hostname from a host:port string, stripping the port.
|
||||
func extractHost(hostport string) string {
|
||||
host, _, err := net.SplitHostPort(hostport)
|
||||
if err != nil {
|
||||
// No port present
|
||||
return hostport
|
||||
}
|
||||
return host
|
||||
}
|
||||
193
internal/sandbox/proxy_test.go
Normal file
193
internal/sandbox/proxy_test.go
Normal file
@@ -0,0 +1,193 @@
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.app.monadical.io/monadical/greywall/internal/config"
|
||||
)
|
||||
|
||||
func TestFilteringProxy_AllowedDomain(t *testing.T) {
|
||||
nc := &config.NetworkConfig{
|
||||
AllowedDomains: []string{"httpbin.org"},
|
||||
}
|
||||
|
||||
fp, err := NewFilteringProxy(nc, false)
|
||||
if err != nil {
|
||||
t.Fatalf("NewFilteringProxy() error = %v", err)
|
||||
}
|
||||
defer fp.Shutdown()
|
||||
|
||||
if fp.Port() == "" {
|
||||
t.Fatal("expected non-empty port")
|
||||
}
|
||||
if fp.Addr() == "" {
|
||||
t.Fatal("expected non-empty addr")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilteringProxy_DeniedDomain_HTTP(t *testing.T) {
|
||||
nc := &config.NetworkConfig{
|
||||
AllowedDomains: []string{"allowed.example.com"},
|
||||
}
|
||||
|
||||
fp, err := NewFilteringProxy(nc, false)
|
||||
if err != nil {
|
||||
t.Fatalf("NewFilteringProxy() error = %v", err)
|
||||
}
|
||||
defer fp.Shutdown()
|
||||
|
||||
// Make a plain HTTP request to a denied domain through the proxy
|
||||
proxyURL, _ := url.Parse(fmt.Sprintf("http://%s", fp.Addr()))
|
||||
client := &http.Client{
|
||||
Transport: &http.Transport{
|
||||
Proxy: http.ProxyURL(proxyURL),
|
||||
},
|
||||
Timeout: 5 * time.Second,
|
||||
}
|
||||
|
||||
resp, err := client.Get("http://denied.example.com/test")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
t.Errorf("expected 403 Forbidden, got %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilteringProxy_DeniedDomain_CONNECT(t *testing.T) {
|
||||
nc := &config.NetworkConfig{
|
||||
AllowedDomains: []string{"allowed.example.com"},
|
||||
}
|
||||
|
||||
fp, err := NewFilteringProxy(nc, false)
|
||||
if err != nil {
|
||||
t.Fatalf("NewFilteringProxy() error = %v", err)
|
||||
}
|
||||
defer fp.Shutdown()
|
||||
|
||||
// Make a CONNECT request to a denied domain
|
||||
proxyURL, _ := url.Parse(fmt.Sprintf("http://%s", fp.Addr()))
|
||||
client := &http.Client{
|
||||
Transport: &http.Transport{
|
||||
Proxy: http.ProxyURL(proxyURL),
|
||||
},
|
||||
Timeout: 5 * time.Second,
|
||||
}
|
||||
|
||||
// HTTPS triggers CONNECT method through the proxy
|
||||
_, err = client.Get("https://denied.example.com/test")
|
||||
if err == nil {
|
||||
t.Error("expected error for denied CONNECT, got nil")
|
||||
}
|
||||
// The error should indicate the proxy rejected the connection (403)
|
||||
}
|
||||
|
||||
func TestFilteringProxy_DenyList_Only(t *testing.T) {
|
||||
nc := &config.NetworkConfig{
|
||||
DeniedDomains: []string{"evil.com"},
|
||||
}
|
||||
|
||||
fp, err := NewFilteringProxy(nc, false)
|
||||
if err != nil {
|
||||
t.Fatalf("NewFilteringProxy() error = %v", err)
|
||||
}
|
||||
defer fp.Shutdown()
|
||||
|
||||
proxyURL, _ := url.Parse(fmt.Sprintf("http://%s", fp.Addr()))
|
||||
client := &http.Client{
|
||||
Transport: &http.Transport{
|
||||
Proxy: http.ProxyURL(proxyURL),
|
||||
},
|
||||
Timeout: 5 * time.Second,
|
||||
}
|
||||
|
||||
// Denied domain should be blocked
|
||||
resp, err := client.Get("http://evil.com/test")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
t.Errorf("expected 403 for denied domain, got %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilteringProxy_WildcardAllow(t *testing.T) {
|
||||
nc := &config.NetworkConfig{
|
||||
AllowedDomains: []string{"*"},
|
||||
DeniedDomains: []string{"evil.com"},
|
||||
}
|
||||
|
||||
fp, err := NewFilteringProxy(nc, false)
|
||||
if err != nil {
|
||||
t.Fatalf("NewFilteringProxy() error = %v", err)
|
||||
}
|
||||
defer fp.Shutdown()
|
||||
|
||||
proxyURL, _ := url.Parse(fmt.Sprintf("http://%s", fp.Addr()))
|
||||
client := &http.Client{
|
||||
Transport: &http.Transport{
|
||||
Proxy: http.ProxyURL(proxyURL),
|
||||
},
|
||||
Timeout: 5 * time.Second,
|
||||
}
|
||||
|
||||
// Denied domain should still be blocked even with wildcard allow
|
||||
resp, err := client.Get("http://evil.com/test")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
t.Errorf("expected 403 for denied domain with wildcard allow, got %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilteringProxy_Shutdown(t *testing.T) {
|
||||
nc := &config.NetworkConfig{
|
||||
AllowedDomains: []string{"example.com"},
|
||||
}
|
||||
|
||||
fp, err := NewFilteringProxy(nc, false)
|
||||
if err != nil {
|
||||
t.Fatalf("NewFilteringProxy() error = %v", err)
|
||||
}
|
||||
|
||||
// Shutdown should not panic
|
||||
fp.Shutdown()
|
||||
|
||||
// Double shutdown should not panic
|
||||
fp.Shutdown()
|
||||
}
|
||||
|
||||
func TestExtractHost(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{"example.com:443", "example.com"},
|
||||
{"example.com:80", "example.com"},
|
||||
{"example.com", "example.com"},
|
||||
{"127.0.0.1:8080", "127.0.0.1"},
|
||||
{"[::1]:443", "::1"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
got := extractHost(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("extractHost(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -86,6 +86,353 @@ func GenerateProxyEnvVars(proxyURL string) []string {
|
||||
return envVars
|
||||
}
|
||||
|
||||
// GenerateHTTPProxyEnvVars creates environment variables for an HTTP proxy.
|
||||
// Used when domain filtering is active (HTTP CONNECT proxy, not SOCKS5).
|
||||
func GenerateHTTPProxyEnvVars(httpProxyURL string) []string {
|
||||
envVars := []string{
|
||||
"GREYWALL_SANDBOX=1",
|
||||
"TMPDIR=/tmp/greywall",
|
||||
}
|
||||
|
||||
if httpProxyURL == "" {
|
||||
return envVars
|
||||
}
|
||||
|
||||
// NO_PROXY for localhost and private networks
|
||||
noProxy := strings.Join([]string{
|
||||
"localhost",
|
||||
"127.0.0.1",
|
||||
"::1",
|
||||
"*.local",
|
||||
".local",
|
||||
"169.254.0.0/16",
|
||||
"10.0.0.0/8",
|
||||
"172.16.0.0/12",
|
||||
"192.168.0.0/16",
|
||||
}, ",")
|
||||
|
||||
envVars = append(envVars,
|
||||
"NO_PROXY="+noProxy,
|
||||
"no_proxy="+noProxy,
|
||||
"HTTP_PROXY="+httpProxyURL,
|
||||
"HTTPS_PROXY="+httpProxyURL,
|
||||
"http_proxy="+httpProxyURL,
|
||||
"https_proxy="+httpProxyURL,
|
||||
)
|
||||
|
||||
// Inject Node.js proxy bootstrap so fetch() honors HTTP_PROXY.
|
||||
// Appends to existing NODE_OPTIONS if set.
|
||||
nodeOpts := "--require " + nodeProxyBootstrapPath
|
||||
if existing := os.Getenv("NODE_OPTIONS"); existing != "" {
|
||||
nodeOpts = existing + " " + nodeOpts
|
||||
}
|
||||
envVars = append(envVars, "NODE_OPTIONS="+nodeOpts)
|
||||
|
||||
return envVars
|
||||
}
|
||||
|
||||
// nodeProxyBootstrapJS is the Node.js bootstrap script that makes both
|
||||
// fetch() and http/https.request() respect HTTP_PROXY/HTTPS_PROXY env vars.
|
||||
//
|
||||
// Two mechanisms are needed because Node.js has two separate HTTP stacks:
|
||||
// 1. fetch() — powered by undici, patched via EnvHttpProxyAgent
|
||||
// 2. http.request()/https.request() — built-in modules, patched via
|
||||
// Agent.prototype.createConnection on BOTH http.Agent and https.Agent.
|
||||
// This patches the prototype so ALL agent instances (including custom ones
|
||||
// created by libraries like node-fetch, axios, got, etc.) tunnel through
|
||||
// the proxy — not just globalAgent.
|
||||
//
|
||||
// The undici setup tries multiple strategies to find the module:
|
||||
// 1. require('undici') from CWD
|
||||
// 2. createRequire from the main script's path (finds it in the app's node_modules)
|
||||
const nodeProxyBootstrapJS = `'use strict';
|
||||
(function() {
|
||||
var proxyUrl = process.env.HTTPS_PROXY || process.env.HTTP_PROXY ||
|
||||
process.env.https_proxy || process.env.http_proxy;
|
||||
if (!proxyUrl) return;
|
||||
|
||||
// --- Part 1: Patch fetch() via undici EnvHttpProxyAgent ---
|
||||
// Strategy: set global dispatcher AND wrap globalThis.fetch to force proxy.
|
||||
// This prevents openclaw or other code from overriding the global dispatcher.
|
||||
var undiciModule = null;
|
||||
var proxyAgent = null;
|
||||
|
||||
function tryGetUndici(undici) {
|
||||
if (undici && typeof undici.EnvHttpProxyAgent === 'function' &&
|
||||
typeof undici.setGlobalDispatcher === 'function') {
|
||||
return undici;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
try { undiciModule = tryGetUndici(require('undici')); } catch (e) {}
|
||||
|
||||
if (!undiciModule) {
|
||||
try {
|
||||
var mainScript = process.argv[1];
|
||||
if (mainScript) {
|
||||
var createRequire = require('module').createRequire;
|
||||
var requireFrom = createRequire(require('path').resolve(mainScript));
|
||||
undiciModule = tryGetUndici(requireFrom('undici'));
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
if (undiciModule) {
|
||||
proxyAgent = new undiciModule.EnvHttpProxyAgent();
|
||||
undiciModule.setGlobalDispatcher(proxyAgent);
|
||||
|
||||
// Wrap globalThis.fetch to force proxy dispatcher on every call.
|
||||
// This prevents code that overrides the global dispatcher from bypassing the proxy.
|
||||
if (typeof globalThis.fetch === 'function') {
|
||||
var _origFetch = globalThis.fetch;
|
||||
globalThis.fetch = function(input, init) {
|
||||
process.stderr.write('[greywall:node-bootstrap] fetch: ' + (typeof input === 'string' ? input : (input && input.url ? input.url : '?')) + '\n');
|
||||
if (!init) init = {};
|
||||
init.dispatcher = proxyAgent;
|
||||
return _origFetch.call(globalThis, input, init);
|
||||
};
|
||||
}
|
||||
|
||||
// Also wrap undici.fetch and undici.request to catch direct usage
|
||||
if (typeof undiciModule.fetch === 'function') {
|
||||
var _origUndFetch = undiciModule.fetch;
|
||||
undiciModule.fetch = function(input, init) {
|
||||
if (!init) init = {};
|
||||
init.dispatcher = proxyAgent;
|
||||
return _origUndFetch.call(undiciModule, input, init);
|
||||
};
|
||||
}
|
||||
if (typeof undiciModule.request === 'function') {
|
||||
var _origUndRequest = undiciModule.request;
|
||||
undiciModule.request = function(url, opts) {
|
||||
if (!opts) opts = {};
|
||||
opts.dispatcher = proxyAgent;
|
||||
return _origUndRequest.call(undiciModule, url, opts);
|
||||
};
|
||||
}
|
||||
}
|
||||
// --- Shared setup for Parts 2 and 3 ---
|
||||
var url = require('url');
|
||||
var http = require('http');
|
||||
var https = require('https');
|
||||
var tls = require('tls');
|
||||
|
||||
var parsed = new url.URL(proxyUrl);
|
||||
var proxyHost = parsed.hostname;
|
||||
var proxyPort = parseInt(parsed.port, 10);
|
||||
|
||||
var noProxyRaw = process.env.NO_PROXY || process.env.no_proxy || '';
|
||||
var noProxyList = noProxyRaw.split(',').map(function(s) { return s.trim(); }).filter(Boolean);
|
||||
|
||||
function isIPAddress(h) {
|
||||
// IPv4 or IPv6 — skip DNS proxy for raw IPs
|
||||
return /^\d{1,3}(\.\d{1,3}){3}$/.test(h) || h.indexOf(':') !== -1;
|
||||
}
|
||||
|
||||
function shouldProxy(hostname) {
|
||||
if (!hostname || isIPAddress(hostname)) return false;
|
||||
for (var i = 0; i < noProxyList.length; i++) {
|
||||
var p = noProxyList[i];
|
||||
if (p === hostname) return false;
|
||||
if (p.charAt(0) === '.' && hostname.length > p.length &&
|
||||
hostname.indexOf(p, hostname.length - p.length) !== -1) return false;
|
||||
if (p.charAt(0) === '*' && hostname.length >= p.length - 1 &&
|
||||
hostname.indexOf(p.slice(1), hostname.length - p.length + 1) !== -1) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Save originals before patching
|
||||
var origHttpCreateConnection = http.Agent.prototype.createConnection;
|
||||
var origHttpsCreateConnection = https.Agent.prototype.createConnection;
|
||||
|
||||
// Direct agent for CONNECT requests to the proxy itself (avoids recursion)
|
||||
var directAgent = new http.Agent({ keepAlive: false });
|
||||
directAgent.createConnection = origHttpCreateConnection;
|
||||
|
||||
// --- Part 2: Patch Agent.prototype.createConnection on both http and https ---
|
||||
// This ensures ALL agent instances tunnel through the proxy, not just globalAgent.
|
||||
// Libraries like node-fetch, axios, got create their own agents — patching the
|
||||
// prototype catches them all.
|
||||
try {
|
||||
// Patch https.Agent.prototype — affects ALL https.Agent instances
|
||||
https.Agent.prototype.createConnection = function(options, callback) {
|
||||
var targetHost = options.host || options.hostname;
|
||||
var targetPort = options.port || 443;
|
||||
|
||||
if (!shouldProxy(targetHost)) {
|
||||
return origHttpsCreateConnection.call(this, options, callback);
|
||||
}
|
||||
|
||||
var connectReq = http.request({
|
||||
host: proxyHost,
|
||||
port: proxyPort,
|
||||
method: 'CONNECT',
|
||||
path: targetHost + ':' + targetPort,
|
||||
agent: directAgent,
|
||||
});
|
||||
|
||||
connectReq.on('connect', function(res, socket) {
|
||||
if (res.statusCode === 200) {
|
||||
var tlsSocket = tls.connect({
|
||||
socket: socket,
|
||||
servername: options.servername || targetHost,
|
||||
rejectUnauthorized: options.rejectUnauthorized !== false,
|
||||
});
|
||||
callback(null, tlsSocket);
|
||||
} else {
|
||||
socket.destroy();
|
||||
callback(new Error('Proxy CONNECT failed: ' + res.statusCode));
|
||||
}
|
||||
});
|
||||
|
||||
connectReq.on('error', function(err) { callback(err); });
|
||||
connectReq.end();
|
||||
};
|
||||
|
||||
// Patch http.Agent.prototype — affects ALL http.Agent instances
|
||||
http.Agent.prototype.createConnection = function(options, callback) {
|
||||
var targetHost = options.host || options.hostname;
|
||||
var targetPort = options.port || 80;
|
||||
|
||||
if (!shouldProxy(targetHost)) {
|
||||
return origHttpCreateConnection.call(this, options, callback);
|
||||
}
|
||||
|
||||
var connectReq = http.request({
|
||||
host: proxyHost,
|
||||
port: proxyPort,
|
||||
method: 'CONNECT',
|
||||
path: targetHost + ':' + targetPort,
|
||||
agent: directAgent,
|
||||
});
|
||||
|
||||
connectReq.on('connect', function(res, socket) {
|
||||
if (res.statusCode === 200) {
|
||||
callback(null, socket);
|
||||
} else {
|
||||
socket.destroy();
|
||||
callback(new Error('Proxy CONNECT failed: ' + res.statusCode));
|
||||
}
|
||||
});
|
||||
|
||||
connectReq.on('error', function(err) { callback(err); });
|
||||
connectReq.end();
|
||||
};
|
||||
} catch (e) {}
|
||||
|
||||
// --- Part 3: Patch dns.lookup / dns.promises.lookup to resolve through proxy ---
|
||||
// OpenClaw (and other apps) do DNS resolution before fetch for SSRF protection.
|
||||
// Inside the sandbox, DNS is blocked. Route lookups through the proxy's
|
||||
// /__greywall_dns endpoint which resolves on the host side.
|
||||
try {
|
||||
var dns = require('dns');
|
||||
var dnsPromises = require('dns/promises');
|
||||
var origDnsLookup = dns.lookup;
|
||||
var origDnsPromisesLookup = dnsPromises.lookup;
|
||||
|
||||
function proxyDnsResolve(hostname) {
|
||||
return new Promise(function(resolve, reject) {
|
||||
var req = http.request({
|
||||
host: proxyHost,
|
||||
port: proxyPort,
|
||||
path: '/__greywall_dns?host=' + encodeURIComponent(hostname),
|
||||
method: 'GET',
|
||||
agent: directAgent,
|
||||
}, function(res) {
|
||||
var data = '';
|
||||
res.on('data', function(chunk) { data += chunk; });
|
||||
res.on('end', function() {
|
||||
try {
|
||||
var parsed = JSON.parse(data);
|
||||
if (parsed.error) {
|
||||
var err = new Error(parsed.error);
|
||||
err.code = 'ENOTFOUND';
|
||||
reject(err);
|
||||
} else {
|
||||
resolve(parsed.addresses || []);
|
||||
}
|
||||
} catch(e) {
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
dnsPromises.lookup = function(hostname, options) {
|
||||
if (!shouldProxy(hostname)) {
|
||||
return origDnsPromisesLookup.call(dnsPromises, hostname, options);
|
||||
}
|
||||
|
||||
return proxyDnsResolve(hostname).then(function(addresses) {
|
||||
if (!addresses || addresses.length === 0) {
|
||||
var err = new Error('getaddrinfo ENOTFOUND ' + hostname);
|
||||
err.code = 'ENOTFOUND';
|
||||
throw err;
|
||||
}
|
||||
|
||||
var opts = (typeof options === 'object' && options !== null) ? options : {};
|
||||
var family = typeof options === 'number' ? options : (opts.family || 0);
|
||||
|
||||
var filtered = addresses;
|
||||
if (family === 4 || family === 6) {
|
||||
filtered = addresses.filter(function(a) { return a.family === family; });
|
||||
if (filtered.length === 0) filtered = addresses;
|
||||
}
|
||||
|
||||
if (opts.all) {
|
||||
return filtered;
|
||||
}
|
||||
|
||||
return filtered[0];
|
||||
});
|
||||
};
|
||||
|
||||
dns.lookup = function(hostname, options, callback) {
|
||||
if (typeof options === 'function') {
|
||||
callback = options;
|
||||
options = {};
|
||||
}
|
||||
|
||||
if (!shouldProxy(hostname)) {
|
||||
return origDnsLookup.call(dns, hostname, options, callback);
|
||||
}
|
||||
|
||||
dnsPromises.lookup(hostname, options).then(function(result) {
|
||||
if (Array.isArray(result)) {
|
||||
callback(null, result);
|
||||
} else {
|
||||
callback(null, result.address, result.family);
|
||||
}
|
||||
}, function(err) {
|
||||
callback(err);
|
||||
});
|
||||
};
|
||||
|
||||
} catch (e) {}
|
||||
})();
|
||||
`
|
||||
|
||||
// nodeProxyBootstrapPath is the path where the bootstrap script is written.
|
||||
const nodeProxyBootstrapPath = "/tmp/greywall/node-proxy-bootstrap.js"
|
||||
|
||||
// WriteNodeProxyBootstrap writes the Node.js proxy bootstrap script to disk.
|
||||
// Returns the path to the script, or an error if it couldn't be written.
|
||||
func WriteNodeProxyBootstrap() (string, error) {
|
||||
dir := filepath.Dir(nodeProxyBootstrapPath)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := os.WriteFile(nodeProxyBootstrapPath, []byte(nodeProxyBootstrapJS), 0o644); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return nodeProxyBootstrapPath, nil
|
||||
}
|
||||
|
||||
// EncodeSandboxedCommand encodes a command for sandbox monitoring.
|
||||
func EncodeSandboxedCommand(command string) string {
|
||||
if len(command) > 100 {
|
||||
|
||||
@@ -199,6 +199,72 @@ func TestGenerateProxyEnvVars(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateHTTPProxyEnvVars(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
httpProxyURL string
|
||||
wantEnvs []string
|
||||
dontWant []string
|
||||
}{
|
||||
{
|
||||
name: "no proxy",
|
||||
httpProxyURL: "",
|
||||
wantEnvs: []string{
|
||||
"GREYWALL_SANDBOX=1",
|
||||
"TMPDIR=/tmp/greywall",
|
||||
},
|
||||
dontWant: []string{
|
||||
"HTTP_PROXY=",
|
||||
"HTTPS_PROXY=",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "http proxy",
|
||||
httpProxyURL: "http://127.0.0.1:12345",
|
||||
wantEnvs: []string{
|
||||
"GREYWALL_SANDBOX=1",
|
||||
"HTTP_PROXY=http://127.0.0.1:12345",
|
||||
"HTTPS_PROXY=http://127.0.0.1:12345",
|
||||
"http_proxy=http://127.0.0.1:12345",
|
||||
"https_proxy=http://127.0.0.1:12345",
|
||||
"NO_PROXY=",
|
||||
"no_proxy=",
|
||||
},
|
||||
dontWant: []string{
|
||||
"ALL_PROXY=",
|
||||
"all_proxy=",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := GenerateHTTPProxyEnvVars(tt.httpProxyURL)
|
||||
|
||||
for _, want := range tt.wantEnvs {
|
||||
found := false
|
||||
for _, env := range got {
|
||||
if strings.HasPrefix(env, want) || env == want {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("GenerateHTTPProxyEnvVars(%q) missing %q", tt.httpProxyURL, want)
|
||||
}
|
||||
}
|
||||
|
||||
for _, dontWant := range tt.dontWant {
|
||||
for _, env := range got {
|
||||
if strings.HasPrefix(env, dontWant) {
|
||||
t.Errorf("GenerateHTTPProxyEnvVars(%q) should not contain %q, got %q", tt.httpProxyURL, dontWant, env)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncodeSandboxedCommand(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||
@@ -121,7 +121,7 @@ run_test "read file in workspace" "pass" "$GREYWALL_BIN" -c "cat $WORKSPACE/test
|
||||
|
||||
# Test: Write outside workspace blocked
|
||||
# Create a settings file that only allows write to current workspace
|
||||
SETTINGS_FILE="$WORKSPAC./greywall.json"
|
||||
SETTINGS_FILE="$WORKSPACE/greywall.json"
|
||||
cat > "$SETTINGS_FILE" << EOF
|
||||
{
|
||||
"filesystem": {
|
||||
|
||||
Reference in New Issue
Block a user