diff --git a/internal/service/user.go b/internal/service/user.go index 2996060..5aef1d4 100755 --- a/internal/service/user.go +++ b/internal/service/user.go @@ -8,7 +8,6 @@ import ( "strings" "time" - goaway "github.com/TwiN/go-away" "github.com/asaskevich/govalidator" "github.com/windoze95/saltybytes-api/internal/config" "github.com/windoze95/saltybytes-api/internal/logger" @@ -308,6 +307,13 @@ func (s *UserService) UpdateSettings(user *models.User, keepScreenAwake bool) er // ValidateUsername validates a username against a set of rules. func (s *UserService) ValidateUsername(username string) error { + // Format, reserved words and profanity first — see username_policy.go. All + // of it is local, and the uniqueness check below is a database round trip: + // don't spend one on input that can never be valid. + if err := checkUsernamePolicy(username); err != nil { + return err + } + // Check if the username already exists. // This is also caught as a known error in the repository. exists, err := s.Repo.UsernameExists(username) @@ -318,73 +324,6 @@ func (s *UserService) ValidateUsername(username string) error { return fmt.Errorf("username is already taken") } - // Check if the username is long enough - minLength := 3 - if len(username) < minLength { - return fmt.Errorf("username must be at least %d characters", minLength) - } - - // Check if the username is alphanumeric - if !govalidator.IsAlphanumeric(username) { - return fmt.Errorf("username can only contain alphanumeric characters") - } - - // Define a list of forbidden usernames - var forbiddenUsernames = []string{ - "admin", - "administrator", - "root", - // "julian", - "awfulbits", - "windoze95", - // "yana", - "russianminx", - "russianminxx", - "sys", - "sysadmin", - "system", - "test", - "testuser", - "test-user", - "test_user", - "login", - "logout", - "register", - "password", - "user", - "newuser", - "yourapp", - "yourcompany", - "yourbrand", - "support", - "help", - "faq", - "saltybytes", - "saltybytes_ai", - "saltybytes-ai", - "saltybytesadmin", - "saltybytes_admin", - "saltybytes-admin", - "saltybytesroot", - "saltybytes_root", - "saltybytes-root", - } - - // Check if the username is in the forbidden list - lowercaseUsername := strings.ToLower(username) - for _, forbiddenUsername := range forbiddenUsernames { - if strings.EqualFold(lowercaseUsername, forbiddenUsername) { - return fmt.Errorf("username '%s' is not allowed", username) - } - } - - // Profanity check - profanityDetector := goaway.NewProfanityDetector().WithSanitizeLeetSpeak(true).WithSanitizeSpecialCharacters(true).WithSanitizeAccents(false) - if profanityDetector.IsProfane(username) { - return fmt.Errorf("username contains inappropriate language") - } - - // If we've passed all checks, the username is valid. return nil } diff --git a/internal/service/username_policy.go b/internal/service/username_policy.go new file mode 100644 index 0000000..f113c96 --- /dev/null +++ b/internal/service/username_policy.go @@ -0,0 +1,159 @@ +package service + +import ( + "fmt" + "strings" + "unicode/utf8" + + goaway "github.com/TwiN/go-away" + "github.com/asaskevich/govalidator" +) + +// Username length bounds. The 30-character ceiling matches the client rule in +// the app's register_screen.dart — change one and you have to change the other, +// or the app will submit names the API rejects. +const ( + minUsernameLength = 3 + maxUsernameLength = 30 +) + +// forbiddenSubstrings are terms that may not appear ANYWHERE in a username. +// Every entry is distinctive enough that matching it as a substring has no +// realistic false positive: "admin" also rejects "administrator", "xxadminxx" +// and "saltybytesadmin" without swallowing any ordinary word. +// +// Be strict about what goes here. A short or common word rejects real people: +// "test" would reject "sweetestchef" and "thegreatest", "user" would reject +// "validuser123". Words like those belong in forbiddenExact instead. +var forbiddenSubstrings = []string{ + // Brand and owner handles. + "saltybyte", // saltybytes, saltybytesadmin, saltybytez… + "awfulbits", // + "windoze", // windoze95 + "russianminx", // and russianminxx + "juliandice", // the bare handle "julian" stays deliberately available + + // Authority impersonation. + "admin", // administrator, sysadmin, adm1n, admin1… + "moderator", +} + +// forbiddenExact are reserved words that are only a problem when they ARE the +// whole username — too short or too ordinary to match as substrings. See the +// warning on forbiddenSubstrings. +// +// Entries only need to be alphanumeric and at least minUsernameLength long: +// anything else ("test_user", "saltybytes-admin", "me") is already rejected by +// the format rules before this list is consulted. +var forbiddenExact = newStringSet( + // Accounts and roles. + "root", "sys", "system", "systems", "superuser", "user", "users", + "guest", "anonymous", "anon", "nobody", "null", "none", "undefined", + "self", "owner", "founder", "staff", "team", "crew", "employee", + "official", "operator", "security", + + // Auth surface. + "login", "logout", "signin", "signout", "signup", "register", + "registration", "auth", "oauth", "token", "session", "password", + "passwd", "credentials", "verify", "verification", "reset", "recover", + + // Infrastructure and mail. + "api", "www", "web", "mail", "email", "smtp", "imap", "ftp", "ssh", + "dns", "cdn", "static", "assets", "media", "img", "image", "images", + "upload", "uploads", "noreply", "donotreply", "postmaster", "webmaster", + "hostmaster", "abuse", "spam", + + // Company and support. + "support", "helpdesk", "help", "faq", "contact", "info", "about", + "legal", "privacy", "terms", "tos", "policy", "careers", "press", + "blog", "news", "status", + + // Billing and tiers. + "billing", "payment", "payments", "checkout", "invoice", "refund", + "subscribe", "subscription", "subscriptions", "premium", "plus", + "unlimited", "free", "trial", "upgrade", + + // Product surface. + "recipe", "recipes", "search", "import", "preview", "explore", + "discover", "feed", "home", "dashboard", "settings", "profile", + "account", "accounts", "collection", "collections", "family", + "families", "connector", "webhook", "webhooks", "mcp", + + // Placeholders and test scaffolding. + "test", "tester", "testing", "testuser", "newuser", "demo", "example", + "sample", "dummy", "placeholder", "yourapp", "yourcompany", "yourbrand", +) + +// leetReplacer folds digit-for-letter substitutions, so "4dm1n" and "s4l7ybyte5" +// are checked as "admin" and "saltybytes". Usernames are alphanumeric by the +// time this runs, so digits are the only evasion left. +var leetReplacer = strings.NewReplacer( + "0", "o", + "1", "i", + "3", "e", + "4", "a", + "5", "s", + "7", "t", +) + +// profanityDetector is read-only once built, so it is shared across requests +// rather than reconstructed on every signup. +var profanityDetector = goaway.NewProfanityDetector(). + WithSanitizeLeetSpeak(true). + WithSanitizeSpecialCharacters(true). + WithSanitizeAccents(false) + +func newStringSet(items ...string) map[string]struct{} { + set := make(map[string]struct{}, len(items)) + for _, item := range items { + set[item] = struct{}{} + } + return set +} + +// blocklistCandidates returns the forms of a username to test against the +// reserved lists: as typed (lowercased), and with leetspeak folded back so +// "r00t" is caught alongside "root". The folded form is dropped when it adds +// nothing, which is the common case. +func blocklistCandidates(username string) []string { + lowered := strings.ToLower(username) + folded := leetReplacer.Replace(lowered) + if folded == lowered { + return []string{lowered} + } + return []string{lowered, folded} +} + +// checkUsernamePolicy applies every username rule that doesn't need the +// database: length, character set, reserved words and profanity. Uniqueness is +// the caller's job. +func checkUsernamePolicy(username string) error { + length := utf8.RuneCountInString(username) + if length < minUsernameLength { + return fmt.Errorf("username must be at least %d characters", minUsernameLength) + } + if length > maxUsernameLength { + return fmt.Errorf("username must be %d characters or less", maxUsernameLength) + } + + if !govalidator.IsAlphanumeric(username) { + return fmt.Errorf("username can only contain alphanumeric characters") + } + + for _, candidate := range blocklistCandidates(username) { + if _, reserved := forbiddenExact[candidate]; reserved { + return fmt.Errorf("username '%s' is not allowed", username) + } + for _, term := range forbiddenSubstrings { + if strings.Contains(candidate, term) { + return fmt.Errorf("username '%s' is not allowed", username) + } + } + } + + if profanityDetector.IsProfane(username) { + return fmt.Errorf("username contains inappropriate language") + } + + return nil +} diff --git a/internal/service/username_policy_test.go b/internal/service/username_policy_test.go new file mode 100644 index 0000000..46467fa --- /dev/null +++ b/internal/service/username_policy_test.go @@ -0,0 +1,139 @@ +package service + +import ( + "strings" + "testing" +) + +func TestCheckUsernamePolicy_Length(t *testing.T) { + tests := []struct { + name string + username string + wantErr bool + }{ + {"too short", "ab", true}, + {"at minimum", "abc", false}, + {"at maximum", strings.Repeat("a", maxUsernameLength), false}, + {"one over maximum", strings.Repeat("a", maxUsernameLength+1), true}, + {"far over maximum", strings.Repeat("a", 5000), true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := checkUsernamePolicy(tt.username) + if (err != nil) != tt.wantErr { + t.Errorf("checkUsernamePolicy(%d chars) error = %v, wantErr %v", len(tt.username), err, tt.wantErr) + } + }) + } +} + +func TestCheckUsernamePolicy_Reserved(t *testing.T) { + tests := []struct { + name string + username string + wantErr bool + }{ + // Exact reservations: blocked on their own… + {"exact reserved word", "root", true}, + {"exact reserved word, mixed case", "RooT", true}, + {"exact reserved word: user", "user", true}, + {"exact reserved word: test", "test", true}, + {"exact reserved tier", "unlimited", true}, + // …but not as a fragment of a real name. These are the names a + // substring match on "test"/"user" would wrongly eat. + {"reserved word as fragment: sweetest", "sweetestchef", false}, + {"reserved word as fragment: greatest", "thegreatest", false}, + {"reserved word as fragment: user", "validuser123", false}, + {"reserved word as fragment: home", "homecook", false}, + + // Substring reservations: blocked anywhere in the name. + {"brand alone", "saltybytes", true}, + {"brand embedded", "thesaltybytesguy", true}, + {"brand suffixed", "saltybytesadmin", true}, + {"admin alone", "admin", true}, + {"admin embedded", "xxadminxx", true}, + {"administrator", "administrator", true}, + {"moderator embedded", "recipemoderator", true}, + {"owner handle", "windoze95", true}, + {"reserved personal handle", "russianminx", true}, + {"reserved personal handle, suffixed", "russianminxx", true}, + {"owner real-name handle", "juliandice", true}, + + // Deliberately still available. + {"julian stays available", "julian", false}, + {"yana stays available", "yana", false}, + + // Leetspeak evasion is folded before matching. + {"leetspeak admin", "4dm1n", true}, + {"leetspeak brand", "s4l7ybyte5", true}, + {"leetspeak root", "r00t", true}, + + // Ordinary names still pass. + {"ordinary name", "chefjulia", false}, + {"ordinary name with digits", "baker2026", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := checkUsernamePolicy(tt.username) + if (err != nil) != tt.wantErr { + t.Errorf("checkUsernamePolicy(%q) error = %v, wantErr %v", tt.username, err, tt.wantErr) + } + }) + } +} + +func TestCheckUsernamePolicy_CharacterSet(t *testing.T) { + for _, username := range []string{"user@name", "with space", "under_score", "hyphen-ated", "emoji🍳"} { + if err := checkUsernamePolicy(username); err == nil { + t.Errorf("checkUsernamePolicy(%q): non-alphanumeric should fail", username) + } + } +} + +// The reserved lists are only reachable for input that already passed the +// format rules, so an entry that can never match is dead weight. +func TestForbiddenListsAreReachable(t *testing.T) { + for word := range forbiddenExact { + if err := checkFormatOnly(word); err != nil { + t.Errorf("forbiddenExact entry %q can never match: %v", word, err) + } + } + for _, word := range forbiddenSubstrings { + if word != strings.ToLower(word) { + t.Errorf("forbiddenSubstrings entry %q must be lowercase — matching is done on a lowercased name", word) + } + } +} + +// checkFormatOnly mirrors the format rules checkUsernamePolicy applies before +// consulting the reserved lists. +func checkFormatOnly(username string) error { + length := len([]rune(username)) + if length < minUsernameLength { + return errTooShort + } + if length > maxUsernameLength { + return errTooLong + } + for _, r := range username { + isDigit := r >= '0' && r <= '9' + isLower := r >= 'a' && r <= 'z' + isUpper := r >= 'A' && r <= 'Z' + if !isDigit && !isLower && !isUpper { + return errNotAlphanumeric + } + } + return nil +} + +var ( + errTooShort = errStr("shorter than the minimum length") + errTooLong = errStr("longer than the maximum length") + errNotAlphanumeric = errStr("not alphanumeric") +) + +type errStr string + +func (e errStr) Error() string { return string(e) }