verify / verify (push) Successful in 4m29s
Reviewed source export adds verified TLS mail, encrypted outbox, mailbox verification and password reset protocols. Preserve public ancestry; omit local development history and operational queue. Consumer deployment and inbox delivery proof remain separate.
158 lines
6.1 KiB
Go
158 lines
6.1 KiB
Go
// 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>", "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")
|
|
}
|
|
}
|
|
}
|