mirror of
https://github.com/juanfont/headscale.git
synced 2026-04-01 06:53:23 +02: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:
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
### Changes
|
### 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)
|
- **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)
|
## 0.28.0 (2026-02-04)
|
||||||
|
|||||||
@@ -10142,29 +10142,28 @@ func TestTailscaleCompatErrorCases(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestTailscaleCompatErrorCasesHeadscaleDiffers documents cases where Tailscale produces
|
// TestTailscaleCompatErrorCasesHeadscaleDiffers validates that Headscale correctly rejects
|
||||||
// validation errors but Headscale does NOT. These represent compatibility gaps.
|
// 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
|
// 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
|
// users, groups, or autogroup:member. Headscale now performs this same 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.
|
|
||||||
//
|
//
|
||||||
// Reference: /home/kradalby/acl-explore/findings/09-mixed-scenarios.md.
|
// Reference: /home/kradalby/acl-explore/findings/09-mixed-scenarios.md.
|
||||||
func TestTailscaleCompatErrorCasesHeadscaleDiffers(t *testing.T) {
|
func TestTailscaleCompatErrorCasesHeadscaleDiffers(t *testing.T) {
|
||||||
t.Parallel()
|
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),
|
// Tailscale rejects these policies at validation time (400 Bad Request),
|
||||||
// but Headscale currently accepts them.
|
// and Headscale now does the same.
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
policy string
|
policy string
|
||||||
tailscaleError string // What Tailscale would return
|
tailscaleError string // What Tailscale returns (and Headscale should match)
|
||||||
reference string
|
reference string
|
||||||
}{
|
}{
|
||||||
// Test 2.5: tag:client → autogroup:self:* + tag:server:22
|
// 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",
|
name: "tag_source_with_self_dest_2_5",
|
||||||
policy: `{
|
policy: `{
|
||||||
@@ -10184,7 +10183,7 @@ func TestTailscaleCompatErrorCasesHeadscaleDiffers(t *testing.T) {
|
|||||||
},
|
},
|
||||||
|
|
||||||
// Test 4.5: tag:client → autogroup:self:*
|
// 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",
|
name: "tag_source_to_self_dest_only_4_5",
|
||||||
policy: `{
|
policy: `{
|
||||||
@@ -10203,7 +10202,7 @@ func TestTailscaleCompatErrorCasesHeadscaleDiffers(t *testing.T) {
|
|||||||
},
|
},
|
||||||
|
|
||||||
// Test 6.1: autogroup:tagged → autogroup:self:*
|
// 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",
|
name: "autogroup_tagged_to_self_6_1",
|
||||||
policy: `{
|
policy: `{
|
||||||
@@ -10222,7 +10221,7 @@ func TestTailscaleCompatErrorCasesHeadscaleDiffers(t *testing.T) {
|
|||||||
},
|
},
|
||||||
|
|
||||||
// Test 9.5: [autogroup:member, autogroup:tagged] → [autogroup:self:*, tag:server:22]
|
// 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",
|
name: "both_autogroups_to_self_plus_tag_9_5",
|
||||||
policy: `{
|
policy: `{
|
||||||
@@ -10241,7 +10240,7 @@ func TestTailscaleCompatErrorCasesHeadscaleDiffers(t *testing.T) {
|
|||||||
},
|
},
|
||||||
|
|
||||||
// Test 13.6: autogroup:tagged → self:*
|
// 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",
|
name: "autogroup_tagged_to_self_13_6",
|
||||||
policy: `{
|
policy: `{
|
||||||
@@ -10260,7 +10259,7 @@ func TestTailscaleCompatErrorCasesHeadscaleDiffers(t *testing.T) {
|
|||||||
},
|
},
|
||||||
|
|
||||||
// Test 13.10: tag:client → self:*
|
// 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",
|
name: "tag_to_self_13_10",
|
||||||
policy: `{
|
policy: `{
|
||||||
@@ -10279,7 +10278,7 @@ func TestTailscaleCompatErrorCasesHeadscaleDiffers(t *testing.T) {
|
|||||||
},
|
},
|
||||||
|
|
||||||
// Test 13.13: Host → self:*
|
// 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",
|
name: "host_to_self_13_13",
|
||||||
policy: `{
|
policy: `{
|
||||||
@@ -10301,7 +10300,7 @@ func TestTailscaleCompatErrorCasesHeadscaleDiffers(t *testing.T) {
|
|||||||
},
|
},
|
||||||
|
|
||||||
// Test 13.14: Raw IP → self:*
|
// 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",
|
name: "raw_ip_to_self_13_14",
|
||||||
policy: `{
|
policy: `{
|
||||||
@@ -10320,7 +10319,7 @@ func TestTailscaleCompatErrorCasesHeadscaleDiffers(t *testing.T) {
|
|||||||
},
|
},
|
||||||
|
|
||||||
// Test 13.25: [autogroup:member, tag:client] → self:*
|
// 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",
|
name: "mixed_valid_invalid_sources_to_self_13_25",
|
||||||
policy: `{
|
policy: `{
|
||||||
@@ -10343,16 +10342,15 @@ func TestTailscaleCompatErrorCasesHeadscaleDiffers(t *testing.T) {
|
|||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
pol, err := unmarshalPolicy([]byte(tt.policy))
|
// unmarshalPolicy calls validate() internally, so we expect it to fail
|
||||||
require.NoError(t, err, "test %s (%s): policy should parse", tt.name, tt.reference)
|
// with our validation error
|
||||||
|
_, err := unmarshalPolicy([]byte(tt.policy))
|
||||||
// TODO: Headscale does NOT validate autogroup:self source restrictions for ACL rules.
|
require.Error(t, err,
|
||||||
// Tailscale would reject these policies with: %s
|
"test %s (%s): should reject policy like Tailscale",
|
||||||
// For now, document that Headscale accepts these policies.
|
tt.name, tt.reference)
|
||||||
err = pol.validate()
|
require.ErrorIs(t, err, ErrACLAutogroupSelfInvalidSource,
|
||||||
require.NoError(t, err,
|
"test %s (%s): expected autogroup:self validation error",
|
||||||
"test %s (%s): Headscale currently accepts this policy (Tailscale rejects with: %s)",
|
tt.name, tt.reference)
|
||||||
tt.name, tt.reference, tt.tailscaleError)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,6 +46,11 @@ var (
|
|||||||
ErrSSHWildcardDestination = errors.New("wildcard (*) is not supported as SSH destination")
|
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
|
type Asterix int
|
||||||
|
|
||||||
func (a Asterix) Validate() error {
|
func (a Asterix) Validate() error {
|
||||||
@@ -1680,6 +1685,51 @@ func validateSSHSrcDstCombination(sources SSHSrcAliases, destinations SSHDstAlia
|
|||||||
return nil
|
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
|
// validate reports if there are any errors in a policy after
|
||||||
// the unmarshaling process.
|
// the unmarshaling process.
|
||||||
// It runs through all rules and checks if there are any inconsistencies
|
// 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 {
|
if err := validateProtocolPortCompatibility(acl.Protocol, acl.Destinations); err != nil {
|
||||||
errs = append(errs, err)
|
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 {
|
for _, ssh := range p.SSHs {
|
||||||
|
|||||||
Reference in New Issue
Block a user