policy/v2: trim whitespace in SSH src and dst aliases

SaaS trims surrounding whitespace from src/dst entries before
dispatching to the alias lookup, so `"tag:server "` resolves to the
same tag and `" odin@example.com"` resolves to the same user.
headscale was treating the untrimmed strings as literals, which
either failed the tag/user lookup or dropped the affected node from
every rule referencing it. Trim inside AliasEnc.UnmarshalJSON so
ACL src/dst and SSH src/dst all benefit.
This commit is contained in:
Kristoffer Dalby
2026-05-13 09:51:57 +00:00
parent e772faf22f
commit c2d012cdf9
3 changed files with 57 additions and 14 deletions
@@ -88,19 +88,6 @@ var sshSkipReasons = map[string]string{
"ssh-b5": "user:*@passkey wildcard not supported in headscale",
"ssh-d10": "user:*@passkey wildcard not supported in headscale",
// WHITESPACE_TRIM_IN_SRC_DST (2 tests)
//
// SaaS strips surrounding whitespace from SSH rule src/dst entries
// before lookup ("tag:server " resolves to "tag:server",
// " user@host" resolves to "user@host"). headscale treats the
// untrimmed string as a literal identifier and either fails the
// tag/user lookup or produces no rule for the affected node. The
// fix lives in policy/v2 alias resolution — strings.TrimSpace at
// the same point where SaaS does, before dispatching to the alias
// type registry.
"ssh-dst-trailing-whitespace": "src/dst whitespace trim mismatch: SaaS trims, headscale doesn't",
"ssh-src-leading-whitespace": "src/dst whitespace trim mismatch: SaaS trims, headscale doesn't",
// TAG_OWNER_CYCLE_TOLERATED (2 tests)
//
// SaaS accepts tagOwners with circular references (tag:a -> tag:b,
+8 -1
View File
@@ -1043,10 +1043,17 @@ func parseAlias(vs string) (Alias, error) {
// AliasEnc is used to deserialize a Alias.
type AliasEnc struct{ Alias }
// UnmarshalJSON trims surrounding whitespace from each alias string
// before dispatching so that `"tag:server "` or `" odin@example.com"`
// resolves to the same tag or user SaaS would resolve. SaaS trims
// before lookup; a literal-match policy here would drop the affected
// node from every rule referencing it.
func (ve *AliasEnc) UnmarshalJSON(b []byte) error {
ptr, err := unmarshalPointer(
b,
parseAlias,
func(s string) (Alias, error) {
return parseAlias(strings.TrimSpace(s))
},
)
if err != nil {
return err
+49
View File
@@ -4787,6 +4787,55 @@ func TestSSHUserTrimEndToEnd(t *testing.T) {
})
}
// TestAliasEncUnmarshalTrim verifies that src/dst entries get
// trimmed before alias dispatch so `"tag:server "` resolves to the
// same Tag alias SaaS uses and `" odin@example.com"` resolves to the
// same Username alias. Covers tag, group, user, and autogroup entries
// on both the leading- and trailing-whitespace edges.
func TestAliasEncUnmarshalTrim(t *testing.T) {
tests := []struct {
name string
input string
want Alias
}{
{
name: "tag trailing whitespace",
input: `"tag:server "`,
want: new(Tag("tag:server")),
},
{
name: "tag leading whitespace",
input: `" tag:server"`,
want: new(Tag("tag:server")),
},
{
name: "group leading whitespace",
input: `" group:admins"`,
want: new(Group("group:admins")),
},
{
name: "user trailing whitespace",
input: `"odin@example.com "`,
want: new(Username("odin@example.com")),
},
{
name: "autogroup trailing whitespace",
input: `"autogroup:member "`,
want: new(AutoGroup("autogroup:member")),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var a AliasEnc
err := json.Unmarshal([]byte(tt.input), &a)
require.NoError(t, err)
require.Equal(t, tt.want, a.Alias)
})
}
}
// TestSSHCheckPeriodInvalidDuration verifies the SaaS body for the
// malformed-duration case (`time: invalid duration "abc"`).
func TestSSHCheckPeriodInvalidDuration(t *testing.T) {