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/.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/README.md b/README.md index c06569b..1496f7e 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,19 @@ 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 +- Wildcards must be a full dot-separated segment — partial wildcards like `google.co*` are not supported + +```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/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/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 8b6d912..c8c166a 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -110,15 +110,25 @@ 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) } +// 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 @@ -315,7 +325,6 @@ func (cm *Manager) LoadFromEnv() error { for _, entry := range splitAndTrim(allowedHosts) { if entry != "" { host, ports := parseHostWithPorts(entry) - host = normalizeHostname(host) rules = append(rules, Rule{ Type: RuleTypeHostname, Value: host, @@ -346,7 +355,6 @@ func (cm *Manager) LoadFromEnv() error { for _, entry := range splitAndTrim(blockedHosts) { if entry != "" { host, ports := parseHostWithPorts(entry) - host = normalizeHostname(host) rules = append(rules, Rule{ Type: RuleTypeHostname, Value: host, @@ -437,13 +445,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() @@ -565,18 +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). + // 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) + if rule.Action == ActionDeny { + return ActionDeny + } + if parentAction == "" { + parentAction = rule.Action + } } } + if parentAction != "" { + return parentAction + } + slog.Debug("No tracked hostname found", "hostname", hostname) return "" } @@ -682,6 +706,14 @@ func (cm *Manager) FindTrackedHostname(name string) string { return trackedHost } } + + // 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 name + } + } return "" } @@ -913,7 +945,7 @@ 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.MatchesHostname(hostname) { return rule.AutoAddedType } } @@ -964,7 +996,7 @@ 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.MatchesHostname(hostname) { return rule.AutoAddedType } case RuleTypeCIDR: @@ -1012,16 +1044,25 @@ 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) { + pattern, err := compileHostnamePattern(rule.Value) + if err != nil { + slog.Error("Invalid hostname pattern", "value", rule.Value, "error", err) + continue + } + resolved.Pattern = &pattern } 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..86199f5 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -355,26 +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 envVars := []string{ @@ -443,7 +423,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 +463,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) @@ -891,3 +871,168 @@ 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 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 + 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) + } +} + +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 new file mode 100644 index 0000000..fe8e779 --- /dev/null +++ b/pkg/config/pattern.go @@ -0,0 +1,93 @@ +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 value contains glob wildcards. +func isHostnamePattern(value string) bool { + return strings.Contains(value, "*") +} + +// 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") + } + + 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 == "**" { + // 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 + 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 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 { + segCount := len(segments) + labelCount := len(labels) + + dp := make([][]bool, segCount+1) + for si := range dp { + dp[si] = make([]bool, labelCount+1) + } + + // Both fully consumed — match + dp[segCount][labelCount] = true + + 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 + } + } + } + } + + return dp[0][0] +} diff --git a/pkg/config/pattern_test.go b/pkg/config/pattern_test.go new file mode 100644 index 0000000..ad863cf --- /dev/null +++ b/pkg/config/pattern_test.go @@ -0,0 +1,195 @@ +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}, + {"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 { + 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..dc1a9cc 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 + // Check patterns first — more specific than parent-domain match for _, rule := range rules { - if rule.Type == config.RuleTypeHostname && strings.HasSuffix(hostname, "."+rule.Value) { + if rule.Type == config.RuleTypeHostname && rule.Pattern != nil && rule.Pattern.Matches(hostname) { + return rule.Ports + } + } + + // Fall back to parent domain + for _, rule := range rules { + if rule.Type == config.RuleTypeHostname && rule.Pattern == nil && strings.HasSuffix(hostname, "."+rule.Value) { return rule.Ports } } diff --git a/pkg/dns/server_test.go b/pkg/dns/server_test.go index beb8bcf..ce35110 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 {