From 3ed74de3d81c9a50207dac067c91d59d9e5742fa Mon Sep 17 00:00:00 2001 From: Julian Dice <19397727+windoze95@users.noreply.github.com> Date: Sun, 12 Jul 2026 21:52:29 -0500 Subject: [PATCH] =?UTF-8?q?feat:=20username=20policy=20=E2=80=94=20length?= =?UTF-8?q?=20cap,=20substring=20blocklist,=20reserved=20words?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The username rules had no maximum length (the client capped at 30, the server accepted anything), and the forbidden list was exact-match only, so "saltybytesadmin" and "adm1n" both sailed through. - Cap usernames at 30 characters, matching the existing client rule in register_screen.dart so old app builds stay in step. - Split the blocklist in two: forbiddenSubstrings for distinctive terms that may not appear anywhere (brand, owner handles, admin, moderator), and forbiddenExact for reserved words too common to match as substrings. Matching "test" or "user" anywhere would reject "sweetestchef" and "validuser123", so those stay exact-only. - Fold leetspeak digits before matching, so "4dm1n" and "s4l7ybyte5" are caught as "admin" and "saltybytes". - Broaden the reserved words: roles, auth surface, infra/mail, support, billing and tier names, product routes, placeholders. Dropped the entries that can never match ("test_user", "saltybytes-admin") — non-alphanumeric names are rejected before the list is consulted. - Run the local rules before the uniqueness check, so a junk username no longer costs a database round trip, and build the profanity detector once instead of per signup. "julian" and "yana" remain deliberately available. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_0194PdH4wDTnz5SWfzyoKagc --- internal/service/user.go | 75 +---------- internal/service/username_policy.go | 159 +++++++++++++++++++++++ internal/service/username_policy_test.go | 139 ++++++++++++++++++++ 3 files changed, 305 insertions(+), 68 deletions(-) create mode 100644 internal/service/username_policy.go create mode 100644 internal/service/username_policy_test.go 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) }