Skip to content
Draft
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions internal/brokers/broker.go
Original file line number Diff line number Diff line change
Expand Up @@ -355,6 +355,9 @@ func validateUserInfo(uInfo types.UserInfo) (err error) {
if uInfo.Name == "" {
return errors.New("empty username")
}
if err := types.ValidateUsername(uInfo.Name); err != nil {
return err
}

// Validate home and shell directories
if !filepath.IsAbs(filepath.Clean(uInfo.Dir)) {
Expand Down
1 change: 1 addition & 0 deletions internal/brokers/broker_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,7 @@ func TestIsAuthenticated(t *testing.T) {
"Error_when_broker_returns_invalid_access": {sessionID: "ia_invalid_access"},
"Error_when_broker_returns_invalid_userinfo": {sessionID: "ia_invalid_userinfo"},
"Error_when_broker_returns_userinfo_with_empty_username": {sessionID: "ia_info_empty_user_name"},
"Error_when_broker_returns_userinfo_with_invalid_username": {sessionID: "ia_info_invalid_username"},
"Error_when_broker_returns_userinfo_with_empty_group_name": {sessionID: "ia_info_empty_group_name"},
"Error_when_broker_returns_userinfo_with_invalid_homedir": {sessionID: "ia_info_invalid_home"},
"Error_when_broker_returns_userinfo_with_invalid_shell": {sessionID: "ia_info_invalid_shell"},
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
FIRST CALL:
access:
data:
err: provided userinfo is invalid: username "-invalid_username" is not valid: it must match ^[a-z_][-a-z0-9_.@]*[$]?$
1 change: 1 addition & 0 deletions internal/services/pam/pam_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -456,6 +456,7 @@ func TestIsAuthenticated(t *testing.T) {
"Error_when_broker_returns_invalid_access": {username: "ia_invalid_access@example.com"},
"Error_when_broker_returns_invalid_data": {username: "ia_invalid_data@example.com"},
"Error_when_broker_returns_invalid_userinfo": {username: "ia_invalid_userinfo@example.com"},
"Error_when_broker_returns_invalid_username": {username: "ia_info_invalid_username@example.com"},
"Error_when_calling_second_time_without_cancelling": {username: "ia_second_call@example.com", secondCall: true},

// local group error
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
FIRST CALL:
access:
msg:
err: provided userinfo is invalid: username "-invalid_username" is not valid: it must match ^[a-z_][-a-z0-9_.@]*[$]?$
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
users: []
groups: []
users_to_groups: []
schema_version: 2
2 changes: 2 additions & 0 deletions internal/testutils/broker.go
Original file line number Diff line number Diff line change
Expand Up @@ -385,6 +385,8 @@ func userInfoFromName(sessionID string, extraGroups []groupJSONInfo) string {
name = ""
case "ia_info_mismatching_user_name":
name = "different_username@example.com"
case "ia_info_invalid_username":
name = "-invalid_username"
case "ia_info_empty_group_name":
group = ""
case "ia_info_empty_ugid":
Expand Down
25 changes: 24 additions & 1 deletion internal/users/types/userinfo.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,29 @@
package types

import "github.com/canonical/authd/internal/sliceutils"
import (
"fmt"
"regexp"

"github.com/canonical/authd/internal/sliceutils"
)

// usernameRegexp is the regexp that a valid username must match.
// It follows the Debian/Ubuntu username policy as defined by shadow-utils/useradd rules,
// extended to allow '@' and '.' characters for cloud identity provider email-style usernames
// (e.g. user@example.com).
var usernameRegexp = regexp.MustCompile(`^[a-z_][-a-z0-9_.@]*[$]?$`)

Comment on lines +11 to +15
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder how likely it is for this to cause regressions, i.e. there being existing authd users with characters that are allowed by this change, causing login to fail for those users.

We might want to allow all characters that are allowed in email addresses.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We might want to allow all characters that are allowed in email addresses.

But email addresses allow / which opens the door to path traversal again. Maybe we just shouldn't try to validate the username and keep accepting what the broker gives us?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@3v1n0 WDYT

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i.e. there being existing authd users with characters that are allowed by this change, causing login to fail for those users.

and new users have the same issue if their email address contains characters not allowed by this change. they will have to change their email address to be able to log in successfully. and in the current implementation, they are only told so after successful device auth, which makes the UX even worse.

Copy link
Copy Markdown
Contributor

@3v1n0 3v1n0 Apr 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe we can just deny-list some relevant chars such as all the ones included in these tests, which even if used by someone, really seems wrong.

		// Injection / path traversal characters must be rejected
		"Error_on_name_with_slash":                     {username: "user/name", wantErr: true},
		"Error_on_name_with_backslash":                 {username: `user\name`, wantErr: true},
		"Error_on_name_with_single_quote":              {username: "user'name", wantErr: true},
		"Error_on_name_with_double_quote":              {username: `user"name`, wantErr: true},
		"Error_on_name_with_backtick":                  {username: "user`name", wantErr: true},
		"Error_on_name_with_semicolon":                 {username: "user;name", wantErr: true},
		"Error_on_name_with_ampersand":                 {username: "user&name", wantErr: true},
		"Error_on_name_with_pipe":                      {username: "user|name", wantErr: true},
		"Error_on_name_with_null_byte":                 {username: "user\x00name", wantErr: true},
		"Error_on_name_with_newline":                   {username: "user\nname", wantErr: true},
		"Error_on_name_with_tab":                       {username: "user\tname", wantErr: true},
		"Error_on_name_with_colon":                     {username: "user:name", wantErr: true},
		"Error_on_name_with_exclamation":               {username: "user!name", wantErr: true},
		"Error_on_name_with_open_paren":                {username: "user(name", wantErr: true},
		"Error_on_name_with_close_paren":               {username: "user)name", wantErr: true},
		"Error_on_name_with_less_than":                 {username: "user<name", wantErr: true},
		"Error_on_name_with_greater_than":              {username: "user>name", wantErr: true},

Likely including any white-space and non-printable character in general

// ValidateUsername checks if the given username is valid.
// Valid usernames follow the Debian/Ubuntu naming convention: they start with a lowercase letter or
// underscore, followed by lowercase letters, digits, hyphens, underscores, dots, or '@', with an
// optional trailing dollar sign. The '@' and '.' characters are also permitted to support
// email-style usernames used by cloud identity providers.
func ValidateUsername(name string) error {
if !usernameRegexp.MatchString(name) {
return fmt.Errorf("username %q is not valid: it must match %s", name, usernameRegexp)
}
return nil
}

// Equals checks that two users are equal.
func (u UserInfo) Equals(other UserInfo) bool {
Expand Down
43 changes: 43 additions & 0 deletions internal/users/types/userinfo_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -136,3 +136,46 @@ func TestUserInfoEquals(t *testing.T) {
})
}
}

func TestValidateUsername(t *testing.T) {
t.Parallel()

tests := map[string]struct {
username string
wantErr bool
}{
// Valid usernames
"Valid_lowercase_name": {username: "user"},
"Valid_name_starting_with_underscore": {username: "_user"},
"Valid_name_with_hyphen": {username: "user-name"},
"Valid_name_with_underscore": {username: "user_name"},
"Valid_name_with_digits": {username: "user1"},
"Valid_name_with_trailing_dollar_sign": {username: "user$"},
"Valid_single_character_name": {username: "a"},
"Valid_name_with_mixed_allowed_chars": {username: "a-b_c0"},
"Valid_email_style_name": {username: "user@example.com"},
"Valid_email_style_name_with_subdomain": {username: "user@sub.example.com"},
"Valid_name_with_dot": {username: "first.last"},

// Invalid usernames
"Error_on_empty_username": {username: "", wantErr: true},
"Error_on_uppercase_character": {username: "User", wantErr: true},
"Error_on_uppercase_email": {username: "User@example.com", wantErr: true},
"Error_on_name_starting_with_digit": {username: "1user", wantErr: true},
"Error_on_name_starting_with_hyphen": {username: "-user", wantErr: true},
"Error_on_name_with_dollar_not_at_end": {username: "user$name", wantErr: true},
"Error_on_name_with_space": {username: "user name", wantErr: true},
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Check if a name has / or other elements that may lead to injections such as quotes and so on

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in b8eb81b. Added explicit wantErr: true test cases for /, \, ', ", `, ;, &, |, null byte, newline, tab, :, !, (, ), <, and > — all correctly rejected by the allowlist regex.

}
for name, tc := range tests {
t.Run(name, func(t *testing.T) {
t.Parallel()

err := types.ValidateUsername(tc.username)
if tc.wantErr {
require.Error(t, err, "ValidateUsername should return an error for %q, but did not", tc.username)
return
}
require.NoError(t, err, "ValidateUsername should not return an error for %q, but did", tc.username)
})
}
}