diff --git a/api/internal/transport/api/req.go b/api/internal/transport/api/req.go index b41c254a..b06a8a78 100644 --- a/api/internal/transport/api/req.go +++ b/api/internal/transport/api/req.go @@ -38,7 +38,7 @@ type AliasReq struct { Format string `json:"format"` Domain string `json:"domain" validate:"required"` WildcardLocalPart string `json:"wildcard_local_part" validate:"omitempty,alphanum,min=6,max=12"` - LocalPart string `json:"local_part" validate:"omitempty,alphanum,min=1,max=64"` + LocalPart string `json:"local_part" validate:"omitempty,emaillocalpart"` } type RecipientReq struct { diff --git a/api/internal/utils/validator.go b/api/internal/utils/validator.go index 91dad45f..8ca72545 100644 --- a/api/internal/utils/validator.go +++ b/api/internal/utils/validator.go @@ -35,6 +35,11 @@ func NewValidator() Validator { log.Println("error registering search validation:", err) } + err = v.RegisterValidation("emaillocalpart", emailLocalPartValidation) + if err != nil { + log.Println("error registering email local part validation:", err) + } + return v } @@ -115,3 +120,32 @@ func searchValidation(fl validator.FieldLevel) bool { re := regexp.MustCompile(`^[-a-zA-Z0-9 ._+@]+$`) return re.MatchString(value) } + +func emailLocalPartValidation(fl validator.FieldLevel) bool { + localPart := fl.Field().String() + + if len(localPart) < 1 || len(localPart) > 64 { + return false + } + + // RFC 5321/5322 allow a broad set of characters in the local part. + // This covers the common, practical subset: letters, digits, and + // the typical unquoted special characters. + var validChars = regexp.MustCompile(`^[a-zA-Z0-9.!#$%&'*+/=?^_` + "`" + `{|}~-]+$`).MatchString + + if !validChars(localPart) { + return false + } + + // Local part must not start or end with a dot + if strings.HasPrefix(localPart, ".") || strings.HasSuffix(localPart, ".") { + return false + } + + // No consecutive dots allowed + if strings.Contains(localPart, "..") { + return false + } + + return true +} diff --git a/api/internal/utils/validator_test.go b/api/internal/utils/validator_test.go index 96b07110..6f924d02 100644 --- a/api/internal/utils/validator_test.go +++ b/api/internal/utils/validator_test.go @@ -210,3 +210,57 @@ func TestSearchValidation(t *testing.T) { } } } + +func TestEmailLocalPartValidation(t *testing.T) { + v := NewValidator() + + err := v.RegisterValidation("emaillocalpart", emailLocalPartValidation) + if err != nil { + t.Fatalf("expected no error when registering emaillocalpart validation, but got: %v", err) + } + + tests := []struct { + value string + valid bool + desc string + }{ + // Valid + {"user", true, "simple lowercase"}, + {"User123", true, "mixed case alphanumeric"}, + {"user.name", true, "dot-separated parts"}, + {"user+tag", true, "plus sign"}, + {"user-name", true, "hyphen"}, + {"user_name", true, "underscore"}, + {"user!#$%&'*+/=?^_{|}~-", true, "all allowed special chars"}, + {"a", true, "single character (minimum length)"}, + {"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1", true, "64 characters (maximum length)"}, + {"123", true, "digits only"}, + // Invalid: length + {"", false, "empty string"}, + {"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1", false, "65 characters (over maximum length)"}, + // Invalid: leading/trailing dot + {".user", false, "starts with dot"}, + {"user.", false, "ends with dot"}, + // Invalid: consecutive dots + {"user..name", false, "consecutive dots"}, + // Invalid: disallowed characters + {"user name", false, "space not allowed"}, + {"user@name", false, "@ sign not allowed"}, + {"user(name)", false, "parentheses not allowed"}, + {"user", false, "angle brackets not allowed"}, + {"user\\name", false, "backslash not allowed"}, + {"user\"name", false, "double quote not allowed"}, + // Security + {"", false, "XSS attempt"}, + {"' OR '1'='1", false, "SQL injection attempt"}, + {"../../../etc/passwd", false, "path traversal attempt"}, + } + + for _, tt := range tests { + err := v.Var(tt.value, "emaillocalpart") + isValid := err == nil + if isValid != tt.valid { + t.Errorf("emailLocalPartValidation(%q): got %v, want %v (%s)", tt.value, isValid, tt.valid, tt.desc) + } + } +}