Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
75 changes: 7 additions & 68 deletions internal/service/user.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
Expand All @@ -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
}

Expand Down
159 changes: 159 additions & 0 deletions internal/service/username_policy.go
Original file line number Diff line number Diff line change
@@ -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
}
139 changes: 139 additions & 0 deletions internal/service/username_policy_test.go
Original file line number Diff line number Diff line change
@@ -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) }
Loading