From 5e75e8cd0bb8a68be2a9d4ddde89aa5092a6ba02 Mon Sep 17 00:00:00 2001 From: Matthew DeVenny Date: Mon, 6 Apr 2026 14:28:12 -0700 Subject: [PATCH 1/7] #33 add wildcard matching Signed-off-by: Matthew DeVenny --- .github/workflows/ci.yml | 8 +- config.example.json | 6 ++ pkg/config/config.go | 93 ++++++++++++++---- pkg/config/config_test.go | 126 ++++++++++++++++++++++++ pkg/config/pattern.go | 89 +++++++++++++++++ pkg/config/pattern_test.go | 192 +++++++++++++++++++++++++++++++++++++ pkg/dns/server.go | 11 ++- pkg/dns/server_test.go | 38 ++++++++ 8 files changed, 542 insertions(+), 21 deletions(-) create mode 100644 pkg/config/pattern.go create mode 100644 pkg/config/pattern_test.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b7116c1..e067cf2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -145,9 +145,10 @@ jobs: with: mode: enforce offline: 'true' - allowed-hosts: | + allowed-hosts: | docker.io docker.com + **.githubusercontent.com binary-path: ./bin/cargowall debug: true @@ -156,6 +157,11 @@ jobs: curl -sf --connect-timeout 10 --max-time 30 https://github.com > /dev/null echo "✅ github.com allowed" + - name: Test allowed connection via hostname pattern + run: | + curl -sf --connect-timeout 10 --max-time 30 https://raw.githubusercontent.com/github/gitignore/main/README.md > /dev/null + echo "✅ raw.githubusercontent.com allowed via **.githubusercontent.com pattern" + - name: Test blocked connection run: | if curl -sf --connect-timeout 5 --max-time 10 https://example.com > /dev/null 2>&1; then diff --git a/config.example.json b/config.example.json index 6b36760..7858673 100644 --- a/config.example.json +++ b/config.example.json @@ -35,6 +35,12 @@ "value": "api.example.com", "ports": [{"port": 80, "protocol": "tcp"}, {"port": 443, "protocol": "tcp"}], "action": "allow" + }, + { + "type": "hostname", + "value": "actions.githubusercontent.com.*.*.internal.cloudapp.net", + "ports": [{"port": 443, "protocol": "tcp"}], + "action": "allow" } ], "sudoLockdown": { diff --git a/pkg/config/config.go b/pkg/config/config.go index 8b6d912..fa31c77 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -110,15 +110,24 @@ type SudoLockdownSettings struct { // ResolvedRule represents a Rule with resolved IP addresses or CIDR blocks type ResolvedRule struct { - Type RuleType // "hostname" or "cidr" - Value string // Original value (hostname or CIDR string) - IPs []net.IP // For hostnames: resolved IPs. For CIDR: empty - IPNet *net.IPNet // For CIDR blocks only + Type RuleType // "hostname" or "cidr" + Value string // Original value (hostname or CIDR string) + IPs []net.IP // For hostnames: resolved IPs. For CIDR: empty + IPNet *net.IPNet // For CIDR blocks only + Pattern *HostnamePattern // Non-nil for hostname rules with glob wildcards Ports []Port Action Action AutoAddedType AutoAddedType // Why this rule was auto-added (empty for user-configured rules) } +// hostnamePatternEntry holds a compiled glob pattern with its associated rule metadata. +type hostnamePatternEntry struct { + Pattern HostnamePattern + Action Action + Ports []Port + AutoAddedType AutoAddedType +} + // Manager manages the firewall configuration and hostname resolution type Manager struct { mu sync.RWMutex @@ -128,7 +137,8 @@ type Manager struct { ipToHostname map[string]string // Reverse lookup: IP -> hostname ipLastSeen map[string]time.Time // Track when each IP was last seen trackedHostnames map[string]Action // Track hostnames we have rules for (hostname -> action) - maxCacheSize int // Maximum number of IPs to cache + hostnamePatterns []hostnamePatternEntry // Compiled glob patterns for hostname matching + maxCacheSize int // Maximum number of IPs to cache dnsCacheTTL time.Duration // How long to keep DNS entries } @@ -315,7 +325,9 @@ func (cm *Manager) LoadFromEnv() error { for _, entry := range splitAndTrim(allowedHosts) { if entry != "" { host, ports := parseHostWithPorts(entry) - host = normalizeHostname(host) + if !IsHostnamePattern(host) { + host = normalizeHostname(host) + } rules = append(rules, Rule{ Type: RuleTypeHostname, Value: host, @@ -346,7 +358,9 @@ func (cm *Manager) LoadFromEnv() error { for _, entry := range splitAndTrim(blockedHosts) { if entry != "" { host, ports := parseHostWithPorts(entry) - host = normalizeHostname(host) + if !IsHostnamePattern(host) { + host = normalizeHostname(host) + } rules = append(rules, Rule{ Type: RuleTypeHostname, Value: host, @@ -577,6 +591,17 @@ func (cm *Manager) GetTrackedHostnameAction(hostname string) Action { } } + // Check hostname patterns (glob matching) + for _, entry := range cm.hostnamePatterns { + if entry.Pattern.Matches(hostname) { + slog.Debug("Found pattern match", + "hostname", hostname, + "pattern", entry.Pattern.Raw, + "action", entry.Action) + return entry.Action + } + } + slog.Debug("No tracked hostname found", "hostname", hostname) return "" } @@ -682,6 +707,13 @@ func (cm *Manager) FindTrackedHostname(name string) string { return trackedHost } } + + // Pattern match + for _, entry := range cm.hostnamePatterns { + if entry.Pattern.Matches(name) { + return entry.Pattern.Raw + } + } return "" } @@ -913,7 +945,11 @@ func (cm *Manager) GetAutoAllowedTypeForHostname(hostname string) AutoAddedType if rule.Type != RuleTypeHostname || rule.AutoAddedType == AutoAddedTypeNone || rule.Action != ActionAllow { continue } - if hostname == rule.Value || strings.HasSuffix(hostname, "."+rule.Value) { + if rule.Pattern != nil { + if rule.Pattern.Matches(hostname) { + return rule.AutoAddedType + } + } else if hostname == rule.Value || strings.HasSuffix(hostname, "."+rule.Value) { return rule.AutoAddedType } } @@ -964,7 +1000,11 @@ func (cm *Manager) GetAutoAllowedType(ip string, port uint16, hostname string) A switch rule.Type { case RuleTypeHostname: - if hostname == rule.Value || strings.HasSuffix(hostname, "."+rule.Value) { + if rule.Pattern != nil { + if rule.Pattern.Matches(hostname) { + return rule.AutoAddedType + } + } else if hostname == rule.Value || strings.HasSuffix(hostname, "."+rule.Value) { return rule.AutoAddedType } case RuleTypeCIDR: @@ -1000,6 +1040,7 @@ func (cm *Manager) resolveRules() error { } cm.resolvedRules = nil + cm.hostnamePatterns = nil for _, rule := range cm.config.Rules { resolved := ResolvedRule{ @@ -1012,16 +1053,32 @@ func (cm *Manager) resolveRules() error { switch rule.Type { case RuleTypeHostname: - // Track ALL hostnames we have rules for - will resolve JIT when DNS queries arrive - cm.trackedHostnames[rule.Value] = rule.Action - - // Check if we already have cached IPs for this hostname (from previous DNS intercepts) - if cachedIPs, ok := cm.hostnameCache[rule.Value]; ok { - resolved.IPs = cachedIPs + if IsHostnamePattern(rule.Value) { + // Compile glob pattern and store separately + pattern, err := CompileHostnamePattern(rule.Value) + if err != nil { + slog.Error("Invalid hostname pattern", "value", rule.Value, "error", err) + continue + } + resolved.Pattern = &pattern + cm.hostnamePatterns = append(cm.hostnamePatterns, hostnamePatternEntry{ + Pattern: pattern, + Action: rule.Action, + Ports: rule.Ports, + AutoAddedType: rule.AutoAddedType, + }) } else { - // Initialize the hostnameCache entry so UpdateDNSMapping can append IPs later - cm.hostnameCache[rule.Value] = []net.IP{} - resolved.IPs = []net.IP{} + // Track ALL hostnames we have rules for - will resolve JIT when DNS queries arrive + cm.trackedHostnames[rule.Value] = rule.Action + + // Check if we already have cached IPs for this hostname (from previous DNS intercepts) + if cachedIPs, ok := cm.hostnameCache[rule.Value]; ok { + resolved.IPs = cachedIPs + } else { + // Initialize the hostnameCache entry so UpdateDNSMapping can append IPs later + cm.hostnameCache[rule.Value] = []net.IP{} + resolved.IPs = []net.IP{} + } } case RuleTypeCIDR: diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index 6fd88c0..5247817 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -891,3 +891,129 @@ func TestLoadConfig_SudoLockdown(t *testing.T) { t.Errorf("GetDefaultAction() = %v, want deny", cm.GetDefaultAction()) } } + +func TestHostnamePatternRules(t *testing.T) { + cm := NewConfigManager() + err := cm.LoadConfigFromRules([]Rule{ + {Type: RuleTypeHostname, Value: "github.com", Action: ActionAllow}, + {Type: RuleTypeHostname, Value: "actions.githubusercontent.com.*.*.internal.cloudapp.net", Action: ActionAllow}, + {Type: RuleTypeHostname, Value: "**.storage.azure.com", Action: ActionAllow}, + {Type: RuleTypeHostname, Value: "evil.*.example.com", Action: ActionDeny}, + }, ActionDeny) + if err != nil { + t.Fatalf("LoadConfigFromRules() error = %v", err) + } + + // Plain hostname rules still work + if action := cm.GetTrackedHostnameAction("github.com"); action != ActionAllow { + t.Errorf("GetTrackedHostnameAction(github.com) = %q, want allow", action) + } + if action := cm.GetTrackedHostnameAction("api.github.com"); action != ActionAllow { + t.Errorf("GetTrackedHostnameAction(api.github.com) = %q, want allow", action) + } + + // Pattern with two single wildcards in middle + if action := cm.GetTrackedHostnameAction("actions.githubusercontent.com.abc123.phxx.internal.cloudapp.net"); action != ActionAllow { + t.Errorf("two-star middle pattern should match, got %q", action) + } + if action := cm.GetTrackedHostnameAction("actions.githubusercontent.com.only1.internal.cloudapp.net"); action != "" { + t.Errorf("two-star middle pattern should not match with only 1 label, got %q", action) + } + + // Double-star pattern + if action := cm.GetTrackedHostnameAction("westus2.storage.azure.com"); action != ActionAllow { + t.Errorf("doublestar pattern should match one label, got %q", action) + } + if action := cm.GetTrackedHostnameAction("account.westus2.storage.azure.com"); action != ActionAllow { + t.Errorf("doublestar pattern should match multiple labels, got %q", action) + } + if action := cm.GetTrackedHostnameAction("storage.azure.com"); action != "" { + t.Errorf("doublestar pattern should not match zero labels, got %q", action) + } + + // Deny pattern + if action := cm.GetTrackedHostnameAction("evil.anything.example.com"); action != ActionDeny { + t.Errorf("deny pattern should match, got %q", action) + } + + // No match + if action := cm.GetTrackedHostnameAction("unknown.com"); action != "" { + t.Errorf("unknown hostname should not match, got %q", action) + } +} + +func TestFindTrackedHostnameWithPatterns(t *testing.T) { + cm := NewConfigManager() + err := cm.LoadConfigFromRules([]Rule{ + {Type: RuleTypeHostname, Value: "github.com", Action: ActionAllow}, + {Type: RuleTypeHostname, Value: "*.*.internal.cloudapp.net", Action: ActionAllow}, + }, ActionDeny) + if err != nil { + t.Fatalf("LoadConfigFromRules() error = %v", err) + } + + // Plain hostname + if got := cm.FindTrackedHostname("api.github.com"); got != "github.com" { + t.Errorf("FindTrackedHostname(api.github.com) = %q, want github.com", got) + } + + // Pattern match returns the raw pattern + if got := cm.FindTrackedHostname("abc.def.internal.cloudapp.net"); got != "*.*.internal.cloudapp.net" { + t.Errorf("FindTrackedHostname(abc.def.internal.cloudapp.net) = %q, want *.*.internal.cloudapp.net", got) + } + + // No match + if got := cm.FindTrackedHostname("unknown.com"); got != "" { + t.Errorf("FindTrackedHostname(unknown.com) = %q, want empty", got) + } +} + +func TestHostnamePatternNotInTrackedHostnames(t *testing.T) { + cm := NewConfigManager() + err := cm.LoadConfigFromRules([]Rule{ + {Type: RuleTypeHostname, Value: "github.com", Action: ActionAllow}, + {Type: RuleTypeHostname, Value: "*.*.internal.cloudapp.net", Action: ActionAllow}, + }, ActionDeny) + if err != nil { + t.Fatalf("LoadConfigFromRules() error = %v", err) + } + + tracked := cm.GetTrackedHostnames() + + // Pattern rules should NOT appear in the tracked hostnames map + if _, ok := tracked["*.*.internal.cloudapp.net"]; ok { + t.Error("pattern rule should not appear in trackedHostnames map") + } + // Plain hostname should be in the map + if _, ok := tracked["github.com"]; !ok { + t.Error("plain hostname should appear in trackedHostnames map") + } +} + +func TestLeadingWildcardIsPattern(t *testing.T) { + cm := NewConfigManager() + err := cm.LoadConfigFromRules([]Rule{ + {Type: RuleTypeHostname, Value: "*.github.com", Action: ActionAllow}, + }, ActionDeny) + if err != nil { + t.Fatalf("LoadConfigFromRules() error = %v", err) + } + + // "*.github.com" should be treated as a glob pattern, not normalized + tracked := cm.GetTrackedHostnames() + if _, ok := tracked["github.com"]; ok { + t.Error("*.github.com should not be normalized to github.com") + } + if _, ok := tracked["*.github.com"]; ok { + t.Error("pattern should not appear in trackedHostnames map") + } + + // * matches exactly one label + if action := cm.GetTrackedHostnameAction("api.github.com"); action != ActionAllow { + t.Errorf("api.github.com should match *.github.com, got %q", action) + } + // * does NOT match two labels + if action := cm.GetTrackedHostnameAction("a.b.github.com"); action != "" { + t.Errorf("a.b.github.com should NOT match *.github.com (single * = one label), got %q", action) + } +} diff --git a/pkg/config/pattern.go b/pkg/config/pattern.go new file mode 100644 index 0000000..6c9ae36 --- /dev/null +++ b/pkg/config/pattern.go @@ -0,0 +1,89 @@ +package config + +import ( + "fmt" + "strings" +) + +// HostnamePattern represents a compiled glob pattern for hostname matching. +// Segments are dot-split elements: literal labels, "*" (one label), or "**" (one or more labels). +type HostnamePattern struct { + Raw string + Segments []string +} + +// IsHostnamePattern returns true if the hostname value contains glob wildcards. +// Since * is not a valid DNS character, any hostname containing * is a pattern. +func IsHostnamePattern(value string) bool { + return strings.Contains(value, "*") +} + +// CompileHostnamePattern parses a glob pattern string into a HostnamePattern. +func CompileHostnamePattern(raw string) (HostnamePattern, error) { + if raw == "" { + return HostnamePattern{}, fmt.Errorf("empty pattern") + } + + segments := strings.Split(raw, ".") + for i, seg := range segments { + if seg == "" { + return HostnamePattern{}, fmt.Errorf("empty segment at position %d in pattern %q", i, raw) + } + if seg == "*" || seg == "**" { + continue + } + // A segment with a mix of wildcards and literals (e.g. "foo*bar") is not supported + if strings.Contains(seg, "*") { + return HostnamePattern{}, fmt.Errorf("partial wildcard %q at position %d not supported (use full * or ** segments)", seg, i) + } + } + + return HostnamePattern{Raw: raw, Segments: segments}, nil +} + +// Matches returns true if hostname matches the glob pattern. +func (p *HostnamePattern) Matches(hostname string) bool { + labels := strings.Split(hostname, ".") + return matchSegments(p.Segments, labels) +} + +// matchSegments recursively matches pattern segments against hostname labels. +func matchSegments(segments []string, labels []string) bool { + si, li := 0, 0 + + for si < len(segments) { + seg := segments[si] + + switch seg { + case "**": + // ** must match one or more labels + // Try consuming 1..N remaining labels, then match rest of pattern + remaining := segments[si+1:] + for take := 1; take <= len(labels)-li; take++ { + if matchSegments(remaining, labels[li+take:]) { + return true + } + } + return false + + case "*": + // * matches exactly one label + if li >= len(labels) { + return false + } + si++ + li++ + + default: + // Literal match + if li >= len(labels) || labels[li] != seg { + return false + } + si++ + li++ + } + } + + // Both must be fully consumed + return si == len(segments) && li == len(labels) +} diff --git a/pkg/config/pattern_test.go b/pkg/config/pattern_test.go new file mode 100644 index 0000000..6a7ce3c --- /dev/null +++ b/pkg/config/pattern_test.go @@ -0,0 +1,192 @@ +package config + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestIsHostnamePattern(t *testing.T) { + assert.True(t, IsHostnamePattern("*.github.com")) + assert.True(t, IsHostnamePattern("**.internal.cloudapp.net")) + assert.True(t, IsHostnamePattern("foo.*.*.bar.com")) + assert.True(t, IsHostnamePattern("*.*.internal.cloudapp.net")) + assert.True(t, IsHostnamePattern("**.github.com")) + assert.False(t, IsHostnamePattern("github.com")) + assert.False(t, IsHostnamePattern("api.github.com")) +} + +func TestCompileHostnamePattern(t *testing.T) { + tests := []struct { + name string + raw string + wantErr bool + }{ + {"single wildcard", "*.github.com", false}, + {"double wildcard", "**.internal.cloudapp.net", false}, + {"middle wildcards", "actions.githubusercontent.com.*.*.internal.cloudapp.net", false}, + {"mixed", "foo.**.bar.*.baz.com", false}, + {"empty pattern", "", true}, + {"empty segment", "foo..bar.com", true}, + {"partial wildcard", "foo*.bar.com", true}, + {"partial wildcard mid", "foo.b*r.com", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p, err := CompileHostnamePattern(tt.raw) + if tt.wantErr { + assert.Error(t, err) + } else { + require.NoError(t, err) + assert.Equal(t, tt.raw, p.Raw) + } + }) + } +} + +func TestHostnamePatternMatches(t *testing.T) { + tests := []struct { + name string + pattern string + hostname string + want bool + }{ + // Single wildcard (*) + { + "star matches one label", + "*.github.com", + "api.github.com", + true, + }, + { + "star does not match zero labels", + "*.github.com", + "github.com", + false, + }, + { + "star does not match two labels", + "*.github.com", + "a.b.github.com", + false, + }, + // Multiple wildcards + { + "two stars in middle", + "actions.githubusercontent.com.*.*.internal.cloudapp.net", + "actions.githubusercontent.com.j4d2msqy.phxx.internal.cloudapp.net", + true, + }, + { + "two stars wrong count", + "actions.githubusercontent.com.*.*.internal.cloudapp.net", + "actions.githubusercontent.com.only1.internal.cloudapp.net", + false, + }, + { + "two stars too many", + "actions.githubusercontent.com.*.*.internal.cloudapp.net", + "actions.githubusercontent.com.a.b.c.internal.cloudapp.net", + false, + }, + // Double wildcard (**) + { + "doublestar matches one label", + "**.internal.cloudapp.net", + "phxx.internal.cloudapp.net", + true, + }, + { + "doublestar matches multiple labels", + "**.internal.cloudapp.net", + "a.b.c.internal.cloudapp.net", + true, + }, + { + "doublestar does not match zero labels", + "**.internal.cloudapp.net", + "internal.cloudapp.net", + false, + }, + { + "doublestar with prefix", + "actions.githubusercontent.com.**.internal.cloudapp.net", + "actions.githubusercontent.com.abc.def.ghi.internal.cloudapp.net", + true, + }, + { + "doublestar with prefix one label", + "actions.githubusercontent.com.**.internal.cloudapp.net", + "actions.githubusercontent.com.abc.internal.cloudapp.net", + true, + }, + // Literal only (no wildcards — still valid pattern) + { + "exact match no wildcards", + "github.com", + "github.com", + true, + }, + { + "no match no wildcards", + "github.com", + "api.github.com", + false, + }, + // Mixed patterns + { + "star then doublestar", + "*.**.example.com", + "a.b.c.example.com", + true, + }, + { + "star then doublestar minimum", + "*.**.example.com", + "a.b.example.com", + true, + }, + { + "star then doublestar too few", + "*.**.example.com", + "a.example.com", + false, + }, + // Region-style patterns + { + "region wildcard", + "storage.*.azure.com", + "storage.westus2.azure.com", + true, + }, + { + "region wildcard no match", + "storage.*.azure.com", + "storage.azure.com", + false, + }, + // Trailing mismatch + { + "wrong suffix", + "*.github.com", + "api.gitlab.com", + false, + }, + { + "wrong prefix", + "actions.*.internal.cloudapp.net", + "other.foo.internal.cloudapp.net", + false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p, err := CompileHostnamePattern(tt.pattern) + require.NoError(t, err) + assert.Equal(t, tt.want, p.Matches(tt.hostname)) + }) + } +} diff --git a/pkg/dns/server.go b/pkg/dns/server.go index 1ccee41..043dfd2 100644 --- a/pkg/dns/server.go +++ b/pkg/dns/server.go @@ -552,14 +552,21 @@ func (s *Server) getHostnamePorts(hostname string) []config.Port { // First try exact match for _, rule := range rules { - if rule.Type == config.RuleTypeHostname && rule.Value == hostname { + if rule.Type == config.RuleTypeHostname && rule.Pattern == nil && rule.Value == hostname { return rule.Ports } } // Check parent domain for _, rule := range rules { - if rule.Type == config.RuleTypeHostname && strings.HasSuffix(hostname, "."+rule.Value) { + if rule.Type == config.RuleTypeHostname && rule.Pattern == nil && strings.HasSuffix(hostname, "."+rule.Value) { + return rule.Ports + } + } + + // Check hostname patterns + for _, rule := range rules { + if rule.Type == config.RuleTypeHostname && rule.Pattern != nil && rule.Pattern.Matches(hostname) { return rule.Ports } } diff --git a/pkg/dns/server_test.go b/pkg/dns/server_test.go index beb8bcf..30faa69 100644 --- a/pkg/dns/server_test.go +++ b/pkg/dns/server_test.go @@ -488,6 +488,12 @@ func TestGetHostnamePorts(t *testing.T) { Action: config.ActionAllow, Ports: []config.Port{{Port: 80, Protocol: config.ProtocolAll}, {Port: 443, Protocol: config.ProtocolAll}}, }, + { + Type: config.RuleTypeHostname, + Value: "*.*.internal.cloudapp.net", + Action: config.ActionAllow, + Ports: []config.Port{{Port: 443, Protocol: config.ProtocolTCP}}, + }, } err := cfg.LoadConfigFromRules(rules, config.ActionDeny) require.NoError(t, err) @@ -499,6 +505,8 @@ func TestGetHostnamePorts(t *testing.T) { {"api.example.com", []config.Port{{Port: 443, Protocol: config.ProtocolAll}, {Port: 8443, Protocol: config.ProtocolAll}}}, {"example.com", []config.Port{{Port: 80, Protocol: config.ProtocolAll}, {Port: 443, Protocol: config.ProtocolAll}}}, {"sub.example.com", []config.Port{{Port: 80, Protocol: config.ProtocolAll}, {Port: 443, Protocol: config.ProtocolAll}}}, // Should match parent domain + {"abc.def.internal.cloudapp.net", []config.Port{{Port: 443, Protocol: config.ProtocolTCP}}}, // Should match pattern + {"only1.internal.cloudapp.net", nil}, // Pattern needs 2 labels {"other.com", nil}, } @@ -782,6 +790,36 @@ func TestIsQueryAllowed(t *testing.T) { domain: "1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2.ip6.arpa", expected: true, }, + { + name: "deny-by-default, hostname pattern allowed", + filterEnabled: true, + defaultAction: config.ActionDeny, + rules: []config.Rule{ + {Type: config.RuleTypeHostname, Value: "*.*.internal.cloudapp.net", Action: config.ActionAllow}, + }, + domain: "abc.def.internal.cloudapp.net", + expected: true, + }, + { + name: "deny-by-default, hostname pattern not matched (too few labels)", + filterEnabled: true, + defaultAction: config.ActionDeny, + rules: []config.Rule{ + {Type: config.RuleTypeHostname, Value: "*.*.internal.cloudapp.net", Action: config.ActionAllow}, + }, + domain: "only1.internal.cloudapp.net", + expected: false, + }, + { + name: "allow-by-default, hostname pattern denied", + filterEnabled: true, + defaultAction: config.ActionAllow, + rules: []config.Rule{ + {Type: config.RuleTypeHostname, Value: "evil.*.example.com", Action: config.ActionDeny}, + }, + domain: "evil.anything.example.com", + expected: false, + }, } for _, tt := range tests { From caaf02f18bb56072535f0402d8f1e0252a1c6280 Mon Sep 17 00:00:00 2001 From: Matthew DeVenny Date: Mon, 6 Apr 2026 14:39:37 -0700 Subject: [PATCH 2/7] review changes Signed-off-by: Matthew DeVenny --- README.md | 12 ++++++++++++ design.md | 3 ++- pkg/config/config.go | 8 ++++---- pkg/config/config_test.go | 8 ++++---- pkg/dns/server_test.go | 2 +- 5 files changed, 23 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index c06569b..8fc60b4 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,18 @@ Add the [CargoWall GitHub Action](https://github.com/code-cargo/cargowall-action registry.npmjs.org ``` +Hostname rules support **glob patterns** for matching dynamic hostnames: + +- `*` matches exactly one DNS label +- `**` matches one or more DNS labels + +```yaml +allowed-hosts: | + github.com, + actions.githubusercontent.com.*.*.internal.cloudapp.net, + **.storage.azure.com +``` + See the [cargowall-action README](https://github.com/code-cargo/cargowall-action) for full usage, inputs, outputs, and examples. --- diff --git a/design.md b/design.md index a20e5c9..8a99908 100644 --- a/design.md +++ b/design.md @@ -7,6 +7,7 @@ Dual-stack (IPv4/IPv6) L4 firewall using TC eBPF egress filtering, cgroup socket - **Dual-Stack L4 Firewall**: Filters TCP and UDP traffic on both IPv4 and IPv6 - **Protocol Handling**: Blocks non-TCP/UDP on IPv4; allows ICMPv6 and IPv6 multicast (`ff00::/8`); passes non-IP traffic (ARP) - **DNS Proxy with JIT Resolution**: Intercepts DNS queries and updates firewall rules in real-time +- **Hostname Glob Patterns**: `*` (one label) and `**` (one or more labels) wildcards for matching dynamic hostnames - **DNS Query Filtering**: Blocks queries for non-allowed domains to prevent DNS tunneling - **Port-Specific Rules**: Granular port-based filtering including wildcard CIDRs (`0.0.0.0/0`, `::/0`) - **LPM Trie Optimization**: Separate IPv4 and IPv6 longest-prefix-match tries for efficient CIDR lookups @@ -362,7 +363,7 @@ sequenceDiagram - Env vars: `CARGOWALL_DEFAULT_ACTION`, `CARGOWALL_ALLOWED_HOSTS`, `CARGOWALL_ALLOWED_CIDRS`, `CARGOWALL_BLOCKED_HOSTS`, `CARGOWALL_BLOCKED_CIDRS` - Port format in env: `host:port1;port2` (e.g., `github.com:443;80`) - Subdomain matching: `lb-140-82-113-22-iad.github.com` matches a `github.com` rule -- Wildcard hostname normalization: `*.github.com` → `github.com` (parent domain matching handles subdomains) +- Glob pattern matching for hostnames: `*` matches one DNS label, `**` matches one or more (e.g., `actions.githubusercontent.com.*.*.internal.cloudapp.net`, `**.storage.azure.com`) - IP-to-hostname reverse mapping via `UpdateDNSMapping()` with bounded cache (10,000 entries, 24h TTL) - Rule conflict detection: `CheckIPRuleConflict()` finds most specific CIDR by prefix length, checks port overlap, deny wins - `EnsureDNSAllowed(ips)` — adds /32 allow rules on port 53 for upstream DNS IPs diff --git a/pkg/config/config.go b/pkg/config/config.go index fa31c77..a68a216 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -134,12 +134,12 @@ type Manager struct { config *FirewallConfig resolvedRules []ResolvedRule hostnameCache map[string][]net.IP - ipToHostname map[string]string // Reverse lookup: IP -> hostname - ipLastSeen map[string]time.Time // Track when each IP was last seen - trackedHostnames map[string]Action // Track hostnames we have rules for (hostname -> action) + ipToHostname map[string]string // Reverse lookup: IP -> hostname + ipLastSeen map[string]time.Time // Track when each IP was last seen + trackedHostnames map[string]Action // Track hostnames we have rules for (hostname -> action) hostnamePatterns []hostnamePatternEntry // Compiled glob patterns for hostname matching maxCacheSize int // Maximum number of IPs to cache - dnsCacheTTL time.Duration // How long to keep DNS entries + dnsCacheTTL time.Duration // How long to keep DNS entries } // NewConfigManager creates a new configuration manager diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index 5247817..351fe03 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -443,7 +443,7 @@ func TestLoadFromEnv_WithPorts(t *testing.T) { } } -func TestLoadFromEnv_WildcardNormalization(t *testing.T) { +func TestLoadFromEnv_WildcardPattern(t *testing.T) { // Save and restore env vars envVars := []string{ "CARGOWALL_DEFAULT_ACTION", @@ -483,9 +483,9 @@ func TestLoadFromEnv_WildcardNormalization(t *testing.T) { t.Fatalf("expected 2 rules, got %d", len(cm.config.Rules)) } - // *.github.com should be normalized to github.com - if cm.config.Rules[0].Value != "github.com" { - t.Errorf("rule[0].Value = %q, want %q (wildcard should be normalized)", cm.config.Rules[0].Value, "github.com") + // *.github.com is a glob pattern — should be preserved as-is (not normalized) + if cm.config.Rules[0].Value != "*.github.com" { + t.Errorf("rule[0].Value = %q, want %q (pattern should be preserved)", cm.config.Rules[0].Value, "*.github.com") } if !reflect.DeepEqual(cm.config.Rules[0].Ports, []Port{{Port: 443, Protocol: ProtocolAll}}) { t.Errorf("rule[0].Ports = %v, want [{443 all}]", cm.config.Rules[0].Ports) diff --git a/pkg/dns/server_test.go b/pkg/dns/server_test.go index 30faa69..ce35110 100644 --- a/pkg/dns/server_test.go +++ b/pkg/dns/server_test.go @@ -506,7 +506,7 @@ func TestGetHostnamePorts(t *testing.T) { {"example.com", []config.Port{{Port: 80, Protocol: config.ProtocolAll}, {Port: 443, Protocol: config.ProtocolAll}}}, {"sub.example.com", []config.Port{{Port: 80, Protocol: config.ProtocolAll}, {Port: 443, Protocol: config.ProtocolAll}}}, // Should match parent domain {"abc.def.internal.cloudapp.net", []config.Port{{Port: 443, Protocol: config.ProtocolTCP}}}, // Should match pattern - {"only1.internal.cloudapp.net", nil}, // Pattern needs 2 labels + {"only1.internal.cloudapp.net", nil}, // Pattern needs 2 labels {"other.com", nil}, } From 797089b7323ff9f2942413d05d426a327be58b1c Mon Sep 17 00:00:00 2001 From: Matthew DeVenny Date: Mon, 6 Apr 2026 14:58:32 -0700 Subject: [PATCH 3/7] review changes Signed-off-by: Matthew DeVenny --- pkg/config/config.go | 62 ++++++++++---------------------------- pkg/config/config_test.go | 19 ------------ pkg/config/pattern.go | 24 +++++++-------- pkg/config/pattern_test.go | 18 +++++------ 4 files changed, 37 insertions(+), 86 deletions(-) diff --git a/pkg/config/config.go b/pkg/config/config.go index a68a216..2aec7a7 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -114,32 +114,23 @@ type ResolvedRule struct { Value string // Original value (hostname or CIDR string) IPs []net.IP // For hostnames: resolved IPs. For CIDR: empty IPNet *net.IPNet // For CIDR blocks only - Pattern *HostnamePattern // Non-nil for hostname rules with glob wildcards + Pattern *hostnamePattern // Non-nil for hostname rules with glob wildcards Ports []Port Action Action AutoAddedType AutoAddedType // Why this rule was auto-added (empty for user-configured rules) } -// hostnamePatternEntry holds a compiled glob pattern with its associated rule metadata. -type hostnamePatternEntry struct { - Pattern HostnamePattern - Action Action - Ports []Port - AutoAddedType AutoAddedType -} - // Manager manages the firewall configuration and hostname resolution type Manager struct { mu sync.RWMutex config *FirewallConfig resolvedRules []ResolvedRule hostnameCache map[string][]net.IP - ipToHostname map[string]string // Reverse lookup: IP -> hostname - ipLastSeen map[string]time.Time // Track when each IP was last seen - trackedHostnames map[string]Action // Track hostnames we have rules for (hostname -> action) - hostnamePatterns []hostnamePatternEntry // Compiled glob patterns for hostname matching - maxCacheSize int // Maximum number of IPs to cache - dnsCacheTTL time.Duration // How long to keep DNS entries + ipToHostname map[string]string // Reverse lookup: IP -> hostname + ipLastSeen map[string]time.Time // Track when each IP was last seen + trackedHostnames map[string]Action // Track hostnames we have rules for (hostname -> action) + maxCacheSize int // Maximum number of IPs to cache + dnsCacheTTL time.Duration // How long to keep DNS entries } // NewConfigManager creates a new configuration manager @@ -325,9 +316,6 @@ func (cm *Manager) LoadFromEnv() error { for _, entry := range splitAndTrim(allowedHosts) { if entry != "" { host, ports := parseHostWithPorts(entry) - if !IsHostnamePattern(host) { - host = normalizeHostname(host) - } rules = append(rules, Rule{ Type: RuleTypeHostname, Value: host, @@ -358,9 +346,6 @@ func (cm *Manager) LoadFromEnv() error { for _, entry := range splitAndTrim(blockedHosts) { if entry != "" { host, ports := parseHostWithPorts(entry) - if !IsHostnamePattern(host) { - host = normalizeHostname(host) - } rules = append(rules, Rule{ Type: RuleTypeHostname, Value: host, @@ -451,13 +436,6 @@ func parseHostWithPorts(entry string) (string, []Port) { return host, ports } -// normalizeHostname strips a leading "*." wildcard prefix from a hostname. -// Since parent domain matching already handles subdomains, *.github.com is -// equivalent to github.com. -func normalizeHostname(host string) string { - return strings.TrimPrefix(host, "*.") -} - // GetResolvedRules returns the current resolved rules func (cm *Manager) GetResolvedRules() []ResolvedRule { cm.mu.RLock() @@ -592,13 +570,13 @@ func (cm *Manager) GetTrackedHostnameAction(hostname string) Action { } // Check hostname patterns (glob matching) - for _, entry := range cm.hostnamePatterns { - if entry.Pattern.Matches(hostname) { + for _, rule := range cm.resolvedRules { + if rule.Pattern != nil && rule.Pattern.Matches(hostname) { slog.Debug("Found pattern match", "hostname", hostname, - "pattern", entry.Pattern.Raw, - "action", entry.Action) - return entry.Action + "pattern", rule.Pattern.Raw, + "action", rule.Action) + return rule.Action } } @@ -709,9 +687,9 @@ func (cm *Manager) FindTrackedHostname(name string) string { } // Pattern match - for _, entry := range cm.hostnamePatterns { - if entry.Pattern.Matches(name) { - return entry.Pattern.Raw + for _, rule := range cm.resolvedRules { + if rule.Pattern != nil && rule.Pattern.Matches(name) { + return rule.Pattern.Raw } } return "" @@ -1040,7 +1018,6 @@ func (cm *Manager) resolveRules() error { } cm.resolvedRules = nil - cm.hostnamePatterns = nil for _, rule := range cm.config.Rules { resolved := ResolvedRule{ @@ -1053,20 +1030,13 @@ func (cm *Manager) resolveRules() error { switch rule.Type { case RuleTypeHostname: - if IsHostnamePattern(rule.Value) { - // Compile glob pattern and store separately - pattern, err := CompileHostnamePattern(rule.Value) + if isHostnamePattern(rule.Value) { + pattern, err := compileHostnamePattern(rule.Value) if err != nil { slog.Error("Invalid hostname pattern", "value", rule.Value, "error", err) continue } resolved.Pattern = &pattern - cm.hostnamePatterns = append(cm.hostnamePatterns, hostnamePatternEntry{ - Pattern: pattern, - Action: rule.Action, - Ports: rule.Ports, - AutoAddedType: rule.AutoAddedType, - }) } else { // Track ALL hostnames we have rules for - will resolve JIT when DNS queries arrive cm.trackedHostnames[rule.Value] = rule.Action diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index 351fe03..45963ed 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -355,25 +355,6 @@ func TestParseHostWithPorts(t *testing.T) { } } -func TestNormalizeHostname(t *testing.T) { - tests := []struct { - input string - want string - }{ - {"*.github.com", "github.com"}, - {"github.com", "github.com"}, - {"*.*.example.com", "*.example.com"}, - } - - for _, tt := range tests { - t.Run(tt.input, func(t *testing.T) { - got := normalizeHostname(tt.input) - if got != tt.want { - t.Errorf("normalizeHostname(%q) = %q, want %q", tt.input, got, tt.want) - } - }) - } -} func TestLoadFromEnv_WithPorts(t *testing.T) { // Save and restore env vars diff --git a/pkg/config/pattern.go b/pkg/config/pattern.go index 6c9ae36..e8ac717 100644 --- a/pkg/config/pattern.go +++ b/pkg/config/pattern.go @@ -5,44 +5,44 @@ import ( "strings" ) -// HostnamePattern represents a compiled glob pattern for hostname matching. +// hostnamePattern represents a compiled glob pattern for hostname matching. // Segments are dot-split elements: literal labels, "*" (one label), or "**" (one or more labels). -type HostnamePattern struct { +type hostnamePattern struct { Raw string Segments []string } -// IsHostnamePattern returns true if the hostname value contains glob wildcards. -// Since * is not a valid DNS character, any hostname containing * is a pattern. -func IsHostnamePattern(value string) bool { +// isHostnamePattern returns true if the value contains glob wildcards. +func isHostnamePattern(value string) bool { return strings.Contains(value, "*") } -// CompileHostnamePattern parses a glob pattern string into a HostnamePattern. -func CompileHostnamePattern(raw string) (HostnamePattern, error) { +// compileHostnamePattern parses a glob pattern string into a hostnamePattern. +// Wildcards: "*" matches one DNS label, "**" matches one or more labels. +func compileHostnamePattern(raw string) (hostnamePattern, error) { if raw == "" { - return HostnamePattern{}, fmt.Errorf("empty pattern") + return hostnamePattern{}, fmt.Errorf("empty pattern") } segments := strings.Split(raw, ".") for i, seg := range segments { if seg == "" { - return HostnamePattern{}, fmt.Errorf("empty segment at position %d in pattern %q", i, raw) + return hostnamePattern{}, fmt.Errorf("empty segment at position %d in pattern %q", i, raw) } if seg == "*" || seg == "**" { continue } // A segment with a mix of wildcards and literals (e.g. "foo*bar") is not supported if strings.Contains(seg, "*") { - return HostnamePattern{}, fmt.Errorf("partial wildcard %q at position %d not supported (use full * or ** segments)", seg, i) + return hostnamePattern{}, fmt.Errorf("partial wildcard %q at position %d not supported (use full * or ** segments)", seg, i) } } - return HostnamePattern{Raw: raw, Segments: segments}, nil + return hostnamePattern{Raw: raw, Segments: segments}, nil } // Matches returns true if hostname matches the glob pattern. -func (p *HostnamePattern) Matches(hostname string) bool { +func (p *hostnamePattern) Matches(hostname string) bool { labels := strings.Split(hostname, ".") return matchSegments(p.Segments, labels) } diff --git a/pkg/config/pattern_test.go b/pkg/config/pattern_test.go index 6a7ce3c..7b3d29c 100644 --- a/pkg/config/pattern_test.go +++ b/pkg/config/pattern_test.go @@ -8,13 +8,13 @@ import ( ) func TestIsHostnamePattern(t *testing.T) { - assert.True(t, IsHostnamePattern("*.github.com")) - assert.True(t, IsHostnamePattern("**.internal.cloudapp.net")) - assert.True(t, IsHostnamePattern("foo.*.*.bar.com")) - assert.True(t, IsHostnamePattern("*.*.internal.cloudapp.net")) - assert.True(t, IsHostnamePattern("**.github.com")) - assert.False(t, IsHostnamePattern("github.com")) - assert.False(t, IsHostnamePattern("api.github.com")) + assert.True(t, isHostnamePattern("*.github.com")) + assert.True(t, isHostnamePattern("**.internal.cloudapp.net")) + assert.True(t, isHostnamePattern("foo.*.*.bar.com")) + assert.True(t, isHostnamePattern("*.*.internal.cloudapp.net")) + assert.True(t, isHostnamePattern("**.github.com")) + assert.False(t, isHostnamePattern("github.com")) + assert.False(t, isHostnamePattern("api.github.com")) } func TestCompileHostnamePattern(t *testing.T) { @@ -35,7 +35,7 @@ func TestCompileHostnamePattern(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - p, err := CompileHostnamePattern(tt.raw) + p, err := compileHostnamePattern(tt.raw) if tt.wantErr { assert.Error(t, err) } else { @@ -184,7 +184,7 @@ func TestHostnamePatternMatches(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - p, err := CompileHostnamePattern(tt.pattern) + p, err := compileHostnamePattern(tt.pattern) require.NoError(t, err) assert.Equal(t, tt.want, p.Matches(tt.hostname)) }) From 731c573fdee9204df6403b6124433aa7539ecb0c Mon Sep 17 00:00:00 2001 From: Matthew DeVenny Date: Mon, 6 Apr 2026 15:31:33 -0700 Subject: [PATCH 4/7] formatting Signed-off-by: Matthew DeVenny --- pkg/config/config_test.go | 1 - 1 file changed, 1 deletion(-) diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index 45963ed..2638484 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -355,7 +355,6 @@ func TestParseHostWithPorts(t *testing.T) { } } - func TestLoadFromEnv_WithPorts(t *testing.T) { // Save and restore env vars envVars := []string{ From 320b88d7230549ec46e1587fc8252b338231b4bf Mon Sep 17 00:00:00 2001 From: Matthew DeVenny Date: Mon, 6 Apr 2026 15:50:32 -0700 Subject: [PATCH 5/7] review changes Signed-off-by: Matthew DeVenny --- .gitignore | 1 + pkg/config/config.go | 21 +++++++++++---------- pkg/config/pattern.go | 5 ----- pkg/dns/server.go | 4 ++-- 4 files changed, 14 insertions(+), 17 deletions(-) diff --git a/.gitignore b/.gitignore index f624aaf..d30ec65 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ .idea .claude +.omc node_modules bin /cargowall diff --git a/pkg/config/config.go b/pkg/config/config.go index 2aec7a7..8bab262 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -120,6 +120,15 @@ type ResolvedRule struct { AutoAddedType AutoAddedType // Why this rule was auto-added (empty for user-configured rules) } +// MatchesHostname returns true if the hostname matches this hostname rule +// via glob pattern, exact match, or parent domain (subdomain) match. +func (r *ResolvedRule) MatchesHostname(hostname string) bool { + if r.Pattern != nil { + return r.Pattern.Matches(hostname) + } + return hostname == r.Value || strings.HasSuffix(hostname, "."+r.Value) +} + // Manager manages the firewall configuration and hostname resolution type Manager struct { mu sync.RWMutex @@ -923,11 +932,7 @@ func (cm *Manager) GetAutoAllowedTypeForHostname(hostname string) AutoAddedType if rule.Type != RuleTypeHostname || rule.AutoAddedType == AutoAddedTypeNone || rule.Action != ActionAllow { continue } - if rule.Pattern != nil { - if rule.Pattern.Matches(hostname) { - return rule.AutoAddedType - } - } else if hostname == rule.Value || strings.HasSuffix(hostname, "."+rule.Value) { + if rule.MatchesHostname(hostname) { return rule.AutoAddedType } } @@ -978,11 +983,7 @@ func (cm *Manager) GetAutoAllowedType(ip string, port uint16, hostname string) A switch rule.Type { case RuleTypeHostname: - if rule.Pattern != nil { - if rule.Pattern.Matches(hostname) { - return rule.AutoAddedType - } - } else if hostname == rule.Value || strings.HasSuffix(hostname, "."+rule.Value) { + if rule.MatchesHostname(hostname) { return rule.AutoAddedType } case RuleTypeCIDR: diff --git a/pkg/config/pattern.go b/pkg/config/pattern.go index e8ac717..b264df4 100644 --- a/pkg/config/pattern.go +++ b/pkg/config/pattern.go @@ -56,8 +56,6 @@ func matchSegments(segments []string, labels []string) bool { switch seg { case "**": - // ** must match one or more labels - // Try consuming 1..N remaining labels, then match rest of pattern remaining := segments[si+1:] for take := 1; take <= len(labels)-li; take++ { if matchSegments(remaining, labels[li+take:]) { @@ -67,7 +65,6 @@ func matchSegments(segments []string, labels []string) bool { return false case "*": - // * matches exactly one label if li >= len(labels) { return false } @@ -75,7 +72,6 @@ func matchSegments(segments []string, labels []string) bool { li++ default: - // Literal match if li >= len(labels) || labels[li] != seg { return false } @@ -84,6 +80,5 @@ func matchSegments(segments []string, labels []string) bool { } } - // Both must be fully consumed return si == len(segments) && li == len(labels) } diff --git a/pkg/dns/server.go b/pkg/dns/server.go index 043dfd2..77f2c8e 100644 --- a/pkg/dns/server.go +++ b/pkg/dns/server.go @@ -564,9 +564,9 @@ func (s *Server) getHostnamePorts(hostname string) []config.Port { } } - // Check hostname patterns + // Check hostname patterns (glob matching) for _, rule := range rules { - if rule.Type == config.RuleTypeHostname && rule.Pattern != nil && rule.Pattern.Matches(hostname) { + if rule.Type == config.RuleTypeHostname && rule.Pattern != nil && rule.MatchesHostname(hostname) { return rule.Ports } } From 31eb38345bbafdb4f4af1d48e2f23b619d6037cc Mon Sep 17 00:00:00 2001 From: Matthew DeVenny Date: Mon, 6 Apr 2026 16:01:55 -0700 Subject: [PATCH 6/7] review changes Signed-off-by: Matthew DeVenny --- pkg/config/config.go | 27 ++++++++++++----- pkg/config/config_test.go | 45 ++++++++++++++++++++++++++-- pkg/config/pattern.go | 61 ++++++++++++++++++++++---------------- pkg/config/pattern_test.go | 3 ++ pkg/dns/server.go | 8 ++--- 5 files changed, 104 insertions(+), 40 deletions(-) diff --git a/pkg/config/config.go b/pkg/config/config.go index 8bab262..c8c166a 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -566,29 +566,41 @@ func (cm *Manager) GetTrackedHostnameAction(hostname string) Action { return action } - // Check if it's a subdomain of a tracked hostname - // For example, if "google.com" is tracked, then "www.google.com" should inherit the same action + // Check if it's a subdomain of a tracked hostname. + // Don't return yet — a more specific deny pattern may override. + var parentAction Action for trackedHost, action := range cm.trackedHostnames { if strings.HasSuffix(hostname, "."+trackedHost) { slog.Debug("Found parent domain match", "hostname", hostname, "parent", trackedHost, "action", action) - return action + parentAction = action + break } } - // Check hostname patterns (glob matching) + // Check hostname patterns (glob matching). + // A deny pattern overrides a parent-domain allow (more specific wins). for _, rule := range cm.resolvedRules { if rule.Pattern != nil && rule.Pattern.Matches(hostname) { slog.Debug("Found pattern match", "hostname", hostname, "pattern", rule.Pattern.Raw, "action", rule.Action) - return rule.Action + if rule.Action == ActionDeny { + return ActionDeny + } + if parentAction == "" { + parentAction = rule.Action + } } } + if parentAction != "" { + return parentAction + } + slog.Debug("No tracked hostname found", "hostname", hostname) return "" } @@ -695,10 +707,11 @@ func (cm *Manager) FindTrackedHostname(name string) string { } } - // Pattern match + // Pattern match — return the actual hostname (not the pattern string) + // so callers can safely pass it to UpdateDNSMapping / GetTrackedHostnameAction. for _, rule := range cm.resolvedRules { if rule.Pattern != nil && rule.Pattern.Matches(name) { - return rule.Pattern.Raw + return name } } return "" diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index 2638484..86199f5 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -937,9 +937,9 @@ func TestFindTrackedHostnameWithPatterns(t *testing.T) { t.Errorf("FindTrackedHostname(api.github.com) = %q, want github.com", got) } - // Pattern match returns the raw pattern - if got := cm.FindTrackedHostname("abc.def.internal.cloudapp.net"); got != "*.*.internal.cloudapp.net" { - t.Errorf("FindTrackedHostname(abc.def.internal.cloudapp.net) = %q, want *.*.internal.cloudapp.net", got) + // Pattern match returns the actual hostname (not the pattern string) + if got := cm.FindTrackedHostname("abc.def.internal.cloudapp.net"); got != "abc.def.internal.cloudapp.net" { + t.Errorf("FindTrackedHostname(abc.def.internal.cloudapp.net) = %q, want abc.def.internal.cloudapp.net", got) } // No match @@ -997,3 +997,42 @@ func TestLeadingWildcardIsPattern(t *testing.T) { t.Errorf("a.b.github.com should NOT match *.github.com (single * = one label), got %q", action) } } + +func TestDenyPatternOverridesParentAllow(t *testing.T) { + cm := NewConfigManager() + err := cm.LoadConfigFromRules([]Rule{ + {Type: RuleTypeHostname, Value: "example.com", Action: ActionAllow}, + {Type: RuleTypeHostname, Value: "evil.*.example.com", Action: ActionDeny}, + }, ActionDeny) + if err != nil { + t.Fatalf("LoadConfigFromRules() error = %v", err) + } + + // Parent domain allow still works for normal subdomains + if action := cm.GetTrackedHostnameAction("api.example.com"); action != ActionAllow { + t.Errorf("api.example.com should be allowed via parent domain, got %q", action) + } + + // Deny pattern overrides the parent-domain allow + if action := cm.GetTrackedHostnameAction("evil.foo.example.com"); action != ActionDeny { + t.Errorf("evil.foo.example.com should be denied by pattern even though example.com is allowed, got %q", action) + } +} + +func TestConsecutiveDoubleStarRejected(t *testing.T) { + _, err := compileHostnamePattern("**.**.com") + if err == nil { + t.Error("expected error for consecutive ** segments, got nil") + } + + _, err = compileHostnamePattern("foo.**.**.bar.com") + if err == nil { + t.Error("expected error for consecutive ** segments, got nil") + } + + // Non-consecutive ** is fine + _, err = compileHostnamePattern("**.foo.**.com") + if err != nil { + t.Errorf("non-consecutive ** should be valid, got error: %v", err) + } +} diff --git a/pkg/config/pattern.go b/pkg/config/pattern.go index b264df4..fe8e779 100644 --- a/pkg/config/pattern.go +++ b/pkg/config/pattern.go @@ -30,6 +30,10 @@ func compileHostnamePattern(raw string) (hostnamePattern, error) { return hostnamePattern{}, fmt.Errorf("empty segment at position %d in pattern %q", i, raw) } if seg == "*" || seg == "**" { + // Reject consecutive ** segments (e.g. "**.**.com") — ambiguous and degenerate + if seg == "**" && i > 0 && segments[i-1] == "**" { + return hostnamePattern{}, fmt.Errorf("consecutive ** segments at positions %d-%d in pattern %q", i-1, i, raw) + } continue } // A segment with a mix of wildcards and literals (e.g. "foo*bar") is not supported @@ -47,38 +51,43 @@ func (p *hostnamePattern) Matches(hostname string) bool { return matchSegments(p.Segments, labels) } -// matchSegments recursively matches pattern segments against hostname labels. +// matchSegments matches pattern segments against hostname labels using dynamic +// programming to avoid exponential backtracking with multiple "**" segments. +// dp[si][li] reports whether segments[si:] matches labels[li:]. func matchSegments(segments []string, labels []string) bool { - si, li := 0, 0 + segCount := len(segments) + labelCount := len(labels) - for si < len(segments) { - seg := segments[si] + dp := make([][]bool, segCount+1) + for si := range dp { + dp[si] = make([]bool, labelCount+1) + } - switch seg { - case "**": - remaining := segments[si+1:] - for take := 1; take <= len(labels)-li; take++ { - if matchSegments(remaining, labels[li+take:]) { - return true - } - } - return false + // Both fully consumed — match + dp[segCount][labelCount] = true - case "*": - if li >= len(labels) { - return false - } - si++ - li++ - - default: - if li >= len(labels) || labels[li] != seg { - return false + for si := segCount - 1; si >= 0; si-- { + seg := segments[si] + for li := labelCount; li >= 0; li-- { + switch seg { + case "**": + // ** matches one or more labels + if li < labelCount && (dp[si][li+1] || dp[si+1][li+1]) { + dp[si][li] = true + } + case "*": + // * matches exactly one label + if li < labelCount && dp[si+1][li+1] { + dp[si][li] = true + } + default: + // Literal match + if li < labelCount && labels[li] == seg && dp[si+1][li+1] { + dp[si][li] = true + } } - si++ - li++ } } - return si == len(segments) && li == len(labels) + return dp[0][0] } diff --git a/pkg/config/pattern_test.go b/pkg/config/pattern_test.go index 7b3d29c..ad863cf 100644 --- a/pkg/config/pattern_test.go +++ b/pkg/config/pattern_test.go @@ -27,10 +27,13 @@ func TestCompileHostnamePattern(t *testing.T) { {"double wildcard", "**.internal.cloudapp.net", false}, {"middle wildcards", "actions.githubusercontent.com.*.*.internal.cloudapp.net", false}, {"mixed", "foo.**.bar.*.baz.com", false}, + {"non-consecutive doublestar", "**.foo.**.com", false}, {"empty pattern", "", true}, {"empty segment", "foo..bar.com", true}, {"partial wildcard", "foo*.bar.com", true}, {"partial wildcard mid", "foo.b*r.com", true}, + {"consecutive doublestar", "**.**.com", true}, + {"consecutive doublestar mid", "foo.**.**.bar.com", true}, } for _, tt := range tests { diff --git a/pkg/dns/server.go b/pkg/dns/server.go index 77f2c8e..dc1a9cc 100644 --- a/pkg/dns/server.go +++ b/pkg/dns/server.go @@ -557,16 +557,16 @@ func (s *Server) getHostnamePorts(hostname string) []config.Port { } } - // Check parent domain + // Check patterns first — more specific than parent-domain match for _, rule := range rules { - if rule.Type == config.RuleTypeHostname && rule.Pattern == nil && strings.HasSuffix(hostname, "."+rule.Value) { + if rule.Type == config.RuleTypeHostname && rule.Pattern != nil && rule.Pattern.Matches(hostname) { return rule.Ports } } - // Check hostname patterns (glob matching) + // Fall back to parent domain for _, rule := range rules { - if rule.Type == config.RuleTypeHostname && rule.Pattern != nil && rule.MatchesHostname(hostname) { + if rule.Type == config.RuleTypeHostname && rule.Pattern == nil && strings.HasSuffix(hostname, "."+rule.Value) { return rule.Ports } } From 88e796b3fe872a99526ce1c4f19264b583874a73 Mon Sep 17 00:00:00 2001 From: Matthew DeVenny Date: Mon, 6 Apr 2026 17:51:38 -0700 Subject: [PATCH 7/7] clarify segment wildcard rule Signed-off-by: Matthew DeVenny --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 8fc60b4..1496f7e 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,7 @@ Hostname rules support **glob patterns** for matching dynamic hostnames: - `*` matches exactly one DNS label - `**` matches one or more DNS labels +- Wildcards must be a full dot-separated segment — partial wildcards like `google.co*` are not supported ```yaml allowed-hosts: |