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
+525 -83

No files matched your search

+20 -8
View File
@@ -140,7 +140,8 @@ func cmdAPI(args []string, stdout, stderr io.Writer) int {
// Email one-time codes go through the [smtp] relay when one is configured; the // Email one-time codes go through the [smtp] relay when one is configured; the
// password is read from the env var password_ref names (default SMTPPasswordEnv, // password is read from the env var password_ref names (default SMTPPasswordEnv,
// injected from the felis-smtp Secret). No [smtp] host ⇒ mailer stays nil and // injected from the felis-smtp Secret). No [smtp] host ⇒ mailer stays nil and
// deliverOTP logs each code server-side (the pre-SMTP bootstrap posture). // every door that mails a code answers 503 mail_unavailable: a code that is
// not mailed is never written anywhere else either.
var mailer api.OTPMailer var mailer api.OTPMailer
if cfg.SMTP.Host != "" { if cfg.SMTP.Host != "" {
passRef := cfg.SMTP.PasswordRef passRef := cfg.SMTP.PasswordRef
@@ -151,15 +152,12 @@ func cmdAPI(args []string, stdout, stderr io.Writer) int {
if cfg.SMTP.Username != "" && password == "" { if cfg.SMTP.Username != "" && password == "" {
fmt.Fprintf(stderr, "felis api: warning: [smtp] username is set but credentials env %s is empty — OTP sends will fail AUTH\n", passRef) fmt.Fprintf(stderr, "felis api: warning: [smtp] username is set but credentials env %s is empty — OTP sends will fail AUTH\n", passRef)
} }
mailer = &mail.SMTP{ mailer = smtpRelay(cfg.SMTP, password)
Host: cfg.SMTP.Host, if !cfg.SMTP.TLSRequired() {
Port: cfg.SMTP.Port, fmt.Fprintf(stderr, "felis api: warning: [smtp] %s may be sent codes without TLS (require_tls off or a relay on this host)\n", cfg.SMTP.Host)
From: cfg.SMTP.From,
Username: cfg.SMTP.Username,
Password: password,
} }
} else { } else {
fmt.Fprintln(stderr, "felis api: [smtp] not configured — email one-time codes are logged, not mailed") fmt.Fprintln(stderr, "felis api: [smtp] not configured — email sign-in and verification are off (503 mail_unavailable); sign in with a passkey, or run felis setup to add a relay")
} }
// Build subsystem (spec §16): the weak-SA build Job runs in the configured // Build subsystem (spec §16): the weak-SA build Job runs in the configured
@@ -824,3 +822,17 @@ func internalCallerTokens(getenv func(string) string) (api.CallerTokens, error)
} }
return api.NewCallerTokens(tokens) return api.NewCallerTokens(tokens)
} }
// smtpRelay is the relay [smtp] names, with the resolved password and the TLS
// posture config.SMTPConfig.TLSRequired picks. felis api, the reaper and the
// watchdog all send through it, so none can drift to a weaker posture.
func smtpRelay(c config.SMTPConfig, password string) *mail.SMTP {
return &mail.SMTP{
Host: c.Host,
Port: c.Port,
From: c.From,
Username: c.Username,
Password: password,
RequireTLS: c.TLSRequired(),
}
}
+1 -8
View File
@@ -16,7 +16,6 @@ import (
"felis.lolicon.best/internal/apis/felis/v1alpha1" "felis.lolicon.best/internal/apis/felis/v1alpha1"
"felis.lolicon.best/internal/backup" "felis.lolicon.best/internal/backup"
"felis.lolicon.best/internal/config" "felis.lolicon.best/internal/config"
"felis.lolicon.best/internal/mail"
"felis.lolicon.best/internal/platform" "felis.lolicon.best/internal/platform"
"felis.lolicon.best/internal/reaper" "felis.lolicon.best/internal/reaper"
corev1 "k8s.io/api/core/v1" corev1 "k8s.io/api/core/v1"
@@ -126,13 +125,7 @@ func cmdReaper(args []string, stdout, stderr io.Writer) int {
} }
return email, nil return email, nil
}, },
notifier: &mail.SMTP{ notifier: smtpRelay(cfg.SMTP, password),
Host: cfg.SMTP.Host,
Port: cfg.SMTP.Port,
From: cfg.SMTP.From,
Username: cfg.SMTP.Username,
Password: password,
},
} }
} else { } else {
fmt.Fprintln(stderr, "felis reaper: [smtp] not configured — pre-reap warnings are logged and NOT marked sent") fmt.Fprintln(stderr, "felis reaper: [smtp] not configured — pre-reap warnings are logged and NOT marked sent")
+24 -11
View File
@@ -9,7 +9,6 @@ import (
"strings" "strings"
"felis.lolicon.best/internal/config" "felis.lolicon.best/internal/config"
"felis.lolicon.best/internal/mail"
"felis.lolicon.best/internal/platform" "felis.lolicon.best/internal/platform"
"github.com/charmbracelet/bubbles/spinner" "github.com/charmbracelet/bubbles/spinner"
@@ -311,24 +310,22 @@ func applySMTPConfig(ctx context.Context, in smtpInputs) error {
if err != nil { if err != nil {
return fmt.Errorf("port %q is not a number", in.port) return fmt.Errorf("port %q is not a number", in.port)
} }
relay := &mail.SMTP{Host: in.host, Port: port, From: in.from, Username: in.username, Password: in.password} var prev config.SMTPConfig
if err := relay.Ping(ctx); err != nil { if cur, err := config.Load(hostSetupConfigPath); err == nil {
prev = cur.SMTP
}
// Ping under the posture felis api will send with, so a relay without
// STARTTLS is turned down here rather than at a player's first code.
if err := smtpRelay(setupSMTPConfig(in, port, prev), in.password).Ping(ctx); err != nil {
return err return err
} }
smtpCfg := config.SMTPConfig{
Host: in.host,
Port: port,
From: in.from,
Username: in.username,
PasswordRef: platform.SMTPPasswordEnv,
}
for _, path := range []string{hostSetupConfigPath, podSetupConfigPath} { for _, path := range []string{hostSetupConfigPath, podSetupConfigPath} {
cfg, err := config.Load(path) cfg, err := config.Load(path)
if err != nil { if err != nil {
return err return err
} }
cfg.SMTP = smtpCfg cfg.SMTP = setupSMTPConfig(in, port, cfg.SMTP)
if err := writeConfig(path, cfg); err != nil { if err := writeConfig(path, cfg); err != nil {
return err return err
} }
@@ -351,6 +348,22 @@ func applySMTPConfig(ctx context.Context, in smtpInputs) error {
return kubectl(ctx, "-n", "felis", "rollout", "status", "deployment/felis-api", "--timeout=180s") return kubectl(ctx, "-n", "felis", "rollout", "status", "deployment/felis-api", "--timeout=180s")
} }
// setupSMTPConfig is the [smtp] block this screen writes: the relay it just
// proved, plus the keys only an operator sets by hand (require_tls,
// max_per_hour), carried over from the block it replaces so reconfiguring the
// relay does not quietly reset them.
func setupSMTPConfig(in smtpInputs, port int, prev config.SMTPConfig) config.SMTPConfig {
return config.SMTPConfig{
Host: in.host,
Port: port,
From: in.from,
Username: in.username,
PasswordRef: platform.SMTPPasswordEnv,
MaxPerHour: prev.MaxPerHour,
RequireTLS: prev.RequireTLS,
}
}
// smtpSecretManifest renders the felis-smtp Secret for the given namespace, the // smtpSecretManifest renders the felis-smtp Secret for the given namespace, the
// one the receiving Deployment/CronJob resolves its secretKeyRef against (felis // one the receiving Deployment/CronJob resolves its secretKeyRef against (felis
// for felis-api, the workload namespace for the reaper's mirror). The namespace // for felis-api, the workload namespace for the reaper's mirror). The namespace
+39
View File
@@ -1,9 +1,12 @@
package main package main
import ( import (
"reflect"
"strings" "strings"
"testing" "testing"
"felis.lolicon.best/internal/config"
"felis.lolicon.best/internal/mail"
"sigs.k8s.io/yaml" "sigs.k8s.io/yaml"
) )
@@ -35,3 +38,39 @@ func TestSMTPSecretManifestCarriesTargetNamespace(t *testing.T) {
} }
} }
} }
// TestSMTPRelayCarriesTLSPosture: the relay felis api, the reaper and the
// watchdog send through refuses plaintext for a remote host and allows it for
// one on this host, as [smtp] says.
func TestSMTPRelayCarriesTLSPosture(t *testing.T) {
remote := smtpRelay(config.SMTPConfig{Host: "smtp.example.net", Port: 587, From: "[email protected]", Username: "felis"}, "pw")
want := &mail.SMTP{Host: "smtp.example.net", Port: 587, From: "[email protected]", Username: "felis", Password: "pw", RequireTLS: true}
if !reflect.DeepEqual(remote, want) {
t.Errorf("remote relay = %+v, want %+v", remote, want)
}
if local := smtpRelay(config.SMTPConfig{Host: "127.0.0.1", Port: 25, From: "[email protected]"}, ""); local.RequireTLS {
t.Error("a relay on this host must not require TLS by default")
}
}
// TestSetupSMTPConfigKeepsHandSetKeys: re-running the email screen replaces the
// relay but keeps require_tls and max_per_hour, which only an operator sets.
func TestSetupSMTPConfigKeepsHandSetKeys(t *testing.T) {
off := false
prev := config.SMTPConfig{Host: "old.example.net", Port: 25, From: "[email protected]", MaxPerHour: 500, RequireTLS: &off}
in := smtpInputs{host: "smtp.example.net", from: "[email protected]", username: "felis"}
got := setupSMTPConfig(in, 465, prev)
if got.Host != "smtp.example.net" || got.Port != 465 || got.From != "[email protected]" ||
got.Username != "felis" || got.PasswordRef != "FELIS_SMTP_PASSWORD" {
t.Errorf("relay fields = %+v", got)
}
if got.MaxPerHour != 500 {
t.Errorf("max_per_hour = %d, want 500", got.MaxPerHour)
}
if got.RequireTLS == nil || *got.RequireTLS {
t.Errorf("require_tls = %v, want the operator's false", got.RequireTLS)
}
if fresh := setupSMTPConfig(in, 587, config.SMTPConfig{}); fresh.RequireTLS != nil || fresh.MaxPerHour != 0 {
t.Errorf("first setup = %+v, want require_tls and max_per_hour unset", fresh)
}
}
+1 -2
View File
@@ -13,7 +13,6 @@ import (
"time" "time"
"felis.lolicon.best/internal/config" "felis.lolicon.best/internal/config"
"felis.lolicon.best/internal/mail"
"felis.lolicon.best/internal/offsite" "felis.lolicon.best/internal/offsite"
"felis.lolicon.best/internal/platform" "felis.lolicon.best/internal/platform"
"felis.lolicon.best/internal/store" "felis.lolicon.best/internal/store"
@@ -245,7 +244,7 @@ func sendAlert(ctx context.Context, cfg *config.Config, state *watchdog.State, s
if ref := cfg.SMTP.PasswordRef; ref != "" && os.Getenv(ref) != "" { if ref := cfg.SMTP.PasswordRef; ref != "" && os.Getenv(ref) != "" {
password = os.Getenv(ref) password = os.Getenv(ref)
} }
relay := &mail.SMTP{Host: cfg.SMTP.Host, Port: cfg.SMTP.Port, From: cfg.SMTP.From, Username: cfg.SMTP.Username, Password: password} relay := smtpRelay(cfg.SMTP, password)
var errs []error var errs []error
for _, to := range state.Recipients { for _, to := range state.Recipients {
if err := relay.SendNotice(ctx, to, subject, body); err != nil { if err := relay.SendNotice(ctx, to, subject, body); err != nil {
+2
View File
@@ -350,6 +350,7 @@ listen = "0.0.0.0:8080"
from = "[email protected]" from = "[email protected]"
username = "relay-user" username = "relay-user"
password_ref = "smtp-password" password_ref = "smtp-password"
require_tls = false
# Third-party Yggdrasil sources federated by the hasJoined multiplexer. Mojang is # Third-party Yggdrasil sources federated by the hasJoined multiplexer. Mojang is
# always the code-owned identity anchor (premium-first), prepended in Go; sources here # always the code-owned identity anchor (premium-first), prepended in Go; sources here
@@ -367,6 +368,7 @@ out="$(run_smtp "$smtp_dir")"
expect "a configured [smtp] relay is carried" 'host = "mail.example"' "$out" expect "a configured [smtp] relay is carried" 'host = "mail.example"' "$out"
expect "its port survives the carry" 'port = 587' "$out" expect "its port survives the carry" 'port = 587' "$out"
expect "its credentials reference survives" 'password_ref = "smtp-password"' "$out" expect "its credentials reference survives" 'password_ref = "smtp-password"' "$out"
expect "an operator's require_tls survives" 'require_tls = false' "$out"
case "$out" in case "$out" in
*"#"*) *"#"*)
echo "FAIL: the carry hoards comment lines:"; printf '%s\n' "$out"; fails=$((fails + 1)) ;; echo "FAIL: the carry hoards comment lines:"; printf '%s\n' "$out"; fails=$((fails + 1)) ;;
+5 -6
View File
@@ -116,12 +116,11 @@ worth revisiting.
## Wired since the marker was written ## Wired since the marker was written
- `internal/api/handlers_email_otp.go:54,223,227` and `internal/api/api.go:84` — - `internal/api/handlers_email_otp.go` and `internal/api/api.go` — SMTP shipped on
SMTP shipped on 2026-07-20 (`internal/mail`, wired in `cmd/felis/api.go`). An install with no
2026-07-20 (`internal/mail`, wired at `cmd/felis/api.go:264`). The nil-`Mailer` `[smtp]` section leaves the `Mailer` nil, and every door that mails a code answers
branch that logs the code server-side is a runtime fallback for an install with no 503 `mail_unavailable`; codes are never logged. The reading "Felis cannot send
`[smtp]` section, not an unbuilt feature. The comments are accurate; the reading mail" is stale.
"Felis cannot send mail" is not.
- `internal/config/config.go:117` — was stale. It described the upload transport as a - `internal/config/config.go:117` — was stale. It described the upload transport as a
deferred integration after both backends had shipped (`LocalContextStore`, deferred integration after both backends had shipped (`LocalContextStore`,
`S3ContextStore`, selected in `cmd/felis/api.go` by the shape of the configured `S3ContextStore`, selected in `cmd/felis/api.go` by the shape of the configured
+24 -2
View File
@@ -192,6 +192,15 @@ components:
content: content:
application/json: application/json:
schema: { $ref: '#/components/schemas/Error' } schema: { $ref: '#/components/schemas/Error' }
MailUnavailable:
description: >
This install has no [smtp] relay (code mail_unavailable), so no code was minted
or sent. The public doors answer it before resolving the address, so it is the
same for every address. Sign in with a passkey, or have the operator configure
email with felis setup.
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
RateLimited: RateLimited:
description: > description: >
This client address called the public sign-in doors faster than the per-address This client address called the public sign-in doors faster than the per-address
@@ -2162,7 +2171,10 @@ paths:
'200': '200':
description: >- description: >-
The login methods available for the address, in a deterministic order The login methods available for the address, in a deterministic order
(passkey before email_otp). An empty array means no verified account. (passkey before email_otp). email_otp is offered only when the install has
a mail relay, passkey only when a verifier is wired and the account has a
credential. An empty array means no verified account, or none of its methods
is available on this install.
content: content:
application/json: application/json:
schema: schema:
@@ -2546,6 +2558,8 @@ paths:
schema: { $ref: '#/components/schemas/Error' } schema: { $ref: '#/components/schemas/Error' }
'502': '502':
$ref: '#/components/responses/MailUndeliverable' $ref: '#/components/responses/MailUndeliverable'
'503':
$ref: '#/components/responses/MailUnavailable'
/api/v1/auth/email/verify: /api/v1/auth/email/verify:
post: post:
@@ -2673,6 +2687,8 @@ paths:
schema: { $ref: '#/components/schemas/Error' } schema: { $ref: '#/components/schemas/Error' }
'502': '502':
$ref: '#/components/responses/MailUndeliverable' $ref: '#/components/responses/MailUndeliverable'
'503':
$ref: '#/components/responses/MailUnavailable'
/api/v1/auth/op-login/status/{id}: /api/v1/auth/op-login/status/{id}:
get: get:
@@ -4113,7 +4129,7 @@ paths:
email: { type: string, format: email } email: { type: string, format: email }
responses: responses:
'202': '202':
description: Code minted and dispatched (or logged server-side when no mailer is wired). description: Code minted and mailed.
content: content:
application/json: application/json:
schema: schema:
@@ -4142,6 +4158,8 @@ paths:
schema: { $ref: '#/components/schemas/Error' } schema: { $ref: '#/components/schemas/Error' }
'502': '502':
$ref: '#/components/responses/MailUndeliverable' $ref: '#/components/responses/MailUndeliverable'
'503':
$ref: '#/components/responses/MailUnavailable'
/api/v1/account/email/verify: /api/v1/account/email/verify:
post: post:
@@ -4540,6 +4558,8 @@ paths:
schema: { $ref: '#/components/schemas/Error' } schema: { $ref: '#/components/schemas/Error' }
'502': '502':
$ref: '#/components/responses/MailUndeliverable' $ref: '#/components/responses/MailUndeliverable'
'503':
$ref: '#/components/responses/MailUnavailable'
/api/v1/account/reauth/email/verify: /api/v1/account/reauth/email/verify:
post: post:
@@ -4757,6 +4777,8 @@ paths:
schema: { $ref: '#/components/schemas/Error' } schema: { $ref: '#/components/schemas/Error' }
'502': '502':
$ref: '#/components/responses/MailUndeliverable' $ref: '#/components/responses/MailUndeliverable'
'503':
$ref: '#/components/responses/MailUnavailable'
/api/v1/account/migrate/confirm/otp/verify: /api/v1/account/migrate/confirm/otp/verify:
post: post:
+30 -2
View File
@@ -1947,11 +1947,12 @@ Skips the pre-migration snapshot (`migrate up -no-backup`). The installer warns
loudly when it is set. Use it only when the snapshot cannot work and you have loudly when it is set. Use it only when the snapshot cannot work and you have
another backup, e.g. an external database newer than the host's `pg_dump`. another backup, e.g. an external database newer than the host's `pg_dump`.
## 17. Sign-in refused with 429, mail budget, account code locks, failed sign-ins ## 17. Sign-in refused: 429 limits, no mail relay, account code locks, failed sign-ins
The public sign-in doors (`/api/v1/auth/*` except logout and the op-login The public sign-in doors (`/api/v1/auth/*` except logout and the op-login
status poll) have three limits of their own. Each answers 429 with a status poll) have three limits of their own. Each answers 429 with a
`Retry-After` header and a distinct error code. `Retry-After` header and a distinct error code. The doors that mail a code
also answer 503 `mail_unavailable` on an install with no mail relay.
### `rate_limited`: one address called the doors too often ### `rate_limited`: one address called the doors too often
@@ -1991,6 +1992,33 @@ raise `max_per_hour` to what your relay allows.
(`felis_mail_total{result="failed"}`, 502 `mail_undeliverable` to the caller). (`felis_mail_total{result="failed"}`, 502 `mail_undeliverable` to the caller).
The relay's reason is in the `felis-api` log. The relay's reason is in the `felis-api` log.
### `mail_unavailable`: no mail relay
With no `[smtp]` section every door that mails a code answers 503
`mail_unavailable` before minting one: email sign-in, op.console sign-in,
email verification, and the email step-up for sensitive changes and
migration. The public doors answer before looking up the address, so every
address gets the same reply. Sign-in is by passkey only, and a verified email
stops counting as a way into the account (it is no longer offered as a
re-verification factor). Codes are never logged: `felis api` says at start
`[smtp] not configured`. Run `felis setup` and configure email to open the
doors.
### Relay refused for lacking TLS
A relay on port 465 is spoken to over TLS from the first byte. On any other
port Felis upgrades with STARTTLS, and when the relay does not offer it the
send fails with `smtp: <host>:<port> does not offer STARTTLS` (502
`mail_undeliverable` to the caller, the full text in the `felis-api` log, and
the same error on the `felis setup` email screen). Without TLS anyone on the
path reads the codes, and anyone who can rewrite the conversation can strip
the STARTTLS offer, so this is the default for every relay except one on this
host (`localhost`, `127.0.0.0/8`, `::1`). Use port 465 or a relay that offers
STARTTLS. For a relay you reach over a link you trust, set
`require_tls = false` under `[smtp]` in `/etc/felis/felis.toml` and
`/etc/felis/felis.pod.toml`; installer re-runs and the setup email screen keep
it. `felis api` warns at start whenever codes may go out without TLS.
### `otp_account_locked`: ten wrong codes in 24 hours ### `otp_account_locked`: ten wrong codes in 24 hours
Ten wrong email codes for one account within 24 hours, counted across every Ten wrong email codes for one account within 24 hours, counted across every
+4 -4
View File
@@ -101,10 +101,10 @@ type API struct {
Submissions SubmissionService Submissions SubmissionService
// Mailer delivers player email one-time codes (spec §B2 onboarding). It is // 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 // optional: when nil (no [smtp] relay) every door that mails a code answers 503
// logs it server-side instead of mailing it (a KNOWN-LIMITATION — the demo has no // mail_unavailable before minting one, auth options stops offering email_otp,
// SMTP), so the verify flow is still exercised end-to-end. Production wires a real // and a verified email stops counting as a reauth factor. The code is never
// sender. The code is never returned to the client on either path. // returned to the client or logged.
Mailer OTPMailer Mailer OTPMailer
// Passkey verifies WebAuthn credential-creation ceremonies (spec §14 / Phase 6 // 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") 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())
}
}
+8 -3
View File
@@ -14,8 +14,9 @@ import (
// It is the deliberate counter-slice to the anti-enumeration login doors // 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 // (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 // 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 // existence is revealed. An empty methods array means "no (verified) account, or
// makes it a mass-enumeration surface by design — an accepted product decision, the // 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 // 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 // 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 // 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-OTP login works for any resolved verified account (UserByEmail resolves only
// email_verified rows), so it is always on offer. // 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") methods = append(methods, "email_otp")
}
writeJSON(w, http.StatusOK, map[string]any{"methods": methods}) writeJSON(w, http.StatusOK, map[string]any{"methods": methods})
} }
+19 -1
View File
@@ -22,7 +22,8 @@ import (
// door would immediately 503. // door would immediately 503.
// seedAuthOptionsAPI wires the discovery door: local sessions enabled, a verified player // 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. // Callers seed passkey credentials per-test to set the credential state.
func seedAuthOptionsAPI(t *testing.T) (*API, *fakeRepo) { func seedAuthOptionsAPI(t *testing.T) (*API, *fakeRepo) {
t.Helper() 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} repo.staff["boss"] = &StaffUser{ID: "a1", Username: "boss", Email: "[email protected]", Role: "admin", EmailVerified: true}
api := newTestAPI(repo, newFakeCluster()) api := newTestAPI(repo, newFakeCluster())
api.Passkey = &fakePasskeyVerifier{} api.Passkey = &fakePasskeyVerifier{}
api.Mailer = &captureMailer{}
return api, repo 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 // TestAuthOptionsPasskeyRequiresWiredVerifier: the account HAS an enrolled passkey, but
// no verifier is wired (a.Passkey == nil). Both login halves 503 passkey_unavailable in // 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. // 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 otpLiveLoginCodes = 3
) )
// OTPMailer delivers a one-time code to an email address. It is a seam, not a // OTPMailer delivers a one-time code to an email address. felis api wires the
// dependency: the demo ships without SMTP, so a nil Mailer logs the code // [smtp] relay (internal/mail); with none configured it stays nil and every door
// server-side instead of mailing it (a KNOWN-LIMITATION, never a code returned to // that mails a code answers 503 mail_unavailable before minting one.
// the client). Production wires a real sender.
type OTPMailer interface { type OTPMailer interface {
SendOTP(ctx context.Context, email, code string) error 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")) writeError(w, r, newError(http.StatusBadRequest, "bad_request", "a valid email is required"))
return 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. // Gate the start: the verify only redeems a code minted here.
if !a.requireReauth(w, r, p) { if !a.requireReauth(w, r, p) {
return 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}) 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 // deliverOTP hands the code to the configured Mailer. The code goes nowhere
// demo) — logs it server-side as a KNOWN-LIMITATION. The code is logged ONLY in the // else: never into a response and never into a log, since anyone who can read
// no-mailer fallback and ONLY to the server log; it is never put in an HTTP response. // 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); // 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. // 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 { func (a *API) deliverOTP(ctx context.Context, email, code string) error {
if a.Mailer == nil { 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 errMailUnavailable()
return nil
} }
if ok, wait := a.mailGate().take(mailGateKey); !ok { if ok, wait := a.mailGate().take(mailGateKey); !ok {
metrics.MailTotal.WithLabelValues("otp", "throttled").Inc() 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 // TestEmailOTPStartValidation covers the mint-side input gate and the no-relay
// fallback (the demo path): a malformed address never mints, and a nil Mailer still // refusal: a malformed address never mints, and with no Mailer the start answers
// persists a code (logged server-side) so the verify flow stays exercisable. // 503 mail_unavailable without minting, so no code exists to leak anywhere.
func TestEmailOTPStartValidation(t *testing.T) { func TestEmailOTPStartValidation(t *testing.T) {
user := &Principal{UserID: "u1", Email: "[email protected]", Role: "user"} user := &Principal{UserID: "u1", Email: "[email protected]", Role: "user"}
mk := func(repo *fakeRepo) http.Handler { mk := func(repo *fakeRepo) http.Handler {
api := newTestAPI(repo, newFakeCluster()) api := newTestAPI(repo, newFakeCluster())
api.External = staticExternal{p: user} api.External = staticExternal{p: user}
return api.ExternalHandler() // no Mailer wired → demo fallback return api.ExternalHandler() // no Mailer wired
} }
bad := map[string]string{ 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() repo := newFakeRepo()
w := do(mk(repo), "POST", "/api/v1/account/email/start", `{"email":"[email protected]"}`, nil) w := do(mk(repo), "POST", "/api/v1/account/email/start", `{"email":"[email protected]"}`, nil)
if w.Code != http.StatusAccepted { code, msg := errEnvelope(t, w)
t.Fatalf("code = %d, want 202 (%s)", w.Code, w.Body.String()) 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 { 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.Fatalf("want exactly 1 persisted code, got %d", len(repo.otps)) 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 // 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) "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 // errMailUnavailable answers a door that would mail a code on an install with
// address alike while the budget is spent, so the refusal says nothing about // no [smtp] relay. The code is never minted, so it cannot turn up anywhere.
// whether the address has an account. 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 { func (a *API) checkMailBudget() error {
if a.Mailer == nil { if a.Mailer == nil {
return nil return errMailUnavailable()
} }
if ok, wait := a.mailGate().peek(mailGateKey); !ok { if ok, wait := a.mailGate().peek(mailGateKey); !ok {
metrics.MailTotal.WithLabelValues("otp", "throttled").Inc() metrics.MailTotal.WithLabelValues("otp", "throttled").Inc()
+12 -2
View File
@@ -74,12 +74,18 @@ func (a *API) reauthState(r *http.Request, p *Principal) (reauthState, error) {
if hasPasskey { if hasPasskey {
st.Factors = append(st.Factors, reauthFactorPasskey) st.Factors = append(st.Factors, reauthFactorPasskey)
} }
// 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) { if staffRole(p.Role) {
st.Factors = append(st.Factors, reauthFactorSignIn) st.Factors = append(st.Factors, reauthFactorSignIn)
} else if p.EmailVerified { } else {
st.Factors = append(st.Factors, reauthFactorEmail) 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. // Nothing to protect yet: the session is the account's only way in.
return st, nil 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 // address and answers 202. keyPrefix namespaces the per-mailbox resend cooldown
// so the step-up doors never perturb each other's throttle. // 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) { 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 { if until, err := a.Repo.OTPLockedUntil(r.Context(), p.UserID, purpose, a.now()); err != nil {
writeError(w, r, err) writeError(w, r, err)
return return
+25
View File
@@ -202,6 +202,7 @@ func getReauthStatus(t *testing.T, f *sessionsFixture, tok string) reauthStatusB
func TestReauthStatusNamesTheFactors(t *testing.T) { func TestReauthStatusNamesTheFactors(t *testing.T) {
f := reauthFixture(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["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} 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) { func TestReauthByEmailCode(t *testing.T) {
f := reauthFixture(t) f := reauthFixture(t)
mailer := &captureMailer{} 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 // SMTPConfig is the [smtp] table: the outbound mail relay felis-api delivers
// email one-time codes through (onboarding, email login, op-login). It is // 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 // OPTIONAL — an empty host means "no mailer": every door that mails a code
// logging each code server-side (the pre-SMTP bootstrap posture). Only the // answers 503 mail_unavailable and sign-in is by passkey only. Only the
// coordinates live here; the password follows the tree's credential rule // coordinates live here; the password follows the tree's credential rule
// (ArchiveS3Config, RegistryS3Config): PasswordRef NAMES the environment // (ArchiveS3Config, RegistryS3Config): PasswordRef NAMES the environment
// variable felis-api reads it from — the secret itself is never written into // 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. // 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. // 0 means DefaultMailPerHour. Size it to the relay's own limit.
MaxPerHour int `toml:"max_per_hour"` 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 // 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 // TestLoadRejectsSMTPWithoutFrom guards the deliverability rule: naming a relay
// host commits the block to being sendable, so a missing/invalid From fails at // 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. // load rather than at the first OTP a player is waiting on.
+13 -4
View File
@@ -6,10 +6,12 @@
// notice (SendNotice). // notice (SendNotice).
// //
// TLS posture: port 465 dials implicit TLS; any other port dials plaintext and // 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 // upgrades via STARTTLS. With RequireTLS set (the default for any relay not on
// when a username is configured, and net/smtp's PlainAuth itself refuses to // this host, config.SMTPConfig.TLSRequired) a relay that does not offer
// send credentials over an unencrypted connection — a relay that offers no // STARTTLS is refused before a single address or code is sent, so a relay
// TLS can carry unauthenticated mail but can never be handed the password. // 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 package mail
import ( import (
@@ -38,6 +40,9 @@ type SMTP struct {
From string From string
Username string Username string
Password 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 // 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() c.Close()
return nil, fmt.Errorf("smtp: starttls: %w", err) 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 != "" { if s.Username != "" {
+106 -4
View File
@@ -7,6 +7,7 @@ import (
"net" "net"
"strconv" "strconv"
"strings" "strings"
"sync"
"testing" "testing"
"time" "time"
) )
@@ -91,6 +92,34 @@ func TestNoticeShape(t *testing.T) {
// unconditionally and only render their verdict after the message body, so a // 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. // probe stopping short of end-of-DATA reports a green relay that cannot send.
func fakeRelay(t *testing.T, dataVerdict string) (string, int) { 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() t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0") ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil { if err != nil {
@@ -103,7 +132,7 @@ func fakeRelay(t *testing.T, dataVerdict string) (string, int) {
if err != nil { if err != nil {
return return
} }
go serveFakeRelay(conn, dataVerdict) go serveFakeRelay(conn, opts)
} }
}() }()
host, portStr, err := net.SplitHostPort(ln.Addr().String()) host, portStr, err := net.SplitHostPort(ln.Addr().String())
@@ -117,18 +146,28 @@ func fakeRelay(t *testing.T, dataVerdict string) (string, int) {
return host, port return host, port
} }
func serveFakeRelay(conn net.Conn, dataVerdict string) { func serveFakeRelay(conn net.Conn, opts *relayOpts) {
defer conn.Close() defer conn.Close()
br := bufio.NewReader(conn) br := bufio.NewReader(conn)
io.WriteString(conn, "220 fake ESMTP\r\n") io.WriteString(conn, "220 fake ESMTP\r\n")
for { for {
line, err := br.ReadString('\n') line, err := br.ReadString('\n')
if err != nil { if err != nil {
opts.record("<closed>")
return 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"): case strings.HasPrefix(cmd, "EHLO"), strings.HasPrefix(cmd, "HELO"):
io.WriteString(conn, "250 fake\r\n") 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"): case strings.HasPrefix(cmd, "MAIL FROM"), strings.HasPrefix(cmd, "RCPT TO"):
io.WriteString(conn, "250 2.1.0 Ok\r\n") io.WriteString(conn, "250 2.1.0 Ok\r\n")
case strings.HasPrefix(cmd, "DATA"): case strings.HasPrefix(cmd, "DATA"):
@@ -142,7 +181,7 @@ func serveFakeRelay(conn net.Conn, dataVerdict string) {
break break
} }
} }
io.WriteString(conn, dataVerdict+"\r\n") io.WriteString(conn, opts.dataVerdict+"\r\n")
case strings.HasPrefix(cmd, "QUIT"): case strings.HasPrefix(cmd, "QUIT"):
io.WriteString(conn, "221 bye\r\n") io.WriteString(conn, "221 bye\r\n")
return return
@@ -199,3 +238,66 @@ func TestSendOTPSurfacesEndOfDataRefusal(t *testing.T) {
t.Errorf("want the relay's refusal surfaced, got: %v", err) 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)
}
}
@@ -50,6 +50,7 @@
"bad_idle_stop": "Idle stop must be between 1 minute and 24 hours, or Never.", "bad_idle_stop": "Idle stop must be between 1 minute and 24 hours, or Never.",
"email_taken": "That email is already verified on another account — sign in with it or use another address.", "email_taken": "That email is already verified on another account — sign in with it or use another address.",
"mail_undeliverable": "The verification email could not be delivered — try again later, or ask the operator to check the mail relay.", "mail_undeliverable": "The verification email could not be delivered — try again later, or ask the operator to check the mail relay.",
"mail_unavailable": "This server can't send email codes because no mail relay is set up. Sign in with a passkey, or ask the server operator to configure email.",
"bad_path": "That path is invalid — use a relative path inside the world directory.", "bad_path": "That path is invalid — use a relative path inside the world directory.",
"too_large": "That is larger than the size limit.", "too_large": "That is larger than the size limit.",
"files_timeout": "The file operation timed out — try again shortly.", "files_timeout": "The file operation timed out — try again shortly.",
@@ -50,6 +50,7 @@
"bad_idle_stop": "空闲停服时长须在 1 分钟到 24 小时之间,或选择“从不”。", "bad_idle_stop": "空闲停服时长须在 1 分钟到 24 小时之间,或选择“从不”。",
"email_taken": "该邮箱已在其他账户上完成验证;请直接用该邮箱登录,或换一个地址。", "email_taken": "该邮箱已在其他账户上完成验证;请直接用该邮箱登录,或换一个地址。",
"mail_undeliverable": "验证码邮件发送失败——请稍后重试,或联系管理员检查邮件服务。", "mail_undeliverable": "验证码邮件发送失败——请稍后重试,或联系管理员检查邮件服务。",
"mail_unavailable": "服务器没有配置邮件服务,发不出验证码。请改用 Passkey 登录,或请管理员配置邮件。",
"bad_path": "路径不合法——请使用世界目录内的相对路径。", "bad_path": "路径不合法——请使用世界目录内的相对路径。",
"too_large": "内容超出大小限制,无法处理。", "too_large": "内容超出大小限制,无法处理。",
"files_timeout": "文件操作超时——请稍后重试。", "files_timeout": "文件操作超时——请稍后重试。",
+7
View File
@@ -402,6 +402,13 @@ describe("api access-control wire shapes", () => {
); );
}); });
it("says email codes are off when the install has no mail relay", async () => {
const { humanizeError } = await import("./api");
expect(humanizeError({ status: 503, code: "mail_unavailable" })).toBe(
"This server can't send email codes because no mail relay is set up. Sign in with a passkey, or ask the server operator to configure email.",
);
});
it("maps the backup rationing codes to their own copy", async () => { it("maps the backup rationing codes to their own copy", async () => {
const { humanizeError } = await import("./api"); const { humanizeError } = await import("./api");
expect(humanizeError({ code: "backup_cooldown" })).toMatch(/cooldown/i); expect(humanizeError({ code: "backup_cooldown" })).toMatch(/cooldown/i);
+3
View File
@@ -941,6 +941,9 @@ export function humanizeError(e: unknown): string {
return t("email_taken"); return t("email_taken");
case "mail_undeliverable": case "mail_undeliverable":
return t("mail_undeliverable"); return t("mail_undeliverable");
// No [smtp] relay at all: every door that mails a code refuses before minting.
case "mail_unavailable":
return t("mail_unavailable");
// File editor (spec §7): path/size refusals from the sandboxed job, plus the // File editor (spec §7): path/size refusals from the sandboxed job, plus the
// subsystem being unwired. // subsystem being unwired.
case "bad_path": case "bad_path":
+16 -2
View File
@@ -2488,6 +2488,15 @@ export interface components {
"application/json": components["schemas"]["Error"]; "application/json": components["schemas"]["Error"];
}; };
}; };
/** @description This install has no [smtp] relay (code mail_unavailable), so no code was minted or sent. The public doors answer it before resolving the address, so it is the same for every address. Sign in with a passkey, or have the operator configure email with felis setup. */
MailUnavailable: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
/** @description This client address called the public sign-in doors faster than the per-address limit allows (code rate_limited); Retry-After gives the seconds until the next call is admitted. The address is the visitor header the install's edge writes ([auth] client_ip_header: CF-Connecting-IP behind the Cloudflare tunnel), else the TCP peer; IPv6 clients share one limit per /64. */ /** @description This client address called the public sign-in doors faster than the per-address limit allows (code rate_limited); Retry-After gives the seconds until the next call is admitted. The address is the visitor header the install's edge writes ([auth] client_ip_header: CF-Connecting-IP behind the Cloudflare tunnel), else the TCP peer; IPv6 clients share one limit per /64. */
RateLimited: { RateLimited: {
headers: { headers: {
@@ -3963,7 +3972,7 @@ export interface operations {
}; };
}; };
responses: { responses: {
/** @description The login methods available for the address, in a deterministic order (passkey before email_otp). An empty array means no verified account. */ /** @description The login methods available for the address, in a deterministic order (passkey before email_otp). email_otp is offered only when the install has a mail relay, passkey only when a verifier is wired and the account has a credential. An empty array means no verified account, or none of its methods is available on this install. */
200: { 200: {
headers: { headers: {
[name: string]: unknown; [name: string]: unknown;
@@ -4364,6 +4373,7 @@ export interface operations {
}; };
}; };
502: components["responses"]["MailUndeliverable"]; 502: components["responses"]["MailUndeliverable"];
503: components["responses"]["MailUnavailable"];
}; };
}; };
loginEmailVerify: { loginEmailVerify: {
@@ -4492,6 +4502,7 @@ export interface operations {
}; };
}; };
502: components["responses"]["MailUndeliverable"]; 502: components["responses"]["MailUndeliverable"];
503: components["responses"]["MailUnavailable"];
}; };
}; };
opLoginStatus: { opLoginStatus: {
@@ -5960,7 +5971,7 @@ export interface operations {
}; };
}; };
responses: { responses: {
/** @description Code minted and dispatched (or logged server-side when no mailer is wired). */ /** @description Code minted and mailed. */
202: { 202: {
headers: { headers: {
[name: string]: unknown; [name: string]: unknown;
@@ -5995,6 +6006,7 @@ export interface operations {
}; };
}; };
502: components["responses"]["MailUndeliverable"]; 502: components["responses"]["MailUndeliverable"];
503: components["responses"]["MailUnavailable"];
}; };
}; };
emailOtpVerify: { emailOtpVerify: {
@@ -6388,6 +6400,7 @@ export interface operations {
}; };
}; };
502: components["responses"]["MailUndeliverable"]; 502: components["responses"]["MailUndeliverable"];
503: components["responses"]["MailUnavailable"];
}; };
}; };
reauthEmailVerify: { reauthEmailVerify: {
@@ -6614,6 +6627,7 @@ export interface operations {
}; };
}; };
502: components["responses"]["MailUndeliverable"]; 502: components["responses"]["MailUndeliverable"];
503: components["responses"]["MailUnavailable"];
}; };
}; };
migrateConfirmOtpVerify: { migrateConfirmOtpVerify: {