diff --git a/hscontrol/policy/v2/types.go b/hscontrol/policy/v2/types.go index 88ef20af..e7bd7738 100644 --- a/hscontrol/policy/v2/types.go +++ b/hscontrol/policy/v2/types.go @@ -205,10 +205,14 @@ func (u Username) Resolve(_ *Policy, users types.Users, nodes types.Nodes) (*net type Group string func (g Group) Validate() error { - if isGroup(string(g)) { - return nil + if !isGroup(string(g)) { + return fmt.Errorf(`Group has to start with "group:", got: %q`, g) } - return fmt.Errorf(`Group has to start with "group:", got: %q`, g) + + // Group name is everything after "group:" + groupName := string(g)[len("group:"):] + + return validateNameFormat(groupName, "Group", g) } func (g *Group) UnmarshalJSON(b []byte) error { @@ -266,10 +270,14 @@ func (g Group) Resolve(p *Policy, users types.Users, nodes types.Nodes) (*netipx type Tag string func (t Tag) Validate() error { - if isTag(string(t)) { - return nil + if !isTag(string(t)) { + return fmt.Errorf(`tag has to start with "tag:", got: %q`, t) } - return fmt.Errorf(`tag has to start with "tag:", got: %q`, t) + + // Tag name is everything after "tag:" + tagName := string(t)[len("tag:"):] + + return validateNameFormat(tagName, "Tag", t) } func (t *Tag) UnmarshalJSON(b []byte) error { @@ -654,6 +662,36 @@ func isTag(str string) bool { return strings.HasPrefix(str, "tag:") } +// validateNameFormat checks if a name follows the required format: +// - Must start with an ASCII letter (a-z, A-Z) +// - Can only contain ASCII letters, numbers, or dashes +func validateNameFormat(name string, typeLabel string, original interface{}) error { + // Check if empty + if len(name) == 0 { + return fmt.Errorf(`%s names cannot be empty, got: %q`, typeLabel, original) + } + + // Check if first character is an ASCII letter + firstChar := name[0] + if !((firstChar >= 'a' && firstChar <= 'z') || (firstChar >= 'A' && firstChar <= 'Z')) { + return fmt.Errorf(`%s names must start with a letter, got: %q`, typeLabel, original) + } + + // Check if all characters are ASCII letters, numbers, or dashes + for i := 0; i < len(name); i++ { + char := name[i] + isAsciiLetter := (char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z') + isDigit := char >= '0' && char <= '9' + isDash := char == '-' + + if !isAsciiLetter && !isDigit && !isDash { + return fmt.Errorf(`%s names can only contain ASCII letters, numbers, or dashes, got: %q`, typeLabel, original) + } + } + + return nil +} + func isAutoGroup(str string) bool { return strings.HasPrefix(str, "autogroup:") } @@ -1077,6 +1115,39 @@ func (to TagOwners) MarshalJSON() ([]byte, error) { // TagOwners are a map of Tag to a list of the UserEntities that own the tag. type TagOwners map[Tag]Owners +// UnmarshalJSON overrides the default JSON unmarshalling for TagOwners to ensure +// that each tag name is validated using the isTag function and character validation rules. +// This ensures that all tag names conform to the expected format and character rules. +func (to *TagOwners) UnmarshalJSON(b []byte) error { + var rawTagOwners map[string][]string + if err := json.Unmarshal(b, &rawTagOwners); err != nil { + return err + } + + *to = make(TagOwners) + for key, value := range rawTagOwners { + tag := Tag(key) + if err := tag.Validate(); err != nil { + return err + } + + var owners Owners + + for _, o := range value { + owner, err := parseOwner(o) + if err != nil { + return err + } + + owners = append(owners, owner) + } + + (*to)[tag] = owners + } + + return nil +} + func (to TagOwners) Contains(tagOwner *Tag) error { if tagOwner == nil { return nil diff --git a/hscontrol/policy/v2/types_test.go b/hscontrol/policy/v2/types_test.go index 1255b12b..6ac0d7e8 100644 --- a/hscontrol/policy/v2/types_test.go +++ b/hscontrol/policy/v2/types_test.go @@ -389,6 +389,150 @@ func TestUnmarshalPolicy(t *testing.T) { // wantErr: `Username has to contain @, got: "group:inner"`, wantErr: `Nested groups are not allowed, found "group:inner" inside "group:example"`, }, + { + name: "invalid-group-name-special-chars", + input: ` +{ + "groups": { + "group:example@invalid": [ + "valid@example.com", + ], + }, +} +`, + wantErr: `Group names can only contain ASCII letters, numbers, or dashes, got: "group:example@invalid"`, + }, + { + name: "invalid-group-name-starting-with-number", + input: ` +{ + "groups": { + "group:123example": [ + "valid@example.com", + ], + }, +} +`, + wantErr: `Group names must start with a letter, got: "group:123example"`, + }, + { + name: "invalid-group-name-scandinavian-characters", + input: ` +{ + "groups": { + "group:æøå-example": [ + "valid@example.com", + ], + }, +} +`, + wantErr: `Group names must start with a letter, got: "group:æøå-example"`, + }, + { + name: "invalid-group-name-cyrillic-characters", + input: ` +{ + "groups": { + "group:группа": [ + "valid@example.com", + ], + }, +} +`, + wantErr: `Group names must start with a letter, got: "group:группа"`, + }, + { + name: "invalid-group-name-emoji", + input: ` +{ + "groups": { + "group:dev-😊": [ + "valid@example.com", + ], + }, +} +`, + wantErr: `Group names can only contain ASCII letters, numbers, or dashes, got: "group:dev-😊"`, + }, + { + name: "invalid-group-name-other-special-chars", + input: ` +{ + "groups": { + "group:dev_team": [ + "valid@example.com", + ], + }, +} +`, + wantErr: `Group names can only contain ASCII letters, numbers, or dashes, got: "group:dev_team"`, + }, + { + name: "invalid-tag-name-special-chars", + input: ` +{ + "tagOwners": { + "tag:test@invalid": ["valid@example.com"], + }, +} +`, + wantErr: `Tag names can only contain ASCII letters, numbers, or dashes, got: "tag:test@invalid"`, + }, + { + name: "invalid-tag-name-starting-with-number", + input: ` +{ + "tagOwners": { + "tag:123test": ["valid@example.com"], + }, +} +`, + wantErr: `Tag names must start with a letter, got: "tag:123test"`, + }, + { + name: "invalid-tag-name-scandinavian-characters", + input: ` +{ + "tagOwners": { + "tag:æøå-test": ["valid@example.com"], + }, +} +`, + wantErr: `Tag names must start with a letter, got: "tag:æøå-test"`, + }, + { + name: "invalid-tag-name-cyrillic-characters", + input: ` +{ + "tagOwners": { + "tag:тест": ["valid@example.com"], + }, +} +`, + wantErr: `Tag names must start with a letter, got: "tag:тест"`, + }, + { + name: "invalid-tag-name-emoji", + input: ` +{ + "tagOwners": { + "tag:test-😊": ["valid@example.com"], + }, +} +`, + wantErr: `Tag names can only contain ASCII letters, numbers, or dashes, got: "tag:test-😊"`, + }, + { + name: "invalid-tag-name-other-special-chars", + input: ` +{ + "tagOwners": { + "tag:test_underscore": ["valid@example.com"], + }, +} +`, + wantErr: `Tag names can only contain ASCII letters, numbers, or dashes, got: "tag:test_underscore"`, + }, { name: "invalid-addr", input: `