// SPDX-License-Identifier: MPL-2.0 package authmail import ( "context" "encoding/base64" "errors" "fmt" "strings" "testing" "time" "gamertan.com/web/auth" "gamertan.com/web/mail" ) type unavailableRepository struct{} func (unavailableRepository) OwnSubject(context.Context, [32]byte, time.Time) (Subject, error) { return Subject{}, ErrUnavailable } func (unavailableRepository) ResetSubject(context.Context, string, time.Time) (Subject, error) { return Subject{}, ErrUnavailable } func (unavailableRepository) Issue(context.Context, Request, []mail.Message, auth.AuditEvent) error { return ErrUnavailable } func (unavailableRepository) Pending(context.Context, [32]byte, time.Time) (Pending, error) { return Pending{}, ErrUnavailable } func (unavailableRepository) Complete(context.Context, [32]byte, string, string, []mail.Message, auth.AuditEvent) (bool, error) { return false, ErrUnavailable } func testConfig() Config { return Config{Origin: "https://example.test", SiteName: "Example", ConfirmPath: "/account/email/confirm/", ResetPath: "/password/reset/", SecurityPath: "/account/security/"} } func TestTrustedOriginAndRouteValidation(t *testing.T) { for _, origin := range []string{"http://example.test", "https://user:password@example.test", "https://example.test/path", "https://example.test/?query=1", "https://example.test/#fragment", "javascript:alert(1)", "//example.test", "https://"} { config := testConfig() config.Origin = origin if _, err := New(unavailableRepository{}, config); !errors.Is(err, ErrInvalid) { t.Fatalf("invalid origin accepted: %q", origin) } } for _, route := range []string{"//evil.test/path", "/path?token=1", "/path#fragment", "/a/../b", "relative/path", "/bad\\path", "/bad\npath", "/%2f%2fevil.test"} { config := testConfig() config.ConfirmPath = route if _, err := New(unavailableRepository{}, config); !errors.Is(err, ErrInvalid) { t.Fatalf("invalid route accepted: %q", route) } } if _, err := New(nil, testConfig()); !errors.Is(err, ErrInvalid) { t.Fatal("nil repository") } service, err := New(unavailableRepository{}, testConfig()) if err != nil { t.Fatal(err) } if got := service.link("/password/reset/", "a+/&b"); got != "https://example.test/password/reset/?token=a%2B%2F%26b" { t.Fatalf("link encoding: %s", got) } fragmentConfig := testConfig() fragmentConfig.TokenInFragment = true fragmentService, err := New(unavailableRepository{}, fragmentConfig) if err != nil { t.Fatal(err) } if got := fragmentService.link("/password/reset/", "fixture-token"); got != "https://example.test/password/reset/#token=fixture-token" { t.Fatal("fragment mode exposed token in request target") } config := testConfig() config.SiteName = "Name\nInjected: header" if _, err := New(unavailableRepository{}, config); !errors.Is(err, ErrInvalid) { t.Fatal("site name injection") } } func TestMailboxAndTokenValidation(t *testing.T) { if got, err := NormalizeEmail(" READER@Example.Test "); err != nil || got != "reader@example.test" { t.Fatalf("normalization %q %v", got, err) } for _, email := range []string{"Name ", "reader@example.test\nBcc: other@example.test", "ΓΌ@example.test", "a,b@example.test", "missing-at", strings.Repeat("x", 255) + "@example.test"} { if _, err := NormalizeEmail(email); err == nil { t.Fatalf("invalid address accepted: %q", email) } } token := base64.RawURLEncoding.EncodeToString(make([]byte, 32)) first, err := TokenDigest(token) if err != nil || first == ([32]byte{}) { t.Fatal("valid token rejected") } for _, value := range []string{"", token + "=", token[:42], strings.Repeat("a", 200), " " + token} { if _, err := TokenDigest(value); !errors.Is(err, ErrUnavailable) { t.Fatal("malformed token accepted") } } service, err := New(unavailableRepository{}, testConfig()) if err != nil { t.Fatal(err) } for _, value := range []string{"not-an-email", "unknown@example.test"} { if err := service.RequestReset(t.Context(), value); err != nil { t.Fatal("reset enumeration", err) } } if _, err = service.Inspect(t.Context(), token); !errors.Is(err, ErrUnavailable) { t.Fatal(err) } if _, err = service.Confirm(t.Context(), token); !errors.Is(err, ErrUnavailable) { t.Fatal(err) } } func TestDefaultAndCustomCopyDoesNotControlEnvelope(t *testing.T) { config := testConfig() config.Compose = func(intent MailIntent) (MessageContent, error) { return MessageContent{Subject: "Custom subject", Text: "Custom body: " + intent.ActionURL}, nil } service, err := New(unavailableRepository{}, config) if err != nil { t.Fatal(err) } message, err := service.message("verify", "recipient@example.test", "token", time.Now().UTC()) if err != nil { t.Fatal(err) } if message.To != "recipient@example.test" || message.Subject != "Custom subject" || !strings.Contains(message.Text, "https://example.test/account/email/confirm/?token=token") { t.Fatal("custom copy bypassed fixed envelope/origin") } config.Compose = func(MailIntent) (MessageContent, error) { return MessageContent{Subject: "bad\nBcc: another@example.test", Text: "body"}, nil } service, err = New(unavailableRepository{}, config) if err != nil { t.Fatal(err) } if _, err = service.message("verify", "recipient@example.test", "token", time.Now().UTC()); !errors.Is(err, ErrInvalid) { t.Fatal("custom header injection") } for _, kind := range []string{"verify", "reset", "change-new", "change-old", "password-changed", "email-changed"} { content, err := DefaultComposer(MailIntent{Kind: kind, SiteName: "Example", SecurityURL: "https://example.test/security/", ActionURL: "https://example.test/confirm/", ExpiresAt: time.Now().UTC()}) if err != nil || content.Subject == "" || content.Text == "" { t.Fatal("missing copy", kind) } } if _, err = DefaultComposer(MailIntent{Kind: "unknown"}); !errors.Is(err, ErrInvalid) { t.Fatal("unknown intent") } for _, value := range []any{Subject{Email: "private@example.test", PasswordHash: "secret-hash"}, Request{Email: "private@example.test"}, Pending{Subject: Subject{Email: "private@example.test"}}} { if strings.Contains(fmt.Sprintf("%v %#v", value, value), "private@example.test") { t.Fatal("sensitive debug output") } } }