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
57 changes: 37 additions & 20 deletions auth/email/email.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,11 +63,14 @@ import (
"context"
"errors"
"fmt"
"golang.org/x/text/unicode/norm"
"net"
"net/mail"
"strings"
"sync"
"time"
"unicode"
"unicode/utf8"

"golang.org/x/net/idna"
"golang.org/x/sync/singleflight"
Expand All @@ -86,9 +89,10 @@ var idnaProfile = idna.Lookup
const DefaultCacheTTL = 5 * time.Minute

// maxCacheSize is the maximum number of domains held in the cache at once.
// If the cache is full when a new result arrives, it is silently dropped —
// the next request will query DNS again. Background eviction keeps the cache
// below this limit under normal operation.
// When the cache is full and a result arrives, store first drops every
// expired entry; if the cache is still full, the result is not cached and
// the next request for that domain queries DNS again. There is no
// background eviction since #135.
const maxCacheSize = 10_000

// cacheEntry holds the result of a single MX lookup.
Expand Down Expand Up @@ -183,19 +187,6 @@ func NewWithConfig(p authcore.Provider, cfg Config) (*Email, error) {
// and always safe — including multiple times and from multiple goroutines.
func (e *Email) Close() {}

// evictExpired deletes all expired entries from the cache, taking the write
// lock itself.
func (e *Email) evictExpired() {
e.mu.Lock()
defer e.mu.Unlock()
now := time.Now()
for k, v := range e.cache {
if now.After(v.expiresAt) {
delete(e.cache, k)
}
}
}

// Name implements authcore.Module.
func (e *Email) Name() string { return "email" }

Expand Down Expand Up @@ -249,21 +240,47 @@ func (e *Email) ValidateAndNormalize(address string) (string, error) {
// do not catch a leading-hyphen label, and a malformed name has no
// canonical form to store or query.
func normalize(address string) (string, error) {
lower := strings.ToLower(strings.TrimSpace(address))
atIdx := strings.LastIndexByte(lower, '@')
trimmed := strings.TrimSpace(address)
atIdx := strings.LastIndexByte(trimmed, '@')
if atIdx < 0 {
// Addresses without an "@" fail validation regardless of IDN, so
// leaving the input untouched here produces a clearer error path.
return lower, nil
return strings.ToLower(trimmed), nil
}
local, domain := lower[:atIdx], lower[atIdx+1:]
local, err := canonicalLocalPart(trimmed[:atIdx])
if err != nil {
return "", err
}
domain := strings.ToLower(trimmed[atIdx+1:])
ascii, err := idnaProfile.ToASCII(domain)
if err != nil {
return "", &emailViolation{reason: fmt.Errorf("domain %q is not a valid internationalised name: %w", domain, err)}
}
return local + "@" + ascii, nil
}

// canonicalLocalPart returns the one canonical spelling of a local part: NFC,
// then lowercased. Until 2026-09-25 the local part was lowercased as typed,
// so one mailbox had two canonical forms (precomposed and decomposed
// accents), two mailboxes could share one (U+212A KELVIN SIGN lowercases to
// "k", U+0130 to "i"), and control, format and line-separator characters
// travelled into the stored value. Each of those is refused now; the ASCII
// controls were already refused by net/mail.
func canonicalLocalPart(local string) (string, error) {
local = norm.NFC.String(local)
for _, r := range local {
switch {
case unicode.Is(unicode.Cc, r), unicode.Is(unicode.Cf, r),
unicode.Is(unicode.Zl, r), unicode.Is(unicode.Zp, r),
unicode.Is(unicode.Other_Default_Ignorable_Code_Point, r):
return "", &emailViolation{reason: fmt.Errorf("local part holds an invisible or control character %U", r)}
case r >= utf8.RuneSelf && unicode.ToLower(r) < utf8.RuneSelf:
return "", &emailViolation{reason: fmt.Errorf("local part holds %U, which lowercases to an ASCII letter", r)}
}
}
return strings.ToLower(local), nil
}

// validate checks address against RFC 5321 / RFC 5322 rules.
// It uses net/mail for syntax and then applies stricter structural checks.
func validate(address string) error {
Expand Down
29 changes: 22 additions & 7 deletions auth/email/email_test.go
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
package email

import (
Expand Down Expand Up @@ -480,25 +480,40 @@
}
}

func TestEvictExpired_removesStaleKeepsLive(t *testing.T) {
// store is the only eviction there is since #135 removed the background
// goroutine: when the cache is full it drops the expired entries and then
// admits the new one. Until 2026-09-25 the test for eviction called a helper
// nothing in production called, so this path could be deleted with the suite
// green, and a full cache would then never admit another domain.
func TestStore_evictsExpiredEntriesWhenFull(t *testing.T) {
m := newMod(t)
m.mu.Lock()
m.cache["stale.example"] = cacheEntry{hasMX: true, expiresAt: time.Now().Add(-time.Second)}
for i := 0; i < maxCacheSize-1; i++ {
m.cache[fmt.Sprintf("stale%d.example", i)] = cacheEntry{hasMX: true, expiresAt: time.Now().Add(-time.Second)}
}
m.cache["live.example"] = cacheEntry{hasMX: true, expiresAt: time.Now().Add(time.Minute)}
m.mu.Unlock()

m.evictExpired()
m.store("new.example", cacheEntry{hasMX: true, expiresAt: time.Now().Add(time.Minute)})

m.mu.RLock()
_, staleOk := m.cache["stale.example"]
_, newOk := m.cache["new.example"]
_, liveOk := m.cache["live.example"]
_, staleOk := m.cache["stale0.example"]
size := len(m.cache)
m.mu.RUnlock()

if staleOk {
t.Error("evictExpired must remove stale entries")
if !newOk {
t.Error("a full cache of expired entries must admit the new domain")
}
if !liveOk {
t.Error("evictExpired must keep live entries")
t.Error("eviction must keep live entries")
}
if staleOk {
t.Error("eviction must remove expired entries")
}
if size != 2 {
t.Errorf("cache holds %d entries after eviction, want 2", size)
}
}

Expand Down
85 changes: 85 additions & 0 deletions auth/email/local_part_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
package email

import (
"context"
"errors"
"net"
"strings"
"testing"
)

// The local part has one canonical spelling since 2026-09-25: NFC, then
// lowercased. Each refusal below is paired with an address of the same
// shape that is accepted.

func TestValidateAndNormalize_localPartHasOneCanonicalForm(t *testing.T) {
m := newMod(t)
nfc, err := m.ValidateAndNormalize("José@Example.com")
if err != nil {
t.Fatalf("NFC input: %v", err)
}
nfd, err := m.ValidateAndNormalize("José@example.com")
if err != nil {
t.Fatalf("NFD input: %v", err)
}
if nfc != nfd || nfc != "josé@example.com" {
t.Fatalf("canonical forms differ or are not NFC lowercase: %q vs %q", nfc, nfd)
}
}

// A non-ASCII letter whose lowercase is ASCII would give two mailboxes one
// canonical form. U+0130 has no canonical decomposition and folds to "i" by
// case mapping alone, so it is refused. U+212A KELVIN SIGN is canonically
// equivalent to K: NFC turns it into the letter before anything else looks,
// the same way it merges a decomposed accent, so it canonicalises to
// "kelly" rather than being refused.
func TestValidateAndNormalize_localPartThatFoldsIntoASCII(t *testing.T) {
m := newMod(t)
_, err := m.ValidateAndNormalize("\u0130nfo@example.com")
if !errors.Is(err, ErrInvalidEmail) || !strings.Contains(err.Error(), "lowercases to an ASCII letter") {
t.Errorf("capital dotted i: %v, want the fold refusal", err)
}
if got, err := m.ValidateAndNormalize("\u212aelly@example.com"); err != nil || got != "kelly@example.com" {
t.Errorf("kelvin sign: %q, %v; want the canonical kelly@example.com", got, err)
}
for _, addr := range []string{"kelly@example.com", "info@example.com", "KELLY@example.com"} {
if _, err := m.ValidateAndNormalize(addr); err != nil {
t.Errorf("%s: %v, want accepted", addr, err)
}
}
}

func TestValidateAndNormalize_refusesInvisibleAndControlCharactersInTheLocalPart(t *testing.T) {
m := newMod(t)
for name, addr := range map[string]string{
"zero width space": "admin​@example.com",
"soft hyphen": "ad­min@example.com",
"right-to-left override": "admin‮@example.com",
"next line (C1 control)": "admin\u0085@example.com",
"line separator": "admin
@example.com",
"hangul filler": "adminㅤ@example.com",
} {
_, err := m.ValidateAndNormalize(addr)
if !errors.Is(err, ErrInvalidEmail) || !strings.Contains(err.Error(), "invisible or control character") {
t.Errorf("%s: %v, want the invisible-character refusal", name, err)
}
}
if got, err := m.ValidateAndNormalize("ad-min.user+tag@example.com"); err != nil || got != "ad-min.user+tag@example.com" {
t.Fatalf("a plain local part: %q, %v", got, err)
}
}

// An MX answer with no records and no error is a domain without MX, not one
// that accepts mail. The branch could be deleted with the suite green.
func TestVerifyDomain_emptyAnswerIsNoMX(t *testing.T) {
m := newMod(t)
stub := newStub([]*net.MX{}, nil)
m.resolver = stub
if err := m.VerifyDomain(context.Background(), "user@nomx.example"); !errors.Is(err, ErrDomainNoMX) {
t.Fatalf("empty answer: %v, want ErrDomainNoMX", err)
}
m.resolver = newStub([]*net.MX{{Host: "mx.example.", Pref: 10}}, nil)
if err := m.VerifyDomain(context.Background(), "user@hasmx.example"); err != nil {
t.Fatalf("a real record: %v, want nil", err)
}
}
13 changes: 11 additions & 2 deletions auth/password/password.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@
// - Output: PHC string format — self-describing, portable
// - Comparison: constant-time — immune to timing attacks
// - Policy: Hash rejects weak passwords before spending CPU on them
// - Printable input only: Hash refuses control and invisible characters
// - Printable input only: Hash refuses control, format and other
// default-ignorable characters, and the blank braille pattern
//
// # What is tunable
//
Expand Down Expand Up @@ -246,7 +247,15 @@ func checkPolicy(plaintext string, cfg Config) error {
// this check existed: "Abcdefghijk1\xff" passed the default policy with the
// stray byte counted as its special character.
func isPrintable(r rune) bool {
return r != utf8.RuneError && unicode.IsPrint(r)
if r == utf8.RuneError || !unicode.IsPrint(r) {
return false
}
// IsPrint admits code points that render as nothing: the Hangul fillers
// and the other default-ignorable letters and marks (U+115F, U+3164,
// U+FFA0, U+034F among them), and U+2800 BRAILLE PATTERN BLANK, a symbol.
// Measured 2026-09-25: "Abcdefghijk1" + U+2800 satisfied RequireSymbol
// with a character the user cannot see, the lockout #347 describes.
return r != 0x2800 && !unicode.Is(unicode.Other_Default_Ignorable_Code_Point, r)
}

// isSpecial reports whether r satisfies RequireSymbol: Unicode punctuation
Expand Down
Loading
Loading