mirror of
https://github.com/juanfont/headscale.git
synced 2026-02-07 20:04:00 +01:00
policy: validate autogroup:self sources in ACL rules
Tailscale validates that autogroup:self destinations in ACL rules can only be used when ALL sources are users, groups, autogroup:member, or wildcard (*). Previously, Headscale only performed this validation for SSH rules. Add validateACLSrcDstCombination() to enforce that tags, autogroup:tagged, hosts, and raw IPs cannot be used as sources with autogroup:self destinations. Invalid policies like `tag:client → autogroup:self:*` are now rejected at validation time, matching Tailscale behavior. Wildcard (*) is allowed because autogroup:self evaluation narrows it per-node to only the node's own IPs. Updates #3036
This commit is contained in:
parent
8c8413d0a3
commit
a0aa643b6f
@ -4,7 +4,7 @@
|
||||
|
||||
### Changes
|
||||
|
||||
- **ACL Policy**: Add ICMP and IPv6-ICMP protocols to default filter rules and export protocol constants [#3036](https://github.com/juanfont/headscale/pull/3036)
|
||||
- **ACL Policy**: Add ICMP and IPv6-ICMP protocols to default filter rules when no protocol is specified [#3036](https://github.com/juanfont/headscale/pull/3036)
|
||||
- **ACL Policy**: Fix autogroup:self handling for tagged nodes - tagged nodes no longer incorrectly receive autogroup:self filter rules [#3036](https://github.com/juanfont/headscale/pull/3036)
|
||||
|
||||
## 0.28.0 (2026-02-04)
|
||||
|
||||
@ -10142,29 +10142,28 @@ func TestTailscaleCompatErrorCases(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestTailscaleCompatErrorCasesHeadscaleDiffers documents cases where Tailscale produces
|
||||
// validation errors but Headscale does NOT. These represent compatibility gaps.
|
||||
// TestTailscaleCompatErrorCasesHeadscaleDiffers validates that Headscale correctly rejects
|
||||
// policies that Tailscale also rejects. These tests verify that autogroup:self destination
|
||||
// validation for ACL rules matches Tailscale's behavior.
|
||||
//
|
||||
// TODO: Tailscale validates that autogroup:self can only be used when ALL sources are
|
||||
// users, groups, or autogroup:member. Headscale does NOT currently perform this validation
|
||||
// for ACL rules (only for SSH rules). This means invalid policies like tag:client → autogroup:self:*
|
||||
// will be accepted by Headscale but rejected by Tailscale.
|
||||
// Tailscale validates that autogroup:self can only be used when ALL sources are
|
||||
// users, groups, or autogroup:member. Headscale now performs this same validation.
|
||||
//
|
||||
// Reference: /home/kradalby/acl-explore/findings/09-mixed-scenarios.md.
|
||||
func TestTailscaleCompatErrorCasesHeadscaleDiffers(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// These tests document where Headscale behavior DIFFERS from Tailscale.
|
||||
// These tests verify that Headscale rejects policies the same way Tailscale does.
|
||||
// Tailscale rejects these policies at validation time (400 Bad Request),
|
||||
// but Headscale currently accepts them.
|
||||
// and Headscale now does the same.
|
||||
tests := []struct {
|
||||
name string
|
||||
policy string
|
||||
tailscaleError string // What Tailscale would return
|
||||
tailscaleError string // What Tailscale returns (and Headscale should match)
|
||||
reference string
|
||||
}{
|
||||
// Test 2.5: tag:client → autogroup:self:* + tag:server:22
|
||||
// TODO: Tailscale REJECTS this - autogroup:self requires user/group sources
|
||||
// Tailscale REJECTS this - autogroup:self requires user/group sources
|
||||
{
|
||||
name: "tag_source_with_self_dest_2_5",
|
||||
policy: `{
|
||||
@ -10184,7 +10183,7 @@ func TestTailscaleCompatErrorCasesHeadscaleDiffers(t *testing.T) {
|
||||
},
|
||||
|
||||
// Test 4.5: tag:client → autogroup:self:*
|
||||
// TODO: Tailscale REJECTS this - autogroup:self requires user/group sources
|
||||
// Tailscale REJECTS this - autogroup:self requires user/group sources
|
||||
{
|
||||
name: "tag_source_to_self_dest_only_4_5",
|
||||
policy: `{
|
||||
@ -10203,7 +10202,7 @@ func TestTailscaleCompatErrorCasesHeadscaleDiffers(t *testing.T) {
|
||||
},
|
||||
|
||||
// Test 6.1: autogroup:tagged → autogroup:self:*
|
||||
// TODO: Tailscale REJECTS this - autogroup:tagged is NOT a valid source for autogroup:self
|
||||
// Tailscale REJECTS this - autogroup:tagged is NOT a valid source for autogroup:self
|
||||
{
|
||||
name: "autogroup_tagged_to_self_6_1",
|
||||
policy: `{
|
||||
@ -10222,7 +10221,7 @@ func TestTailscaleCompatErrorCasesHeadscaleDiffers(t *testing.T) {
|
||||
},
|
||||
|
||||
// Test 9.5: [autogroup:member, autogroup:tagged] → [autogroup:self:*, tag:server:22]
|
||||
// TODO: Tailscale REJECTS this - ANY invalid source (autogroup:tagged) invalidates the rule
|
||||
// Tailscale REJECTS this - ANY invalid source (autogroup:tagged) invalidates the rule
|
||||
{
|
||||
name: "both_autogroups_to_self_plus_tag_9_5",
|
||||
policy: `{
|
||||
@ -10241,7 +10240,7 @@ func TestTailscaleCompatErrorCasesHeadscaleDiffers(t *testing.T) {
|
||||
},
|
||||
|
||||
// Test 13.6: autogroup:tagged → self:*
|
||||
// TODO: Tailscale REJECTS this - same as 6.1
|
||||
// Tailscale REJECTS this - same as 6.1
|
||||
{
|
||||
name: "autogroup_tagged_to_self_13_6",
|
||||
policy: `{
|
||||
@ -10260,7 +10259,7 @@ func TestTailscaleCompatErrorCasesHeadscaleDiffers(t *testing.T) {
|
||||
},
|
||||
|
||||
// Test 13.10: tag:client → self:*
|
||||
// TODO: Tailscale REJECTS this - tags are not valid sources for autogroup:self
|
||||
// Tailscale REJECTS this - tags are not valid sources for autogroup:self
|
||||
{
|
||||
name: "tag_to_self_13_10",
|
||||
policy: `{
|
||||
@ -10279,7 +10278,7 @@ func TestTailscaleCompatErrorCasesHeadscaleDiffers(t *testing.T) {
|
||||
},
|
||||
|
||||
// Test 13.13: Host → self:*
|
||||
// TODO: Tailscale REJECTS this - hosts are not valid sources for autogroup:self
|
||||
// Tailscale REJECTS this - hosts are not valid sources for autogroup:self
|
||||
{
|
||||
name: "host_to_self_13_13",
|
||||
policy: `{
|
||||
@ -10301,7 +10300,7 @@ func TestTailscaleCompatErrorCasesHeadscaleDiffers(t *testing.T) {
|
||||
},
|
||||
|
||||
// Test 13.14: Raw IP → self:*
|
||||
// TODO: Tailscale REJECTS this - raw IPs are not valid sources for autogroup:self
|
||||
// Tailscale REJECTS this - raw IPs are not valid sources for autogroup:self
|
||||
{
|
||||
name: "raw_ip_to_self_13_14",
|
||||
policy: `{
|
||||
@ -10320,7 +10319,7 @@ func TestTailscaleCompatErrorCasesHeadscaleDiffers(t *testing.T) {
|
||||
},
|
||||
|
||||
// Test 13.25: [autogroup:member, tag:client] → self:*
|
||||
// TODO: Tailscale REJECTS this - ANY invalid source (tag:client) invalidates the rule
|
||||
// Tailscale REJECTS this - ANY invalid source (tag:client) invalidates the rule
|
||||
{
|
||||
name: "mixed_valid_invalid_sources_to_self_13_25",
|
||||
policy: `{
|
||||
@ -10343,16 +10342,15 @@ func TestTailscaleCompatErrorCasesHeadscaleDiffers(t *testing.T) {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
pol, err := unmarshalPolicy([]byte(tt.policy))
|
||||
require.NoError(t, err, "test %s (%s): policy should parse", tt.name, tt.reference)
|
||||
|
||||
// TODO: Headscale does NOT validate autogroup:self source restrictions for ACL rules.
|
||||
// Tailscale would reject these policies with: %s
|
||||
// For now, document that Headscale accepts these policies.
|
||||
err = pol.validate()
|
||||
require.NoError(t, err,
|
||||
"test %s (%s): Headscale currently accepts this policy (Tailscale rejects with: %s)",
|
||||
tt.name, tt.reference, tt.tailscaleError)
|
||||
// unmarshalPolicy calls validate() internally, so we expect it to fail
|
||||
// with our validation error
|
||||
_, err := unmarshalPolicy([]byte(tt.policy))
|
||||
require.Error(t, err,
|
||||
"test %s (%s): should reject policy like Tailscale",
|
||||
tt.name, tt.reference)
|
||||
require.ErrorIs(t, err, ErrACLAutogroupSelfInvalidSource,
|
||||
"test %s (%s): expected autogroup:self validation error",
|
||||
tt.name, tt.reference)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@ -46,6 +46,11 @@ var (
|
||||
ErrSSHWildcardDestination = errors.New("wildcard (*) is not supported as SSH destination")
|
||||
)
|
||||
|
||||
// ACL validation errors.
|
||||
var (
|
||||
ErrACLAutogroupSelfInvalidSource = errors.New("autogroup:self destination requires sources to be users, groups, or autogroup:member only")
|
||||
)
|
||||
|
||||
type Asterix int
|
||||
|
||||
func (a Asterix) Validate() error {
|
||||
@ -1680,6 +1685,51 @@ func validateSSHSrcDstCombination(sources SSHSrcAliases, destinations SSHDstAlia
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateACLSrcDstCombination validates that ACL source/destination combinations
|
||||
// follow Tailscale's security model:
|
||||
// - autogroup:self destinations require ALL sources to be users, groups, autogroup:member, or wildcard (*)
|
||||
// - Tags, autogroup:tagged, hosts, and raw IPs are NOT valid sources for autogroup:self
|
||||
// - Wildcard (*) is allowed because autogroup:self evaluation narrows it per-node to the node's own IPs.
|
||||
func validateACLSrcDstCombination(sources Aliases, destinations []AliasWithPorts) error {
|
||||
// Check if any destination is autogroup:self
|
||||
hasAutogroupSelf := false
|
||||
|
||||
for _, dst := range destinations {
|
||||
if ag, ok := dst.Alias.(*AutoGroup); ok && ag.Is(AutoGroupSelf) {
|
||||
hasAutogroupSelf = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !hasAutogroupSelf {
|
||||
return nil // No autogroup:self, no validation needed
|
||||
}
|
||||
|
||||
// Validate all sources are valid for autogroup:self
|
||||
for _, src := range sources {
|
||||
switch v := src.(type) {
|
||||
case *Username, *Group, Asterix:
|
||||
// Valid sources - users, groups, and wildcard (*) are allowed
|
||||
// Wildcard is allowed because autogroup:self evaluation narrows it per-node
|
||||
continue
|
||||
case *AutoGroup:
|
||||
if v.Is(AutoGroupMember) {
|
||||
continue // autogroup:member is valid
|
||||
}
|
||||
// autogroup:tagged and others are NOT valid
|
||||
return ErrACLAutogroupSelfInvalidSource
|
||||
case *Tag, *Host, *Prefix:
|
||||
// Tags, hosts, and IPs are NOT valid sources for autogroup:self
|
||||
return ErrACLAutogroupSelfInvalidSource
|
||||
default:
|
||||
// Unknown type - be conservative and reject
|
||||
return ErrACLAutogroupSelfInvalidSource
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// validate reports if there are any errors in a policy after
|
||||
// the unmarshaling process.
|
||||
// It runs through all rules and checks if there are any inconsistencies
|
||||
@ -1762,6 +1812,12 @@ func (p *Policy) validate() error {
|
||||
if err := validateProtocolPortCompatibility(acl.Protocol, acl.Destinations); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
|
||||
// Validate ACL source/destination combinations follow Tailscale's security model
|
||||
err := validateACLSrcDstCombination(acl.Sources, acl.Destinations)
|
||||
if err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
}
|
||||
|
||||
for _, ssh := range p.SSHs {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user