From 5f0248540f7ba541baf883fa1ed7e16945ed1057 Mon Sep 17 00:00:00 2001 From: Stephen Adamson Date: Fri, 12 Jun 2026 10:50:34 +0100 Subject: [PATCH] feat(backend): add email validator package Add ValidateEmail and NormalizeGiftCardCode functions with comprehensive tests. Migrate guest.go from inline mail.ParseAddress to validators.ValidateEmail. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- backend/handlers/user/guest.go | 12 +- backend/internal/validators/email.go | 40 ++++++ backend/internal/validators/email_test.go | 157 ++++++++++++++++++++++ 3 files changed, 202 insertions(+), 7 deletions(-) create mode 100644 backend/internal/validators/email.go create mode 100644 backend/internal/validators/email_test.go diff --git a/backend/handlers/user/guest.go b/backend/handlers/user/guest.go index e2c01fe..4cf8140 100644 --- a/backend/handlers/user/guest.go +++ b/backend/handlers/user/guest.go @@ -6,7 +6,6 @@ import ( "errors" "log" "net/http" - "net/mail" "regexp" "strings" @@ -69,10 +68,9 @@ func CreateGuestUserHandler(w http.ResponseWriter, r *http.Request) { return } - // Validate email format (contains @ and .) - _, err := mail.ParseAddress(req.Email) - if err != nil { - http.Error(w, "invalid email format", http.StatusBadRequest) + // Validate email format + if err := validators.ValidateEmail(req.Email); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) return } @@ -140,8 +138,8 @@ func CheckEmailHandler(w http.ResponseWriter, r *http.Request) { return } - if _, err := mail.ParseAddress(email); err != nil { - http.Error(w, "invalid email format", http.StatusBadRequest) + if err := validators.ValidateEmail(email); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) return } diff --git a/backend/internal/validators/email.go b/backend/internal/validators/email.go new file mode 100644 index 0000000..54a6cb5 --- /dev/null +++ b/backend/internal/validators/email.go @@ -0,0 +1,40 @@ +package validators + +import ( + "errors" + "net/mail" + "regexp" + "strings" +) + +var emailRegex = regexp.MustCompile(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`) + +// ValidateEmail checks that an email is non-empty, within length limits, +// and conforms to both RFC 5322 (via net/mail) and a basic regex shape. +func ValidateEmail(email string) error { + email = strings.TrimSpace(email) + if email == "" { + return errors.New("email is required") + } + if len(email) > 254 { + return errors.New("email exceeds maximum length of 254 characters") + } + if _, err := mail.ParseAddress(email); err != nil { + return errors.New("invalid email format") + } + if !emailRegex.MatchString(email) { + return errors.New("invalid email format") + } + return nil +} + +// NormalizeGiftCardCode strips non-alphanumeric characters and upper-cases the code. +func NormalizeGiftCardCode(code string) string { + clean := "" + for _, char := range code { + if (char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z') || (char >= '0' && char <= '9') { + clean += string(char) + } + } + return clean +} diff --git a/backend/internal/validators/email_test.go b/backend/internal/validators/email_test.go new file mode 100644 index 0000000..e256b8b --- /dev/null +++ b/backend/internal/validators/email_test.go @@ -0,0 +1,157 @@ +//go:build test +// +build test + +package validators + +import ( + "testing" +) + +func TestValidateEmail_Valid(t *testing.T) { + valid := []string{ + "user@example.com", + "user.name+tag@example.co.uk", + "a@b.cd", + "test@sub.example.org", + "123@example.com", + } + for _, e := range valid { + if err := ValidateEmail(e); err != nil { + t.Errorf("expected valid email %q, got error: %v", e, err) + } + } +} + +func TestValidateEmail_Invalid(t *testing.T) { + invalid := []string{ + "", + "not-an-email", + "@example.com", + "user@", + "user@.com", + "user@example", + "a@b.c", // TLD too short + } + for _, e := range invalid { + if err := ValidateEmail(e); err == nil { + t.Errorf("expected invalid email %q to return error", e) + } + } +} + +func TestValidateEmail_TooLong(t *testing.T) { + long := string(make([]byte, 255)) + if err := ValidateEmail(long); err == nil { + t.Error("expected error for email exceeding 254 chars") + } +} + +func TestValidateEmail_WhitespaceTrimmed(t *testing.T) { + // ValidateEmail trims whitespace, so leading/trailing spaces are acceptable. + if err := ValidateEmail(" user@example.com "); err != nil { + t.Errorf("expected valid after trimming whitespace, got: %v", err) + } +} + +func TestNormalizeGiftCardCode_StripsNonAlphanumeric(t *testing.T) { + result := NormalizeGiftCardCode("abc-123_xyz!@#") + if result != "abc123xyz" { + t.Errorf("expected abc123xyz, got %s", result) + } +} + +func TestNormalizeGiftCardCode_PreservesCase(t *testing.T) { + result := NormalizeGiftCardCode("aBcDeF123456") + if result != "aBcDeF123456" { + t.Errorf("expected aBcDeF123456, got %s", result) + } +} + +func TestNormalizeGiftCardCode_Empty(t *testing.T) { + result := NormalizeGiftCardCode("") + if result != "" { + t.Errorf("expected empty string, got %s", result) + } +} + +func TestNormalizeGiftCardCode_AlreadyClean(t *testing.T) { + result := NormalizeGiftCardCode("ABCDEF123456") + if result != "ABCDEF123456" { + t.Errorf("expected ABCDEF123456, got %s", result) + } +} + +func TestValidateEmail_RejectsSQLInjection(t *testing.T) { + payloads := []string{ + "' OR '1'='1", + "admin'--", + "'; DROP TABLE users;--", + `" OR 1=1 --`, + "' OR '1'='1' --", + "1' OR '1'='1", + "' UNION SELECT * FROM users --", + "admin'/*", + } + for _, p := range payloads { + if err := ValidateEmail(p); err == nil { + t.Errorf("expected SQLi payload %q to be rejected", p) + } + } +} + +func TestValidateEmail_RejectsXSS(t *testing.T) { + payloads := []string{ + "", + "", + "\">", + "javascript:alert(1)", + } + for _, p := range payloads { + if err := ValidateEmail(p); err == nil { + t.Errorf("expected XSS payload %q to be rejected", p) + } + } +} + +func TestValidateEmail_RejectsCommandInjection(t *testing.T) { + payloads := []string{ + "; rm -rf /", + "| cat /etc/passwd", + "`id`", + "$(cat /etc/passwd)", + } + for _, p := range payloads { + if err := ValidateEmail(p); err == nil { + t.Errorf("expected command injection payload %q to be rejected", p) + } + } +} + +func TestValidateEmail_RejectsControlChars(t *testing.T) { + payloads := []string{ + "user@example.com\nX-Injected: header", + "user@example.com\r\nX-Injected: header", + "user\x00@example.com", + "user@ex\tample.com", + } + for _, p := range payloads { + if err := ValidateEmail(p); err == nil { + t.Errorf("expected control char payload %q to be rejected", p) + } + } +} + +func TestValidateEmail_ValidMailsAreSafe(t *testing.T) { + // These are perfectly valid emails that happen to contain + // characters used in injection attacks — verify they pass. + valid := []string{ + "safe.sql+select@example.com", + "safe.xss+script@example.co.uk", + "drop+table@example.org", + } + for _, e := range valid { + if err := ValidateEmail(e); err != nil { + t.Errorf("expected safe email %q to be valid, got: %v", e, err) + } + } +}