fix(api): 未配置 SMTP 时发码门统一 503 mail_unavailable 且不再把验证码写日志,非本机中继默认强制 STARTTLS(require_tls)

This commit is contained in:
Lemon-miaow committed 2026-09-25 17:25:05 +08:00
1 parent d93c1b6913
commit 7d82402c18
27 files changed
+529 -87

No files matched your search

+4 -4
View File
@@ -101,10 +101,10 @@ type API struct {
Submissions SubmissionService
// Mailer delivers player email one-time codes (spec §B2 onboarding). It is
// optional: when nil the email-OTP start route mints and persists the code but
// logs it server-side instead of mailing it (a KNOWN-LIMITATION — the demo has no
// SMTP), so the verify flow is still exercised end-to-end. Production wires a real
// sender. The code is never returned to the client on either path.
// optional: when nil (no [smtp] relay) every door that mails a code answers 503
// mail_unavailable before minting one, auth options stops offering email_otp,
// and a verified email stops counting as a reauth factor. The code is never
// returned to the client or logged.
Mailer OTPMailer
// Passkey verifies WebAuthn credential-creation ceremonies (spec §14 / Phase 6
+30
View File
@@ -815,3 +815,33 @@ func TestDeadAccountsCannotLogInOrKeepSessions(t *testing.T) {
t.Error("refused redeem consumed the code; re-enabling the account must stay retryable within TTL")
}
}
// TestPublicMailDoorsWithoutRelay: with no [smtp] relay both public doors that mail
// a code answer 503 mail_unavailable before the address is looked up, so a known
// address, a staff address and an unknown one get the same answer, no code or
// op-login request is minted, and no cooldown is spent for when a relay is added.
func TestPublicMailDoorsWithoutRelay(t *testing.T) {
api, repo, _ := seedLoginEmailAPI(t)
repo.staff["op"] = &StaffUser{ID: "a1", Username: "op", Email: "[email protected]", Role: "admin", EmailVerified: true}
api.Mailer = nil
eh := api.ExternalHandler()
for _, door := range []string{"/api/v1/auth/email/start", "/api/v1/auth/op-login/start"} {
for _, email := range []string{"[email protected]", "[email protected]", "[email protected]"} {
w := do(eh, "POST", door, `{"email":"`+email+`"}`, jsonHeader)
code, msg := errEnvelope(t, w)
if w.Code != http.StatusServiceUnavailable || code != "mail_unavailable" ||
msg != "this server has no mail relay configured, so it cannot send codes; sign in with a passkey or ask the server operator to set up email" {
t.Errorf("%s %s = %d %s, want 503 mail_unavailable", door, email, w.Code, w.Body.String())
}
}
}
if len(repo.otps) != 0 || len(repo.opLogins) != 0 {
t.Fatalf("refused starts minted %d codes and %d op-login requests", len(repo.otps), len(repo.opLogins))
}
api.Mailer = &captureMailer{}
if w := do(eh, "POST", "/api/v1/auth/email/start", `{"email":"[email protected]"}`, jsonHeader); w.Code != http.StatusAccepted {
t.Fatalf("start once a relay is wired = %d (%s), want 202", w.Code, w.Body.String())
}
}
+9 -4
View File
@@ -14,8 +14,9 @@ import (
// It is the deliberate counter-slice to the anti-enumeration login doors
// (handlers_auth_email.go, handlers_passkey.go): those refuse to disclose whether an
// address has an account precisely because THIS endpoint is the one sanctioned place
// existence is revealed. An empty methods array means "no (verified) account". That
// makes it a mass-enumeration surface by design — an accepted product decision, the
// existence is revealed. An empty methods array means "no (verified) account, or
// none of its methods is available on this install". That makes it a
// mass-enumeration surface by design — an accepted product decision, the
// same one the email door's header records. The handler sends no mail and mutates
// nothing, so a per-recipient cooldown would merely block a legitimate retry; what
// bounds enumeration is the per-client-address token bucket shared by every public
@@ -87,8 +88,12 @@ func (a *API) handleAuthOptions(w http.ResponseWriter, r *http.Request) {
}
}
// Email-OTP login works for any resolved verified account (UserByEmail resolves only
// email_verified rows), so it is always on offer.
methods = append(methods, "email_otp")
// email_verified rows), so it is on offer whenever a relay can mail the code; with
// none the email door answers 503 mail_unavailable, so it is left out like an
// unwired passkey verifier.
if a.Mailer != nil {
methods = append(methods, "email_otp")
}
writeJSON(w, http.StatusOK, map[string]any{"methods": methods})
}
+19 -1
View File
@@ -22,7 +22,8 @@ import (
// door would immediately 503.
// seedAuthOptionsAPI wires the discovery door: local sessions enabled, a verified player
// (u1) and a verified staff account (a1), and a passkey verifier wired by default.
// (u1) and a verified staff account (a1), and a passkey verifier and a mail relay wired
// by default.
// Callers seed passkey credentials per-test to set the credential state.
func seedAuthOptionsAPI(t *testing.T) (*API, *fakeRepo) {
t.Helper()
@@ -32,6 +33,7 @@ func seedAuthOptionsAPI(t *testing.T) (*API, *fakeRepo) {
repo.staff["boss"] = &StaffUser{ID: "a1", Username: "boss", Email: "[email protected]", Role: "admin", EmailVerified: true}
api := newTestAPI(repo, newFakeCluster())
api.Passkey = &fakePasskeyVerifier{}
api.Mailer = &captureMailer{}
return api, repo
}
@@ -128,6 +130,22 @@ func TestAuthOptionsDoesNotRevealStaffness(t *testing.T) {
}
}
// TestAuthOptionsEmailRequiresMailRelay: with no [smtp] relay the email door answers
// 503 mail_unavailable, so options leaves email_otp out; an account with a passkey is
// still offered it, and one without is offered nothing.
func TestAuthOptionsEmailRequiresMailRelay(t *testing.T) {
api, repo := seedAuthOptionsAPI(t)
api.Mailer = nil
repo.passkeyCreds["a"] = PasskeyCredential{ID: "a", UserID: "a1", CredentialID: "c-a1", PublicKey: "k", CreatedAt: frozenNow}
eh := api.ExternalHandler()
if w := do(eh, "POST", authOptionsPath, `{"email":"[email protected]"}`, jsonHeader); w.Body.String() != `{"methods":["passkey"]}`+"\n" {
t.Errorf("passkey account body = %q, want only passkey", w.Body.String())
}
if w := do(eh, "POST", authOptionsPath, `{"email":"[email protected]"}`, jsonHeader); w.Body.String() != `{"methods":[]}`+"\n" {
t.Errorf("email-only account body = %q, want no methods", w.Body.String())
}
}
// TestAuthOptionsPasskeyRequiresWiredVerifier: the account HAS an enrolled passkey, but
// no verifier is wired (a.Passkey == nil). Both login halves 503 passkey_unavailable in
// that state, so options must NOT advertise passkey — it would be a dead offer.
+15 -9
View File
@@ -68,10 +68,9 @@ const (
otpLiveLoginCodes = 3
)
// OTPMailer delivers a one-time code to an email address. It is a seam, not a
// dependency: the demo ships without SMTP, so a nil Mailer logs the code
// server-side instead of mailing it (a KNOWN-LIMITATION, never a code returned to
// the client). Production wires a real sender.
// OTPMailer delivers a one-time code to an email address. felis api wires the
// [smtp] relay (internal/mail); with none configured it stays nil and every door
// that mails a code answers 503 mail_unavailable before minting one.
type OTPMailer interface {
SendOTP(ctx context.Context, email, code string) error
}
@@ -140,6 +139,12 @@ func (a *API) handleEmailOTPStart(w http.ResponseWriter, r *http.Request) {
writeError(w, r, newError(http.StatusBadRequest, "bad_request", "a valid email is required"))
return
}
// No relay (or a spent budget) is said before asking for a re-verification
// the player could not then use.
if err := a.checkMailBudget(); err != nil {
writeError(w, r, err)
return
}
// Gate the start: the verify only redeems a code minted here.
if !a.requireReauth(w, r, p) {
return
@@ -274,16 +279,17 @@ func (a *API) handleEmailOTPVerify(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"verified": true, "email": email})
}
// deliverOTP hands the code to the configured Mailer, or — when none is wired (the
// demo) — logs it server-side as a KNOWN-LIMITATION. The code is logged ONLY in the
// no-mailer fallback and ONLY to the server log; it is never put in an HTTP response.
// deliverOTP hands the code to the configured Mailer. The code goes nowhere
// else: never into a response and never into a log, since anyone who can read
// the API's logs could otherwise sign in as any player. The doors refuse a
// relay-less install before minting (checkMailBudget); the nil check here only
// keeps a future caller that skips that check from minting a code no one gets.
//
// Every real send spends one token of the install-wide mail budget (mailGate);
// a spent budget is a 429 mail_rate_limited and nothing reaches the relay.
func (a *API) deliverOTP(ctx context.Context, email, code string) error {
if a.Mailer == nil {
log.Printf("email-otp: no Mailer configured; code for %s is %s (KNOWN-LIMITATION: demo has no SMTP)", email, code)
return nil
return errMailUnavailable()
}
if ok, wait := a.mailGate().take(mailGateKey); !ok {
metrics.MailTotal.WithLabelValues("otp", "throttled").Inc()
+43 -9
View File
@@ -139,15 +139,15 @@ func TestWithRecoverLogsPanicStack(t *testing.T) {
}
}
// TestEmailOTPStartValidation covers the mint-side input gate and the no-mailer
// fallback (the demo path): a malformed address never mints, and a nil Mailer still
// persists a code (logged server-side) so the verify flow stays exercisable.
// TestEmailOTPStartValidation covers the mint-side input gate and the no-relay
// refusal: a malformed address never mints, and with no Mailer the start answers
// 503 mail_unavailable without minting, so no code exists to leak anywhere.
func TestEmailOTPStartValidation(t *testing.T) {
user := &Principal{UserID: "u1", Email: "[email protected]", Role: "user"}
mk := func(repo *fakeRepo) http.Handler {
api := newTestAPI(repo, newFakeCluster())
api.External = staticExternal{p: user}
return api.ExternalHandler() // no Mailer wired → demo fallback
return api.ExternalHandler() // no Mailer wired
}
bad := map[string]string{
@@ -174,16 +174,50 @@ func TestEmailOTPStartValidation(t *testing.T) {
})
}
t.Run("no mailer still persists a code (demo fallback)", func(t *testing.T) {
t.Run("no mailer refuses without minting", func(t *testing.T) {
repo := newFakeRepo()
w := do(mk(repo), "POST", "/api/v1/account/email/start", `{"email":"[email protected]"}`, nil)
if w.Code != http.StatusAccepted {
t.Fatalf("code = %d, want 202 (%s)", w.Code, w.Body.String())
code, msg := errEnvelope(t, w)
if w.Code != http.StatusServiceUnavailable || code != "mail_unavailable" {
t.Fatalf("code = %d %s, want 503 mail_unavailable", w.Code, w.Body.String())
}
if len(repo.otps) != 1 {
t.Fatalf("want exactly 1 persisted code, got %d", len(repo.otps))
if msg != "this server has no mail relay configured, so it cannot send codes; sign in with a passkey or ask the server operator to set up email" {
t.Errorf("message = %q", msg)
}
if len(repo.otps) != 0 {
t.Fatalf("a refused start minted %d codes", len(repo.otps))
}
})
// No relay is said before a re-verification the player could not use.
t.Run("no mailer is said before reauth", func(t *testing.T) {
repo := newFakeRepo()
repo.passkeyCreds["p"] = PasskeyCredential{ID: "p", UserID: "u1", CredentialID: "c-p", CreatedAt: frozenNow}
api := newTestAPI(repo, newFakeCluster())
api.External = staticExternal{p: &Principal{UserID: "u1", Email: "[email protected]", Role: "user", ViaSession: true}}
w := do(api.ExternalHandler(), "POST", "/api/v1/account/email/start", `{"email":"[email protected]"}`, nil)
if code, _ := errEnvelope(t, w); w.Code != http.StatusServiceUnavailable || code != "mail_unavailable" {
t.Fatalf("code = %d %s, want 503 mail_unavailable", w.Code, w.Body.String())
}
})
}
// TestDeliverOTPWithoutMailer: a caller that reaches deliverOTP with no relay gets
// the 503, and the code is written nowhere, the log included.
func TestDeliverOTPWithoutMailer(t *testing.T) {
var logged strings.Builder
old := log.Writer()
log.SetOutput(&logged)
t.Cleanup(func() { log.SetOutput(old) })
api := newTestAPI(newFakeRepo(), newFakeCluster())
err := api.deliverOTP(context.Background(), "[email protected]", "042137")
var ae *apiError
if !errors.As(err, &ae) || ae.status != http.StatusServiceUnavailable || ae.code != "mail_unavailable" {
t.Fatalf("deliverOTP = %v, want 503 mail_unavailable", err)
}
if strings.Contains(logged.String(), "042137") {
t.Errorf("the code reached the log: %s", logged.String())
}
}
// TestEmailOTPStartRateLimited closes the email-bomb vector: handleEmailOTPStart is
+13 -4
View File
@@ -212,12 +212,21 @@ func errMailRateLimited(wait time.Duration) *apiError {
"this server is sending too much mail right now; try again shortly").retryAfter(wait)
}
// checkMailBudget is the public doors' pre-resolution check: it refuses every
// address alike while the budget is spent, so the refusal says nothing about
// whether the address has an account.
// errMailUnavailable answers a door that would mail a code on an install with
// no [smtp] relay. The code is never minted, so it cannot turn up anywhere.
func errMailUnavailable() *apiError {
return newError(http.StatusServiceUnavailable, "mail_unavailable",
"this server has no mail relay configured, so it cannot send codes; sign in with a passkey or ask the server operator to set up email")
}
// checkMailBudget runs before a door mints a code: with no relay it refuses
// with mail_unavailable, and while the install-wide budget is spent with
// mail_rate_limited. The public doors call it before resolving the address, so
// either refusal is the same for every address and says nothing about whether
// it has an account.
func (a *API) checkMailBudget() error {
if a.Mailer == nil {
return nil
return errMailUnavailable()
}
if ok, wait := a.mailGate().peek(mailGateKey); !ok {
metrics.MailTotal.WithLabelValues("otp", "throttled").Inc()
+15 -5
View File
@@ -74,12 +74,18 @@ func (a *API) reauthState(r *http.Request, p *Principal) (reauthState, error) {
if hasPasskey {
st.Factors = append(st.Factors, reauthFactorPasskey)
}
if staffRole(p.Role) {
st.Factors = append(st.Factors, reauthFactorSignIn)
} else if p.EmailVerified {
st.Factors = append(st.Factors, reauthFactorEmail)
// A verified email is a way in only while a relay can mail it a code: with
// none, the email and op-login doors answer 503 mail_unavailable, so it is
// neither a factor to offer nor a door to guard.
emailWayIn := p.EmailVerified && a.Mailer != nil
if emailWayIn {
if staffRole(p.Role) {
st.Factors = append(st.Factors, reauthFactorSignIn)
} else {
st.Factors = append(st.Factors, reauthFactorEmail)
}
}
if !hasPasskey && !p.EmailVerified {
if !hasPasskey && !emailWayIn {
// Nothing to protect yet: the session is the account's only way in.
return st, nil
}
@@ -342,6 +348,10 @@ func (a *API) finishStepUpPasskey(w http.ResponseWriter, r *http.Request, p *Pri
// address and answers 202. keyPrefix namespaces the per-mailbox resend cooldown
// so the step-up doors never perturb each other's throttle.
func (a *API) startStepUpOTP(w http.ResponseWriter, r *http.Request, p *Principal, purpose, keyPrefix, auditAction string) {
if err := a.checkMailBudget(); err != nil {
writeError(w, r, err)
return
}
if until, err := a.Repo.OTPLockedUntil(r.Context(), p.UserID, purpose, a.now()); err != nil {
writeError(w, r, err)
return
+25
View File
@@ -202,6 +202,7 @@ func getReauthStatus(t *testing.T, f *sessionsFixture, tok string) reauthStatusB
func TestReauthStatusNamesTheFactors(t *testing.T) {
f := reauthFixture(t)
f.api.Mailer = &captureMailer{}
f.repo.passkeyCreds["a"] = PasskeyCredential{ID: "a", UserID: "u1", CredentialID: "c-a", CreatedAt: frozenNow}
f.repo.passkeyCreds["p"] = PasskeyCredential{ID: "p", UserID: "u3", CredentialID: "c-p", CreatedAt: frozenNow}
@@ -228,6 +229,30 @@ func TestReauthStatusNamesTheFactors(t *testing.T) {
}
}
// TestReauthWithoutMailRelay: with no [smtp] relay a verified email is no way in
// (the email and op-login doors answer 503), so it is neither offered as a factor
// nor guarded; a passkey still is, and the email start door says why it cannot help.
func TestReauthWithoutMailRelay(t *testing.T) {
f := reauthFixture(t)
f.repo.passkeyCreds["p"] = PasskeyCredential{ID: "p", UserID: "u3", CredentialID: "c-p", CreatedAt: frozenNow}
steve := getReauthStatus(t, f, laptopTok)
if steve.Needed || len(steve.Factors) != 0 {
t.Fatalf("email-only player status = %+v, want not needed and no factors", steve)
}
pam := getReauthStatus(t, f, opTok)
if !pam.Needed || strings.Join(pam.Factors, ",") != "passkey" {
t.Fatalf("operator with a passkey status = %+v, want needed with passkey only", pam)
}
w := do(f.eh, "POST", "/api/v1/account/reauth/email/start", "", asCookie(laptopTok))
if code, _ := errEnvelope(t, w); w.Code != http.StatusServiceUnavailable || code != "mail_unavailable" {
t.Fatalf("email start = %d %s, want 503 mail_unavailable", w.Code, w.Body.String())
}
if n := len(f.repo.otps); n != 0 {
t.Errorf("a refused start minted %d codes", n)
}
}
func TestReauthByEmailCode(t *testing.T) {
f := reauthFixture(t)
mailer := &captureMailer{}
+23 -2
View File
@@ -59,8 +59,8 @@ type AuthSourceConfig struct {
// SMTPConfig is the [smtp] table: the outbound mail relay felis-api delivers
// email one-time codes through (onboarding, email login, op-login). It is
// OPTIONAL — an empty host means "no mailer", and felis-api falls back to
// logging each code server-side (the pre-SMTP bootstrap posture). Only the
// OPTIONAL — an empty host means "no mailer": every door that mails a code
// answers 503 mail_unavailable and sign-in is by passkey only. Only the
// coordinates live here; the password follows the tree's credential rule
// (ArchiveS3Config, RegistryS3Config): PasswordRef NAMES the environment
// variable felis-api reads it from — the secret itself is never written into
@@ -79,6 +79,27 @@ type SMTPConfig struct {
// so a flood cannot spend the relay's quota and get the account suspended.
// 0 means DefaultMailPerHour. Size it to the relay's own limit.
MaxPerHour int `toml:"max_per_hour"`
// RequireTLS refuses to send through a relay on a port other than 465 that
// does not offer STARTTLS. Unset, it is on for every relay except one on
// this host (see TLSRequired). A code sent in the clear can be read by
// anyone on the path, and a relay's STARTTLS offer can be stripped by
// anyone who can rewrite the conversation.
RequireTLS *bool `toml:"require_tls,omitempty"`
}
// TLSRequired reports whether mail may go to this relay only over TLS: the
// explicit require_tls when set, otherwise true unless the relay is this
// host (localhost or a loopback address), where the path never leaves the
// machine.
func (c SMTPConfig) TLSRequired() bool {
if c.RequireTLS != nil {
return *c.RequireTLS
}
if strings.EqualFold(c.Host, "localhost") {
return false
}
ip := net.ParseIP(c.Host)
return ip == nil || !ip.IsLoopback()
}
// DefaultMailPerHour is the install-wide mail cap when smtp.max_per_hour is
+40
View File
@@ -598,6 +598,46 @@ url = "postgres://felis@db/felis"
}
}
// TestSMTPRequireTLS pins when mail may go out in the clear: only to a relay
// on this host unless require_tls says otherwise, and an explicit value wins
// in both directions. The key is read from felis.toml, not only set in code.
func TestSMTPRequireTLS(t *testing.T) {
load := func(smtp string) config.SMTPConfig {
t.Helper()
cfg, err := config.Load(writeTOML(t, `
[server]
root_domain = "mc.example.net"
[database]
url = "postgres://felis@db/felis"
[smtp]
from = "[email protected]"
`+smtp))
if err != nil {
t.Fatalf("Load: %v", err)
}
return cfg.SMTP
}
cases := []struct {
smtp string
want bool
}{
{`host = "smtp.example.net"`, true},
{`host = "10.0.0.5"`, true},
{`host = "localhost"`, false},
{`host = "LocalHost"`, false},
{`host = "127.0.0.1"`, false},
{`host = "127.0.0.53"`, false},
{`host = "::1"`, false},
{"host = \"smtp.example.net\"\nrequire_tls = false", false},
{"host = \"127.0.0.1\"\nrequire_tls = true", true},
}
for _, c := range cases {
if got := load(c.smtp).TLSRequired(); got != c.want {
t.Errorf("%q: TLSRequired = %v, want %v", c.smtp, got, c.want)
}
}
}
// TestLoadRejectsSMTPWithoutFrom guards the deliverability rule: naming a relay
// host commits the block to being sendable, so a missing/invalid From fails at
// load rather than at the first OTP a player is waiting on.
+13 -4
View File
@@ -6,10 +6,12 @@
// notice (SendNotice).
//
// TLS posture: port 465 dials implicit TLS; any other port dials plaintext and
// upgrades via STARTTLS when the relay advertises it. AUTH is attempted only
// when a username is configured, and net/smtp's PlainAuth itself refuses to
// send credentials over an unencrypted connection — a relay that offers no
// TLS can carry unauthenticated mail but can never be handed the password.
// upgrades via STARTTLS. With RequireTLS set (the default for any relay not on
// this host, config.SMTPConfig.TLSRequired) a relay that does not offer
// STARTTLS is refused before a single address or code is sent, so a relay
// without TLS, or a path that strips the offer, fails loudly. AUTH is
// attempted only when a username is configured, and net/smtp's PlainAuth
// itself refuses to send credentials over an unencrypted connection.
package mail
import (
@@ -38,6 +40,9 @@ type SMTP struct {
From string
Username string
Password string
// RequireTLS refuses a relay on a port other than 465 that does not offer
// STARTTLS. Callers set it from config.SMTPConfig.TLSRequired.
RequireTLS bool
}
// SendOTP mails code to email as a small bilingual plain-text message. It is
@@ -172,6 +177,10 @@ func (s *SMTP) connect(ctx context.Context) (*smtp.Client, error) {
c.Close()
return nil, fmt.Errorf("smtp: starttls: %w", err)
}
} else if s.RequireTLS {
c.Close()
return nil, fmt.Errorf("smtp: %s does not offer STARTTLS, so mail to it would cross the network unencrypted; "+
"use port 465 or a relay with STARTTLS, or set [smtp] require_tls = false for a relay you reach over a trusted link", addr)
}
}
if s.Username != "" {
+106 -4
View File
@@ -7,6 +7,7 @@ import (
"net"
"strconv"
"strings"
"sync"
"testing"
"time"
)
@@ -91,6 +92,34 @@ func TestNoticeShape(t *testing.T) {
// unconditionally and only render their verdict after the message body, so a
// probe stopping short of end-of-DATA reports a green relay that cannot send.
func fakeRelay(t *testing.T, dataVerdict string) (string, int) {
t.Helper()
return startRelay(t, &relayOpts{dataVerdict: dataVerdict})
}
// relayOpts shapes a fake relay: its end-of-DATA verdict, whether its EHLO
// offers STARTTLS (it cannot complete one: it answers 220 and hangs up), and
// the commands it received, in order.
type relayOpts struct {
dataVerdict string
starttls bool
mu sync.Mutex
seen []string
}
func (o *relayOpts) record(cmd string) {
o.mu.Lock()
defer o.mu.Unlock()
o.seen = append(o.seen, cmd)
}
func (o *relayOpts) commands() []string {
o.mu.Lock()
defer o.mu.Unlock()
return append([]string(nil), o.seen...)
}
func startRelay(t *testing.T, opts *relayOpts) (string, int) {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
@@ -103,7 +132,7 @@ func fakeRelay(t *testing.T, dataVerdict string) (string, int) {
if err != nil {
return
}
go serveFakeRelay(conn, dataVerdict)
go serveFakeRelay(conn, opts)
}
}()
host, portStr, err := net.SplitHostPort(ln.Addr().String())
@@ -117,18 +146,28 @@ func fakeRelay(t *testing.T, dataVerdict string) (string, int) {
return host, port
}
func serveFakeRelay(conn net.Conn, dataVerdict string) {
func serveFakeRelay(conn net.Conn, opts *relayOpts) {
defer conn.Close()
br := bufio.NewReader(conn)
io.WriteString(conn, "220 fake ESMTP\r\n")
for {
line, err := br.ReadString('\n')
if err != nil {
opts.record("<closed>")
return
}
switch cmd := strings.ToUpper(strings.TrimSpace(line)); {
cmd := strings.ToUpper(strings.TrimSpace(line))
if f := strings.Fields(cmd); len(f) > 0 {
opts.record(f[0])
}
switch {
case strings.HasPrefix(cmd, "EHLO") && opts.starttls:
io.WriteString(conn, "250-fake\r\n250 STARTTLS\r\n")
case strings.HasPrefix(cmd, "EHLO"), strings.HasPrefix(cmd, "HELO"):
io.WriteString(conn, "250 fake\r\n")
case strings.HasPrefix(cmd, "STARTTLS"):
io.WriteString(conn, "220 ready\r\n")
return
case strings.HasPrefix(cmd, "MAIL FROM"), strings.HasPrefix(cmd, "RCPT TO"):
io.WriteString(conn, "250 2.1.0 Ok\r\n")
case strings.HasPrefix(cmd, "DATA"):
@@ -142,7 +181,7 @@ func serveFakeRelay(conn net.Conn, dataVerdict string) {
break
}
}
io.WriteString(conn, dataVerdict+"\r\n")
io.WriteString(conn, opts.dataVerdict+"\r\n")
case strings.HasPrefix(cmd, "QUIT"):
io.WriteString(conn, "221 bye\r\n")
return
@@ -199,3 +238,66 @@ func TestSendOTPSurfacesEndOfDataRefusal(t *testing.T) {
t.Errorf("want the relay's refusal surfaced, got: %v", err)
}
}
// TestRequireTLSRefusesPlaintextRelay: with RequireTLS a relay that offers no
// STARTTLS gets nothing past EHLO — no sender, no recipient, no code — and the
// operator is told which relay and which knob.
func TestRequireTLSRefusesPlaintextRelay(t *testing.T) {
relay := &relayOpts{dataVerdict: "250 2.0.0 Ok"}
host, port := startRelay(t, relay)
s := &SMTP{Host: host, Port: port, From: "[email protected]", RequireTLS: true}
err := s.SendOTP(context.Background(), "[email protected]", "042137")
if err == nil {
t.Fatal("SendOTP sent a code through a relay without STARTTLS")
}
for _, want := range []string{"does not offer STARTTLS", host + ":" + strconv.Itoa(port), "require_tls = false"} {
if !strings.Contains(err.Error(), want) {
t.Errorf("error %q does not mention %q", err, want)
}
}
// The client hangs up instead of leaving the connection to the garbage
// collector; the relay sees that shortly after SendOTP returns.
deadline := time.Now().Add(2 * time.Second)
for len(relay.commands()) < 2 && time.Now().Before(deadline) {
time.Sleep(10 * time.Millisecond)
}
if got := strings.Join(relay.commands(), " "); got != "EHLO <closed>" {
t.Errorf("relay received %q, want EHLO and then the connection closed", got)
}
if err := s.Ping(context.Background()); err == nil || !strings.Contains(err.Error(), "does not offer STARTTLS") {
t.Errorf("Ping = %v, want the STARTTLS refusal", err)
}
}
// TestPlaintextRelayAllowedWithoutRequireTLS: require_tls = false (or a relay
// on this host) keeps the old opportunistic posture and the code is delivered.
func TestPlaintextRelayAllowedWithoutRequireTLS(t *testing.T) {
relay := &relayOpts{dataVerdict: "250 2.0.0 Ok"}
host, port := startRelay(t, relay)
s := &SMTP{Host: host, Port: port, From: "[email protected]"}
if err := s.SendOTP(context.Background(), "[email protected]", "042137"); err != nil {
t.Fatalf("SendOTP: %v", err)
}
if got := strings.Join(relay.commands(), " "); got != "EHLO MAIL RCPT DATA QUIT" {
t.Errorf("relay received %q, want EHLO MAIL RCPT DATA QUIT", got)
}
}
// TestRequireTLSTakesAnOfferedStartTLS: when the relay does offer STARTTLS the
// client goes for it rather than refusing; this fake then hangs up mid-upgrade,
// so the failure is the upgrade's own.
func TestRequireTLSTakesAnOfferedStartTLS(t *testing.T) {
relay := &relayOpts{dataVerdict: "250 2.0.0 Ok", starttls: true}
host, port := startRelay(t, relay)
s := &SMTP{Host: host, Port: port, From: "[email protected]", RequireTLS: true}
err := s.SendOTP(context.Background(), "[email protected]", "042137")
if err == nil || !strings.HasPrefix(err.Error(), "smtp: starttls:") {
t.Fatalf("SendOTP = %v, want the STARTTLS upgrade failure", err)
}
if got := strings.Join(relay.commands(), " "); got != "EHLO STARTTLS" {
t.Errorf("relay received %q, want EHLO STARTTLS", got)
}
}