diff --git a/hscontrol/policy/v2/tailscale_ssh_data_compat_test.go b/hscontrol/policy/v2/tailscale_ssh_data_compat_test.go index 6eeef8b7..615b044b 100644 --- a/hscontrol/policy/v2/tailscale_ssh_data_compat_test.go +++ b/hscontrol/policy/v2/tailscale_ssh_data_compat_test.go @@ -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, diff --git a/hscontrol/policy/v2/types.go b/hscontrol/policy/v2/types.go index 1e767918..b2e746fa 100644 --- a/hscontrol/policy/v2/types.go +++ b/hscontrol/policy/v2/types.go @@ -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 diff --git a/hscontrol/policy/v2/types_test.go b/hscontrol/policy/v2/types_test.go index 99c3f3af..28161bac 100644 --- a/hscontrol/policy/v2/types_test.go +++ b/hscontrol/policy/v2/types_test.go @@ -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) {