fix(api): 未配置 SMTP 时发码门统一 503 mail_unavailable 且不再把验证码写日志,非本机中继默认强制 STARTTLS(require_tls)
This commit is contained in:
27 files changed
+529
-87
No files matched your search
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user