mirror of
https://github.com/juanfont/headscale.git
synced 2026-04-23 00:58:43 +02:00
policy/v2: validate SSH source/destination combinations
Add validation for SSH source/destination combinations that enforces Tailscale's security model: - Tags/autogroup:tagged cannot SSH to user-owned devices - autogroup:self destination requires source to contain only users/groups - Username destinations require source to be that same single user only - Wildcard (*) is no longer supported as SSH destination; use autogroup:member or autogroup:tagged instead The validateSSHSrcDstCombination() function is called during policy validation to reject invalid configurations at load time. Fixes #3009 Fixes #3010
This commit is contained in:
@@ -37,6 +37,15 @@ var ErrCircularReference = errors.New("circular reference detected")
|
|||||||
|
|
||||||
var ErrUndefinedTagReference = errors.New("references undefined tag")
|
var ErrUndefinedTagReference = errors.New("references undefined tag")
|
||||||
|
|
||||||
|
// SSH validation errors.
|
||||||
|
var (
|
||||||
|
ErrSSHTagSourceToUserDest = errors.New("tags in SSH source cannot access user-owned devices")
|
||||||
|
ErrSSHUserDestRequiresSameUser = errors.New("user destination requires source to contain only that same user")
|
||||||
|
ErrSSHAutogroupSelfRequiresUserSource = errors.New("autogroup:self destination requires source to contain only users or groups, not tags or autogroup:tagged")
|
||||||
|
ErrSSHTagSourceToAutogroupMember = errors.New("tags in SSH source cannot access autogroup:member (user-owned devices)")
|
||||||
|
ErrSSHWildcardDestination = errors.New("wildcard (*) is not supported as SSH destination")
|
||||||
|
)
|
||||||
|
|
||||||
type Asterix int
|
type Asterix int
|
||||||
|
|
||||||
func (a Asterix) Validate() error {
|
func (a Asterix) Validate() error {
|
||||||
@@ -1613,6 +1622,63 @@ func validateAutogroupForSSHUser(user *AutoGroup) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// validateSSHSrcDstCombination validates that SSH source/destination combinations
|
||||||
|
// follow Tailscale's security model:
|
||||||
|
// - Destination can be: tags, autogroup:self (if source is users/groups), or same-user
|
||||||
|
// - Tags/autogroup:tagged CANNOT SSH to user destinations
|
||||||
|
// - Username destinations require the source to be that same single user only.
|
||||||
|
func validateSSHSrcDstCombination(sources SSHSrcAliases, destinations SSHDstAliases) error {
|
||||||
|
// Categorize source types
|
||||||
|
srcHasTaggedEntities := false
|
||||||
|
srcHasGroups := false
|
||||||
|
srcUsernames := make(map[string]bool)
|
||||||
|
|
||||||
|
for _, src := range sources {
|
||||||
|
switch v := src.(type) {
|
||||||
|
case *Tag:
|
||||||
|
srcHasTaggedEntities = true
|
||||||
|
case *AutoGroup:
|
||||||
|
if v.Is(AutoGroupTagged) {
|
||||||
|
srcHasTaggedEntities = true
|
||||||
|
} else if v.Is(AutoGroupMember) {
|
||||||
|
srcHasGroups = true // autogroup:member is like a group of users
|
||||||
|
}
|
||||||
|
case *Group:
|
||||||
|
srcHasGroups = true
|
||||||
|
case *Username:
|
||||||
|
srcUsernames[string(*v)] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check destinations against source constraints
|
||||||
|
for _, dst := range destinations {
|
||||||
|
switch v := dst.(type) {
|
||||||
|
case *Username:
|
||||||
|
// Rule: Tags/autogroup:tagged CANNOT SSH to user destinations
|
||||||
|
if srcHasTaggedEntities {
|
||||||
|
return fmt.Errorf("%w (%s); use autogroup:tagged or specific tags as destinations instead",
|
||||||
|
ErrSSHTagSourceToUserDest, *v)
|
||||||
|
}
|
||||||
|
// Rule: Username destination requires source to be that same single user only
|
||||||
|
if srcHasGroups || len(srcUsernames) != 1 || !srcUsernames[string(*v)] {
|
||||||
|
return fmt.Errorf("%w %q; use autogroup:self instead for same-user SSH access",
|
||||||
|
ErrSSHUserDestRequiresSameUser, *v)
|
||||||
|
}
|
||||||
|
case *AutoGroup:
|
||||||
|
// Rule: autogroup:self requires source to NOT contain tags
|
||||||
|
if v.Is(AutoGroupSelf) && srcHasTaggedEntities {
|
||||||
|
return ErrSSHAutogroupSelfRequiresUserSource
|
||||||
|
}
|
||||||
|
// Rule: autogroup:member (user-owned devices) cannot be accessed by tagged entities
|
||||||
|
if v.Is(AutoGroupMember) && srcHasTaggedEntities {
|
||||||
|
return ErrSSHTagSourceToAutogroupMember
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
@@ -1754,6 +1820,12 @@ func (p *Policy) validate() error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate SSH source/destination combinations follow Tailscale's security model
|
||||||
|
err := validateSSHSrcDstCombination(ssh.Sources, ssh.Destinations)
|
||||||
|
if err != nil {
|
||||||
|
errs = append(errs, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tagOwners := range p.TagOwners {
|
for _, tagOwners := range p.TagOwners {
|
||||||
@@ -1886,15 +1958,12 @@ func (a *SSHDstAliases) UnmarshalJSON(b []byte) error {
|
|||||||
*a = make([]Alias, len(aliases))
|
*a = make([]Alias, len(aliases))
|
||||||
for i, alias := range aliases {
|
for i, alias := range aliases {
|
||||||
switch alias.Alias.(type) {
|
switch alias.Alias.(type) {
|
||||||
case *Username, *Tag, *AutoGroup, *Host,
|
case *Username, *Tag, *AutoGroup, *Host:
|
||||||
// Asterix and Group is actually not supposed to be supported,
|
|
||||||
// however we do not support autogroups at the moment
|
|
||||||
// so we will leave it in as there is no other option
|
|
||||||
// to dynamically give all access
|
|
||||||
// https://tailscale.com/kb/1193/tailscale-ssh#dst
|
|
||||||
// TODO(kradalby): remove this when we support autogroup:tagged and autogroup:member
|
|
||||||
Asterix:
|
|
||||||
(*a)[i] = alias.Alias
|
(*a)[i] = alias.Alias
|
||||||
|
case Asterix:
|
||||||
|
return fmt.Errorf("%w; use 'autogroup:member' for user-owned devices, "+
|
||||||
|
"'autogroup:tagged' for tagged devices, or specific tags/users",
|
||||||
|
ErrSSHWildcardDestination)
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf(
|
return fmt.Errorf(
|
||||||
"alias %T is not supported for SSH destination",
|
"alias %T is not supported for SSH destination",
|
||||||
@@ -1924,6 +1993,8 @@ func (a SSHDstAliases) MarshalJSON() ([]byte, error) {
|
|||||||
case *Host:
|
case *Host:
|
||||||
aliases[i] = string(*v)
|
aliases[i] = string(*v)
|
||||||
case Asterix:
|
case Asterix:
|
||||||
|
// Marshal wildcard as "*" so it gets rejected during unmarshal
|
||||||
|
// with a proper error message explaining alternatives
|
||||||
aliases[i] = "*"
|
aliases[i] = "*"
|
||||||
default:
|
default:
|
||||||
return nil, fmt.Errorf("unknown SSH destination alias type: %T", v)
|
return nil, fmt.Errorf("unknown SSH destination alias type: %T", v)
|
||||||
|
|||||||
Reference in New Issue
Block a user