release: 0.1.0: Phases 0-11: panel, Postfix/DKIM, milter, backup, Docker image
Initial feature-complete implementation of the v1.0 specification (phases 0-11 of `docs/implementation-plan.md`). Added: - Panel (Go, single static binary) with SQLite persistence, one-time - Domain management with per-domain DKIM (RSA-2048, generated in pure Go) and - Application (sender identity) management: SASL credentials via `sasldb2`, - Full Postfix relay config generated from env at container start: SMTPS 465, - Journal milter (pure Go, `go-milter`) recording every send to `send_log`; … (6 more in CHANGELOG) Full detail: CHANGELOG.md (0.1.0).
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// passwordBytes is the number of random bytes behind a generated application
|
||||
// password. 24 bytes = 192 bits of entropy, well above any brute-force concern
|
||||
// for a SASL credential the panel shows exactly once (spec 7.6.1).
|
||||
const passwordBytes = 24
|
||||
|
||||
// generatePassword returns a strong, URL-safe random password for an
|
||||
// application's SASL account. The panel generates it, shows it once and never
|
||||
// stores the plaintext (spec 7.6.1); sasldb2 keeps only the hashed form.
|
||||
//
|
||||
// base64url output keeps the password to a safe ASCII alphabet with no shell or
|
||||
// SMTP-special characters, so it survives being typed into client configuration
|
||||
// and passed to saslpasswd2 over stdin unchanged.
|
||||
func generatePassword() (string, error) {
|
||||
buf := make([]byte, passwordBytes)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return "", fmt.Errorf("generate password: %w", err)
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(buf), nil
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// SASLDB manages the Cyrus SASL account database (sasldb2) the panel maintains
|
||||
// for application credentials (spec 5.1). The panel is the only writer; Postfix
|
||||
// reads it to authenticate SMTP clients. Accounts are created and removed with
|
||||
// the standard saslpasswd2 tool ("эквивалент saslpasswd2", per the plan).
|
||||
type SASLDB struct {
|
||||
path string // sasldb2 file, under /data so it survives restarts (spec 9)
|
||||
realm string // SASL realm, so lookups match what Postfix's SASL uses
|
||||
|
||||
// run executes saslpasswd2. It is a field so tests can substitute a fake;
|
||||
// the default shells out to the real binary via runSaslpasswd2.
|
||||
run func(args []string, stdin []byte) error
|
||||
|
||||
// dump reads the raw sasldb2 as db_dump key/value pairs (Berkeley DB). It is
|
||||
// a field so tests can substitute a fake; the default runs db_dump.
|
||||
dump func(path string) ([]byte, error)
|
||||
}
|
||||
|
||||
// NewSASLDB builds a manager for the sasldb2 at path with the given realm. The
|
||||
// realm should match SELFPOST_HOSTNAME so the account identity lines up with
|
||||
// Postfix's SASL configuration in Phase 5.
|
||||
func NewSASLDB(path, realm string) *SASLDB {
|
||||
return &SASLDB{path: path, realm: realm, run: runSaslpasswd2, dump: dumpSASLDB}
|
||||
}
|
||||
|
||||
// Set creates or updates an application's SASL account with the given password
|
||||
// (spec 5.1, 7.2.9). Used both at creation and when a password is regenerated;
|
||||
// saslpasswd2 overwrites an existing entry in place.
|
||||
//
|
||||
// The password is passed to saslpasswd2 on stdin (never as an argument, so it
|
||||
// cannot leak through the process table or logs). The login is passed as a
|
||||
// separate argv element after being whitelisted by validateLogin — it never
|
||||
// goes through a shell and is never interpolated into a command string (spec
|
||||
// 7.6.3).
|
||||
func (s *SASLDB) Set(login, password string) error {
|
||||
if err := validateLogin(login); err != nil {
|
||||
return err
|
||||
}
|
||||
// -p: read the passphrase from stdin (pipe mode, no tty prompt).
|
||||
// -c: create the account / set the password.
|
||||
// -f: operate on our sasldb2 rather than the system default path.
|
||||
// -u: the realm the account lives under.
|
||||
args := []string{"-p", "-c", "-f", s.path, "-u", s.realm, login}
|
||||
if err := s.run(args, []byte(password)); err != nil {
|
||||
return fmt.Errorf("saslpasswd2 set %q: %w", login, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete removes an application's SASL account (spec 7.2.8). A missing account
|
||||
// is not treated as an error, so deletion is idempotent and safe to retry.
|
||||
func (s *SASLDB) Delete(login string) error {
|
||||
if err := validateLogin(login); err != nil {
|
||||
return err
|
||||
}
|
||||
// -d: delete the account.
|
||||
args := []string{"-d", "-f", s.path, "-u", s.realm, login}
|
||||
if err := s.run(args, nil); err != nil {
|
||||
return fmt.Errorf("saslpasswd2 delete %q: %w", login, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ErrSecretNotFound is returned by Secret when the sasldb2 has no password entry
|
||||
// for the login under this realm.
|
||||
var ErrSecretNotFound = fmt.Errorf("sasl secret not found")
|
||||
|
||||
// Secret returns an application's stored password so it can be carried in a
|
||||
// domain export and re-created verbatim on another instance (spec 7.5.B). This
|
||||
// is possible because sasldb2 keeps the SASL secret in a password-equivalent
|
||||
// form (the plaintext userPassword property, to serve challenge-response
|
||||
// mechanisms) — unlike the admin's one-way bcrypt hash (spec 7.6). The value is
|
||||
// realm-independent, so the importer can re-key it under its own realm.
|
||||
//
|
||||
// It reads the database with db_dump (Berkeley DB), passing only our own file
|
||||
// path as a fixed argument (no shell, no user input — spec 7.6.3), and returns
|
||||
// ErrSecretNotFound if the login has no entry.
|
||||
func (s *SASLDB) Secret(login string) (string, error) {
|
||||
if err := validateLogin(login); err != nil {
|
||||
return "", err
|
||||
}
|
||||
out, err := s.dump(s.path)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read sasldb2 for %q: %w", login, err)
|
||||
}
|
||||
secret, ok, err := parseSASLSecret(out, login, s.realm)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("parse sasldb2 for %q: %w", login, err)
|
||||
}
|
||||
if !ok {
|
||||
return "", fmt.Errorf("login %q: %w", login, ErrSecretNotFound)
|
||||
}
|
||||
return secret, nil
|
||||
}
|
||||
|
||||
// parseSASLSecret scans db_dump's byte-value output for the userPassword entry
|
||||
// keyed by (login, realm). sasldb2 keys are NUL-separated tuples
|
||||
// "<login>\0<realm>\0<property>"; the matching value is the stored password.
|
||||
func parseSASLSecret(dump []byte, login, realm string) (string, bool, error) {
|
||||
sc := bufio.NewScanner(bytes.NewReader(dump))
|
||||
// sasldb2 records are tiny, but raise the line cap so a long hex line is
|
||||
// never silently truncated.
|
||||
sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
||||
|
||||
inData := false
|
||||
var keyBytes []byte
|
||||
haveKey := false
|
||||
for sc.Scan() {
|
||||
line := sc.Text()
|
||||
if !inData {
|
||||
if line == "HEADER=END" {
|
||||
inData = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
if line == "DATA=END" {
|
||||
break
|
||||
}
|
||||
// Each data line is a single leading space followed by hex.
|
||||
hexStr := strings.TrimPrefix(line, " ")
|
||||
raw, err := hex.DecodeString(hexStr)
|
||||
if err != nil {
|
||||
return "", false, fmt.Errorf("bad db_dump hex line: %w", err)
|
||||
}
|
||||
if !haveKey {
|
||||
keyBytes = raw
|
||||
haveKey = true
|
||||
continue
|
||||
}
|
||||
// raw is the value for keyBytes.
|
||||
haveKey = false
|
||||
parts := bytes.Split(keyBytes, []byte{0})
|
||||
if len(parts) != 3 {
|
||||
continue
|
||||
}
|
||||
if string(parts[0]) == login && string(parts[1]) == realm && string(parts[2]) == "userPassword" {
|
||||
return string(raw), true, nil
|
||||
}
|
||||
}
|
||||
if err := sc.Err(); err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
return "", false, nil
|
||||
}
|
||||
|
||||
// runSaslpasswd2 executes the real saslpasswd2 with the given arguments and
|
||||
// stdin. Arguments are passed as a fixed argv (no shell), so no user input is
|
||||
// ever interpreted as a command (spec 7.6.3).
|
||||
func runSaslpasswd2(args []string, stdin []byte) error {
|
||||
cmd := exec.Command("saslpasswd2", args...)
|
||||
if stdin != nil {
|
||||
cmd.Stdin = bytes.NewReader(stdin)
|
||||
}
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: %s", err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// dumpSASLDB runs db_dump to export the sasldb2 as key/value hex pairs. The path
|
||||
// is our own sasldb2 file (never user input) and is passed as a fixed argument
|
||||
// with no shell (spec 7.6.3).
|
||||
func dumpSASLDB(path string) ([]byte, error) {
|
||||
cmd := exec.Command("db_dump", path)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("db_dump: %w: %s", err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type fakeRun struct {
|
||||
args []string
|
||||
stdin string
|
||||
calls int
|
||||
}
|
||||
|
||||
func newFakeSASL() (*SASLDB, *fakeRun) {
|
||||
fr := &fakeRun{}
|
||||
s := NewSASLDB("/data/sasl/sasldb2", "mail.example.com")
|
||||
s.run = func(args []string, stdin []byte) error {
|
||||
fr.calls++
|
||||
fr.args = args
|
||||
fr.stdin = string(stdin)
|
||||
return nil
|
||||
}
|
||||
return s, fr
|
||||
}
|
||||
|
||||
func TestSASLSetPassesPasswordOnStdinNotArgv(t *testing.T) {
|
||||
s, fr := newFakeSASL()
|
||||
const secret = "s3cr3t-p4ss"
|
||||
if err := s.Set("alerts", secret); err != nil {
|
||||
t.Fatalf("Set: %v", err)
|
||||
}
|
||||
if fr.stdin != secret {
|
||||
t.Errorf("password not passed on stdin: got %q", fr.stdin)
|
||||
}
|
||||
joined := strings.Join(fr.args, " ")
|
||||
if strings.Contains(joined, secret) {
|
||||
t.Errorf("password leaked into argv: %q", joined)
|
||||
}
|
||||
// Expected fixed flags and the login as its own trailing argument.
|
||||
want := []string{"-p", "-c", "-f", "/data/sasl/sasldb2", "-u", "mail.example.com", "alerts"}
|
||||
if len(fr.args) != len(want) {
|
||||
t.Fatalf("args = %v, want %v", fr.args, want)
|
||||
}
|
||||
for i := range want {
|
||||
if fr.args[i] != want[i] {
|
||||
t.Fatalf("args = %v, want %v", fr.args, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSASLDeleteArgs(t *testing.T) {
|
||||
s, fr := newFakeSASL()
|
||||
if err := s.Delete("alerts"); err != nil {
|
||||
t.Fatalf("Delete: %v", err)
|
||||
}
|
||||
want := []string{"-d", "-f", "/data/sasl/sasldb2", "-u", "mail.example.com", "alerts"}
|
||||
if strings.Join(fr.args, " ") != strings.Join(want, " ") {
|
||||
t.Errorf("delete args = %v, want %v", fr.args, want)
|
||||
}
|
||||
if fr.stdin != "" {
|
||||
t.Errorf("delete should not send stdin, got %q", fr.stdin)
|
||||
}
|
||||
}
|
||||
|
||||
// makeDump builds a db_dump byte-value document from key/value byte pairs, the
|
||||
// same shape `db_dump <sasldb2>` emits.
|
||||
func makeDump(pairs [][2][]byte) []byte {
|
||||
var b strings.Builder
|
||||
b.WriteString("VERSION=3\nformat=bytevalue\ntype=hash\nHEADER=END\n")
|
||||
for _, p := range pairs {
|
||||
fmt.Fprintf(&b, " %s\n", hex.EncodeToString(p[0]))
|
||||
fmt.Fprintf(&b, " %s\n", hex.EncodeToString(p[1]))
|
||||
}
|
||||
b.WriteString("DATA=END\n")
|
||||
return []byte(b.String())
|
||||
}
|
||||
|
||||
func saslKey(login, realm, prop string) []byte {
|
||||
return []byte(login + "\x00" + realm + "\x00" + prop)
|
||||
}
|
||||
|
||||
func TestSecretExtractsPassword(t *testing.T) {
|
||||
s := NewSASLDB("/data/sasl/sasldb2", "mail.example.com")
|
||||
s.dump = func(path string) ([]byte, error) {
|
||||
if path != "/data/sasl/sasldb2" {
|
||||
t.Errorf("dump path = %q", path)
|
||||
}
|
||||
return makeDump([][2][]byte{
|
||||
{saslKey("other", "mail.example.com", "userPassword"), []byte("otherpw")},
|
||||
{saslKey("alerts", "mail.example.com", "userPassword"), []byte("hunter2-pass")},
|
||||
}), nil
|
||||
}
|
||||
got, err := s.Secret("alerts")
|
||||
if err != nil {
|
||||
t.Fatalf("Secret: %v", err)
|
||||
}
|
||||
if got != "hunter2-pass" {
|
||||
t.Errorf("Secret = %q, want %q", got, "hunter2-pass")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecretRealmMismatchNotFound(t *testing.T) {
|
||||
s := NewSASLDB("/data/sasl/sasldb2", "mail.example.com")
|
||||
s.dump = func(string) ([]byte, error) {
|
||||
// Same login but a different realm must not match.
|
||||
return makeDump([][2][]byte{
|
||||
{saslKey("alerts", "other.host", "userPassword"), []byte("hunter2")},
|
||||
}), nil
|
||||
}
|
||||
if _, err := s.Secret("alerts"); !errors.Is(err, ErrSecretNotFound) {
|
||||
t.Errorf("Secret err = %v, want ErrSecretNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecretMissingLoginNotFound(t *testing.T) {
|
||||
s := NewSASLDB("/data/sasl/sasldb2", "mail.example.com")
|
||||
s.dump = func(string) ([]byte, error) {
|
||||
return makeDump(nil), nil
|
||||
}
|
||||
if _, err := s.Secret("ghost"); !errors.Is(err, ErrSecretNotFound) {
|
||||
t.Errorf("Secret err = %v, want ErrSecretNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecretRejectsInvalidLoginBeforeDump(t *testing.T) {
|
||||
s := NewSASLDB("/data/sasl/sasldb2", "mail.example.com")
|
||||
called := false
|
||||
s.dump = func(string) ([]byte, error) { called = true; return nil, nil }
|
||||
if _, err := s.Secret("bad login"); err == nil {
|
||||
t.Error("Secret accepted invalid login")
|
||||
}
|
||||
if called {
|
||||
t.Error("db_dump invoked for an invalid login")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSASLRejectsInvalidLoginBeforeExec(t *testing.T) {
|
||||
s, fr := newFakeSASL()
|
||||
if err := s.Set("bad login", "pw"); err == nil {
|
||||
t.Error("Set accepted invalid login")
|
||||
}
|
||||
if err := s.Delete("bad@login"); err == nil {
|
||||
t.Error("Delete accepted invalid login")
|
||||
}
|
||||
if fr.calls != 0 {
|
||||
t.Errorf("saslpasswd2 invoked %d times for invalid logins, want 0", fr.calls)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
// Package app owns application accounts (spec 4.1, 5.1): the SASL credentials in
|
||||
// sasldb2, the per-application sender address mode, and the
|
||||
// smtpd_sender_login_maps bindings that tie each login to the addresses it may
|
||||
// send from. It keeps those three stores — the SQLite registry, sasldb2 and the
|
||||
// Postfix map — in agreement and drives the Postfix reload.
|
||||
package app
|
||||
|
||||
import (
|
||||
"codeberg.org/mix/selfpost/internal/postfix"
|
||||
"codeberg.org/mix/selfpost/internal/store"
|
||||
)
|
||||
|
||||
// SenderMaps is the slice of the Postfix manager the application service needs:
|
||||
// rebuilding the sender_login_maps from the current bindings and reloading.
|
||||
// *postfix.Postfix satisfies it; tests substitute a fake.
|
||||
type SenderMaps interface {
|
||||
RebuildSenderLoginMaps(bindings []postfix.Binding) error
|
||||
}
|
||||
|
||||
// Service coordinates application state across SQLite, sasldb2 and the Postfix
|
||||
// sender_login_maps. Web handlers validate raw input first; the Service performs
|
||||
// the domain-ownership checks that must not be skipped (spec 7.6.2) and keeps
|
||||
// the stores consistent.
|
||||
type Service struct {
|
||||
store *store.Store
|
||||
sasl *SASLDB
|
||||
pf SenderMaps
|
||||
}
|
||||
|
||||
// NewService builds the application service over the shared store, the sasldb2
|
||||
// manager and the Postfix manager.
|
||||
func NewService(st *store.Store, sasl *SASLDB, pf SenderMaps) *Service {
|
||||
return &Service{store: st, sasl: sasl, pf: pf}
|
||||
}
|
||||
|
||||
// List returns a domain's applications with their address lists (spec 7.2.6).
|
||||
func (s *Service) List(domainID int64) ([]store.Application, error) {
|
||||
return s.store.ListApplicationsByDomain(domainID)
|
||||
}
|
||||
|
||||
// Get returns one application by id (store.ErrApplicationNotFound if absent).
|
||||
func (s *Service) Get(id int64) (store.Application, error) {
|
||||
return s.store.GetApplication(id)
|
||||
}
|
||||
|
||||
// Create adds an application to a domain: it validates the login and (in list
|
||||
// mode) that every address belongs to the domain (spec 7.6.2), generates a
|
||||
// strong password, writes the SASL account and rebuilds the sender map (spec
|
||||
// 7.2.5). The generated password is returned so the caller can show it exactly
|
||||
// once (spec 7.6.1) — it is never persisted in plaintext.
|
||||
//
|
||||
// The registry row is written first so its UNIQUE constraint is the sole arbiter
|
||||
// of a duplicate login (avoiding a check-then-act race and, crucially, avoiding
|
||||
// clobbering an existing account's password in sasldb2). If the SASL write or
|
||||
// the map rebuild fails, everything is rolled back so we never leave an
|
||||
// application the panel cannot fully account for.
|
||||
func (s *Service) Create(domainID int64, login, mode string, rawAddresses []string) (store.Application, string, error) {
|
||||
addresses, err := s.validateForDomain(domainID, login, mode, rawAddresses)
|
||||
if err != nil {
|
||||
return store.Application{}, "", err
|
||||
}
|
||||
|
||||
password, err := generatePassword()
|
||||
if err != nil {
|
||||
return store.Application{}, "", err
|
||||
}
|
||||
|
||||
a, err := s.store.AddApplication(domainID, login, mode, addresses)
|
||||
if err != nil {
|
||||
return store.Application{}, "", err
|
||||
}
|
||||
|
||||
if err := s.sasl.Set(login, password); err != nil {
|
||||
s.rollbackCreate(a.ID, "") // login has no SASL account yet; nothing to unset
|
||||
return store.Application{}, "", err
|
||||
}
|
||||
if err := s.Resync(); err != nil {
|
||||
s.rollbackCreate(a.ID, login)
|
||||
return store.Application{}, "", err
|
||||
}
|
||||
return a, password, nil
|
||||
}
|
||||
|
||||
// rollbackCreate best-effort undoes a partially created application after a
|
||||
// downstream failure: it removes the SASL account (if one was written) and the
|
||||
// registry row. Errors here are subordinate to the original failure the caller
|
||||
// returns.
|
||||
func (s *Service) rollbackCreate(id int64, login string) {
|
||||
if login != "" {
|
||||
_ = s.sasl.Delete(login)
|
||||
}
|
||||
_, _ = s.store.DeleteApplication(id)
|
||||
}
|
||||
|
||||
// ImportApplication re-creates an application from a domain-export file (spec
|
||||
// 7.5.B): it validates the login and (in list mode) that every address belongs
|
||||
// to the domain, inserts the registry row and writes the SASL account with the
|
||||
// imported password verbatim, re-keyed under this instance's realm so the
|
||||
// credential keeps working without regeneration. It deliberately does not
|
||||
// rebuild the sender map — the caller (domain import) does that once after all
|
||||
// applications are in — and returns store.ErrLoginExists if the login collides.
|
||||
func (s *Service) ImportApplication(domainID int64, login, mode string, rawAddresses []string, password string) error {
|
||||
addresses, err := s.validateForDomain(domainID, login, mode, rawAddresses)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateImportedPassword(password); err != nil {
|
||||
return err
|
||||
}
|
||||
a, err := s.store.AddApplication(domainID, login, mode, addresses)
|
||||
if err != nil {
|
||||
return err // ErrLoginExists surfaces to the caller as a friendly message
|
||||
}
|
||||
if err := s.sasl.Set(login, password); err != nil {
|
||||
_, _ = s.store.DeleteApplication(a.ID)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Secret returns an application's stored password for a domain export (spec
|
||||
// 7.5.B). See SASLDB.Secret for why this is possible and safe.
|
||||
func (s *Service) Secret(login string) (string, error) {
|
||||
return s.sasl.Secret(login)
|
||||
}
|
||||
|
||||
// UpdateMode switches an application's address mode / list and rebuilds the
|
||||
// sender map (spec 7.2.7). The login and password are untouched. Addresses are
|
||||
// re-validated against the application's domain.
|
||||
func (s *Service) UpdateMode(id int64, mode string, rawAddresses []string) error {
|
||||
a, err := s.store.GetApplication(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
addresses, err := s.validateForDomain(a.DomainID, a.Login, mode, rawAddresses)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.store.UpdateApplicationMode(id, mode, addresses); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.Resync()
|
||||
}
|
||||
|
||||
// RegeneratePassword issues a fresh password for an existing application (spec
|
||||
// 7.2.9). The old password is invalidated by overwriting the SASL account; the
|
||||
// address mode and bindings are unchanged, so no map rebuild is needed. The new
|
||||
// password is returned to be shown once.
|
||||
func (s *Service) RegeneratePassword(id int64) (string, error) {
|
||||
a, err := s.store.GetApplication(id)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
password, err := generatePassword()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := s.sasl.Set(a.Login, password); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return password, nil
|
||||
}
|
||||
|
||||
// Delete removes an application: its SASL account, its registry row (and address
|
||||
// rows via cascade) and its sender-map bindings, then reloads Postfix (spec
|
||||
// 7.2.8). The domain and other applications are untouched.
|
||||
func (s *Service) Delete(id int64) error {
|
||||
a, err := s.store.DeleteApplication(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.sasl.Delete(a.Login); err != nil {
|
||||
return err
|
||||
}
|
||||
// Drop the application's level-2 limit, if any (spec 7.4); rate_limits has no
|
||||
// cascade of its own.
|
||||
if err := s.store.DeleteRateLimit(store.RateLimitScopeApp, id); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.Resync()
|
||||
}
|
||||
|
||||
// RateLimit returns the application-level differentiated rate limit (spec 7.4),
|
||||
// and whether one is configured, for the application's edit form.
|
||||
func (s *Service) RateLimit(appID int64) (store.RateLimit, bool, error) {
|
||||
return s.store.GetRateLimit(store.RateLimitScopeApp, appID)
|
||||
}
|
||||
|
||||
// SaveRateLimit stores the application-level rate limit. The caller has validated
|
||||
// the IPs and numbers (spec 7.6.2); the milter reads the row live, so no reload
|
||||
// is needed.
|
||||
func (s *Service) SaveRateLimit(appID int64, ips []string, maxMessages, windowSeconds int) error {
|
||||
return s.store.SetRateLimit(store.RateLimit{
|
||||
Scope: store.RateLimitScopeApp,
|
||||
RefID: appID,
|
||||
AllowedIPs: ips,
|
||||
MaxMessages: maxMessages,
|
||||
WindowSeconds: windowSeconds,
|
||||
})
|
||||
}
|
||||
|
||||
// ClearRateLimit removes the application-level rate limit (spec 7.4).
|
||||
func (s *Service) ClearRateLimit(appID int64) error {
|
||||
return s.store.DeleteRateLimit(store.RateLimitScopeApp, appID)
|
||||
}
|
||||
|
||||
// PurgeDomainSASL removes the SASL accounts of every application bound to a
|
||||
// domain. It must be called before the domain's registry rows are cascade-
|
||||
// deleted, while the logins are still known (spec 7.2.4). The registry rows and
|
||||
// the sender map are handled by the domain deletion path; this only clears
|
||||
// sasldb2, which has no cascade of its own.
|
||||
func (s *Service) PurgeDomainSASL(domainID int64) error {
|
||||
logins, err := s.store.ListLoginsByDomain(domainID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, login := range logins {
|
||||
if err := s.sasl.Delete(login); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Resync rebuilds smtpd_sender_login_maps from the full set of application
|
||||
// bindings and reloads Postfix (spec 5.1). It is the single idempotent apply
|
||||
// path shared by create/edit/delete and is also reachable from the manual
|
||||
// reload button; it doubles as recovery if the map ever drifts from the
|
||||
// database.
|
||||
func (s *Service) Resync() error {
|
||||
bindings, err := s.store.ListBindings()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pfBindings := make([]postfix.Binding, 0, len(bindings))
|
||||
for _, b := range bindings {
|
||||
pfBindings = append(pfBindings, postfix.Binding{Address: b.Address, Login: b.Login})
|
||||
}
|
||||
return s.pf.RebuildSenderLoginMaps(pfBindings)
|
||||
}
|
||||
|
||||
// validateForDomain resolves the domain, validates the login and address mode,
|
||||
// and — in list mode — validates that every address belongs to the domain
|
||||
// (spec 7.6.2). It returns the cleaned address list, which is empty in wildcard
|
||||
// mode. Resolving the domain here also confirms it exists before any write.
|
||||
func (s *Service) validateForDomain(domainID int64, login, mode string, rawAddresses []string) ([]string, error) {
|
||||
d, err := s.store.GetDomain(domainID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateLogin(login); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateAddressMode(mode); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if mode == store.AddressModeWildcard {
|
||||
return nil, nil
|
||||
}
|
||||
return parseAddresses(rawAddresses, d.Name)
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"codeberg.org/mix/selfpost/internal/postfix"
|
||||
"codeberg.org/mix/selfpost/internal/store"
|
||||
)
|
||||
|
||||
// fakeMaps records the last set of bindings passed to a rebuild and can be told
|
||||
// to fail, so we can exercise the rollback paths.
|
||||
type fakeMaps struct {
|
||||
last []postfix.Binding
|
||||
calls int
|
||||
failNext bool
|
||||
}
|
||||
|
||||
func (f *fakeMaps) RebuildSenderLoginMaps(b []postfix.Binding) error {
|
||||
f.calls++
|
||||
if f.failNext {
|
||||
f.failNext = false
|
||||
return errors.New("boom")
|
||||
}
|
||||
f.last = b
|
||||
return nil
|
||||
}
|
||||
|
||||
// saslRecorder is a fake sasldb2 backend recording set/delete calls.
|
||||
type saslRecorder struct {
|
||||
set map[string]string // login -> password
|
||||
deleted []string
|
||||
failNext bool
|
||||
}
|
||||
|
||||
func newServiceHarness(t *testing.T) (*Service, *store.Store, *saslRecorder, *fakeMaps) {
|
||||
t.Helper()
|
||||
st, err := store.Open(filepath.Join(t.TempDir(), "test.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open store: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { st.Close() })
|
||||
|
||||
rec := &saslRecorder{set: map[string]string{}}
|
||||
sasl := NewSASLDB("/data/sasl/sasldb2", "mail.example.com")
|
||||
sasl.run = func(args []string, stdin []byte) error {
|
||||
if rec.failNext {
|
||||
rec.failNext = false
|
||||
return errors.New("saslpasswd2 failed")
|
||||
}
|
||||
// args end with the login; a "-d" anywhere means delete.
|
||||
login := args[len(args)-1]
|
||||
del := false
|
||||
for _, a := range args {
|
||||
if a == "-d" {
|
||||
del = true
|
||||
}
|
||||
}
|
||||
if del {
|
||||
rec.deleted = append(rec.deleted, login)
|
||||
delete(rec.set, login)
|
||||
} else {
|
||||
rec.set[login] = string(stdin)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
maps := &fakeMaps{}
|
||||
return NewService(st, sasl, maps), st, rec, maps
|
||||
}
|
||||
|
||||
func addDomain(t *testing.T, st *store.Store, name string) store.Domain {
|
||||
t.Helper()
|
||||
d, err := st.AddDomain(name, "selfpost")
|
||||
if err != nil {
|
||||
t.Fatalf("AddDomain: %v", err)
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
func TestServiceCreateWildcard(t *testing.T) {
|
||||
svc, st, rec, maps := newServiceHarness(t)
|
||||
d := addDomain(t, st, "example.com")
|
||||
|
||||
a, pw, err := svc.Create(d.ID, "alerts", store.AddressModeWildcard, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
if a.Login != "alerts" {
|
||||
t.Errorf("login = %q", a.Login)
|
||||
}
|
||||
if rec.set["alerts"] != pw {
|
||||
t.Errorf("sasl password %q != returned %q", rec.set["alerts"], pw)
|
||||
}
|
||||
if len(maps.last) != 1 || maps.last[0].Address != "@example.com" || maps.last[0].Login != "alerts" {
|
||||
t.Errorf("map bindings = %+v", maps.last)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceCreateListValidatesDomain(t *testing.T) {
|
||||
svc, st, _, _ := newServiceHarness(t)
|
||||
d := addDomain(t, st, "example.com")
|
||||
|
||||
// A cross-domain address is rejected before anything is written.
|
||||
_, _, err := svc.Create(d.ID, "app1", store.AddressModeList, []string{"a@evil.com"})
|
||||
if err == nil {
|
||||
t.Fatal("Create accepted cross-domain address")
|
||||
}
|
||||
apps, _ := st.ListApplicationsByDomain(d.ID)
|
||||
if len(apps) != 0 {
|
||||
t.Errorf("application persisted despite validation failure: %+v", apps)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceCreateDuplicateLogin(t *testing.T) {
|
||||
svc, st, _, _ := newServiceHarness(t)
|
||||
d := addDomain(t, st, "example.com")
|
||||
if _, _, err := svc.Create(d.ID, "dup", store.AddressModeWildcard, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, _, err := svc.Create(d.ID, "dup", store.AddressModeWildcard, nil)
|
||||
if !errors.Is(err, store.ErrLoginExists) {
|
||||
t.Fatalf("duplicate create = %v, want ErrLoginExists", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceCreateRollsBackOnSASLFailure(t *testing.T) {
|
||||
svc, st, rec, _ := newServiceHarness(t)
|
||||
d := addDomain(t, st, "example.com")
|
||||
|
||||
rec.failNext = true // saslpasswd2 fails on the first (set) call
|
||||
_, _, err := svc.Create(d.ID, "app1", store.AddressModeWildcard, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected Create to fail when SASL set fails")
|
||||
}
|
||||
apps, _ := st.ListApplicationsByDomain(d.ID)
|
||||
if len(apps) != 0 {
|
||||
t.Errorf("registry row not rolled back: %+v", apps)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceCreateRollsBackOnMapFailure(t *testing.T) {
|
||||
svc, st, rec, maps := newServiceHarness(t)
|
||||
d := addDomain(t, st, "example.com")
|
||||
|
||||
maps.failNext = true
|
||||
_, _, err := svc.Create(d.ID, "app1", store.AddressModeWildcard, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected Create to fail when map rebuild fails")
|
||||
}
|
||||
apps, _ := st.ListApplicationsByDomain(d.ID)
|
||||
if len(apps) != 0 {
|
||||
t.Errorf("registry row not rolled back: %+v", apps)
|
||||
}
|
||||
if _, ok := rec.set["app1"]; ok {
|
||||
t.Errorf("SASL account not rolled back")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceDelete(t *testing.T) {
|
||||
svc, st, rec, _ := newServiceHarness(t)
|
||||
d := addDomain(t, st, "example.com")
|
||||
a, _, err := svc.Create(d.ID, "app1", store.AddressModeWildcard, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := svc.Delete(a.ID); err != nil {
|
||||
t.Fatalf("Delete: %v", err)
|
||||
}
|
||||
if _, ok := rec.set["app1"]; ok {
|
||||
t.Error("SASL account not deleted")
|
||||
}
|
||||
if len(rec.deleted) != 1 || rec.deleted[0] != "app1" {
|
||||
t.Errorf("deleted logins = %v", rec.deleted)
|
||||
}
|
||||
apps, _ := st.ListApplicationsByDomain(d.ID)
|
||||
if len(apps) != 0 {
|
||||
t.Errorf("application not deleted: %+v", apps)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceUpdateMode(t *testing.T) {
|
||||
svc, st, _, maps := newServiceHarness(t)
|
||||
d := addDomain(t, st, "example.com")
|
||||
a, _, err := svc.Create(d.ID, "app1", store.AddressModeWildcard, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := svc.UpdateMode(a.ID, store.AddressModeList, []string{"alerts@example.com"}); err != nil {
|
||||
t.Fatalf("UpdateMode: %v", err)
|
||||
}
|
||||
if len(maps.last) != 1 || maps.last[0].Address != "alerts@example.com" {
|
||||
t.Errorf("map after mode switch = %+v", maps.last)
|
||||
}
|
||||
got, _ := st.GetApplication(a.ID)
|
||||
if got.AddressMode != store.AddressModeList || len(got.Addresses) != 1 {
|
||||
t.Errorf("stored app after switch = %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceRegeneratePassword(t *testing.T) {
|
||||
svc, st, rec, _ := newServiceHarness(t)
|
||||
d := addDomain(t, st, "example.com")
|
||||
a, pw1, err := svc.Create(d.ID, "app1", store.AddressModeWildcard, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
pw2, err := svc.RegeneratePassword(a.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("RegeneratePassword: %v", err)
|
||||
}
|
||||
if pw1 == pw2 {
|
||||
t.Error("regenerated password equals the old one")
|
||||
}
|
||||
if rec.set["app1"] != pw2 {
|
||||
t.Errorf("sasl password not updated to new value")
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportApplicationWritesRowAndSASL(t *testing.T) {
|
||||
svc, st, rec, maps := newServiceHarness(t)
|
||||
d := addDomain(t, st, "example.com")
|
||||
|
||||
if err := svc.ImportApplication(d.ID, "mailer", store.AddressModeList,
|
||||
[]string{"a@example.com"}, "imported-pw"); err != nil {
|
||||
t.Fatalf("ImportApplication: %v", err)
|
||||
}
|
||||
// Registry row and SASL account written with the imported password verbatim.
|
||||
apps, _ := st.ListApplicationsByDomain(d.ID)
|
||||
if len(apps) != 1 || apps[0].Login != "mailer" {
|
||||
t.Fatalf("apps = %+v", apps)
|
||||
}
|
||||
if rec.set["mailer"] != "imported-pw" {
|
||||
t.Errorf("SASL password = %q, want the imported one", rec.set["mailer"])
|
||||
}
|
||||
// Import does not rebuild the sender map itself (the caller batches that).
|
||||
if maps.calls != 0 {
|
||||
t.Errorf("ImportApplication rebuilt the map %d times, want 0", maps.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportApplicationRejectsBadInput(t *testing.T) {
|
||||
svc, st, rec, _ := newServiceHarness(t)
|
||||
d := addDomain(t, st, "example.com")
|
||||
|
||||
// Empty password.
|
||||
if err := svc.ImportApplication(d.ID, "mailer", store.AddressModeWildcard, nil, ""); err == nil {
|
||||
t.Error("accepted empty imported password")
|
||||
}
|
||||
// Password with an embedded newline would truncate on the saslpasswd2 stdin.
|
||||
if err := svc.ImportApplication(d.ID, "mailer", store.AddressModeWildcard, nil, "line1\nline2"); err == nil {
|
||||
t.Error("accepted password with control characters")
|
||||
}
|
||||
// Cross-domain address.
|
||||
if err := svc.ImportApplication(d.ID, "mailer", store.AddressModeList, []string{"x@evil.com"}, "pw"); err == nil {
|
||||
t.Error("accepted cross-domain address")
|
||||
}
|
||||
if apps, _ := st.ListApplicationsByDomain(d.ID); len(apps) != 0 {
|
||||
t.Errorf("rows persisted despite validation failure: %+v", apps)
|
||||
}
|
||||
if len(rec.set) != 0 {
|
||||
t.Errorf("SASL accounts written despite validation failure: %v", rec.set)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServicePurgeDomainSASL(t *testing.T) {
|
||||
svc, st, rec, _ := newServiceHarness(t)
|
||||
d := addDomain(t, st, "example.com")
|
||||
if _, _, err := svc.Create(d.ID, "app-a", store.AddressModeWildcard, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := svc.Create(d.ID, "app-b", store.AddressModeWildcard, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := svc.PurgeDomainSASL(d.ID); err != nil {
|
||||
t.Fatalf("PurgeDomainSASL: %v", err)
|
||||
}
|
||||
if len(rec.set) != 0 {
|
||||
t.Errorf("SASL accounts remain after purge: %v", rec.set)
|
||||
}
|
||||
if len(rec.deleted) != 2 {
|
||||
t.Errorf("deleted %d logins, want 2", len(rec.deleted))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"codeberg.org/mix/selfpost/internal/store"
|
||||
)
|
||||
|
||||
const (
|
||||
minLoginLen = 3
|
||||
maxLoginLen = 64
|
||||
)
|
||||
|
||||
// validateLogin enforces a strict server-side whitelist for the SASL login
|
||||
// (spec 7.6.2). It intentionally excludes '@': the login is stored in sasldb2,
|
||||
// where '@' separates the user from the realm, so allowing it would change the
|
||||
// account's identity. Client validation is never trusted.
|
||||
//
|
||||
// The login is the one piece of user input that is passed to saslpasswd2 as a
|
||||
// command argument (never through a shell, spec 7.6.3); this whitelist is what
|
||||
// makes that safe.
|
||||
func validateLogin(login string) error {
|
||||
if len(login) < minLoginLen || len(login) > maxLoginLen {
|
||||
return fmt.Errorf("login must be %d-%d characters", minLoginLen, maxLoginLen)
|
||||
}
|
||||
for _, r := range login {
|
||||
lower := r >= 'a' && r <= 'z'
|
||||
upper := r >= 'A' && r <= 'Z'
|
||||
digit := r >= '0' && r <= '9'
|
||||
if !lower && !upper && !digit && r != '.' && r != '-' && r != '_' {
|
||||
return fmt.Errorf("login may contain only letters, digits, '.', '-' and '_'")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateImportedPassword guards a password taken from a domain-export file
|
||||
// (spec 7.5.B) before it is written to sasldb2. Our own exports carry base64url
|
||||
// passwords, but the file is untrusted input, so we reject an empty value or one
|
||||
// containing control characters — saslpasswd2 reads the passphrase from stdin
|
||||
// and a newline would silently truncate it (spec 7.6.2).
|
||||
func validateImportedPassword(password string) error {
|
||||
if password == "" {
|
||||
return fmt.Errorf("imported application password is empty")
|
||||
}
|
||||
if len(password) > 1024 {
|
||||
return fmt.Errorf("imported application password is too long")
|
||||
}
|
||||
for _, r := range password {
|
||||
if r < 0x20 || r == 0x7f {
|
||||
return fmt.Errorf("imported application password contains control characters")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateAddressMode checks the submitted mode is one of the two known values.
|
||||
func validateAddressMode(mode string) error {
|
||||
if mode != store.AddressModeWildcard && mode != store.AddressModeList {
|
||||
return fmt.Errorf("invalid address mode")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// normalizeAddress lower-cases and trims a sender address. Both the local part
|
||||
// and domain are treated case-insensitively for the ownership check and for the
|
||||
// generated map, matching how addresses are compared elsewhere.
|
||||
func normalizeAddress(addr string) string {
|
||||
return strings.ToLower(strings.TrimSpace(addr))
|
||||
}
|
||||
|
||||
// validateSenderAddress enforces that a list-mode address is well-formed and,
|
||||
// critically, belongs to the application's own domain (spec 7.6.2). The domain
|
||||
// check is done here, before anything is written to a config file — not left to
|
||||
// smtpd_sender_login_maps to catch at delivery time. domain must already be a
|
||||
// validated, normalised domain name.
|
||||
func validateSenderAddress(addr, domain string) error {
|
||||
at := strings.LastIndexByte(addr, '@')
|
||||
if at < 0 {
|
||||
return fmt.Errorf("%q is not a valid email address", addr)
|
||||
}
|
||||
local, host := addr[:at], addr[at+1:]
|
||||
if host != domain {
|
||||
return fmt.Errorf("%q does not belong to domain %s", addr, domain)
|
||||
}
|
||||
if err := validateLocalPart(local); err != nil {
|
||||
return fmt.Errorf("%q: %w", addr, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateLocalPart applies a conservative whitelist to the part before '@'.
|
||||
// This is deliberately stricter than RFC 5321 (no quoted local parts) so the
|
||||
// value is always safe to write verbatim into the Postfix map (spec 7.6.4).
|
||||
func validateLocalPart(local string) error {
|
||||
if local == "" {
|
||||
return fmt.Errorf("missing the part before '@'")
|
||||
}
|
||||
if local[0] == '.' || local[len(local)-1] == '.' {
|
||||
return fmt.Errorf("local part must not start or end with '.'")
|
||||
}
|
||||
for i := 0; i < len(local); i++ {
|
||||
c := local[i]
|
||||
lower := c >= 'a' && c <= 'z'
|
||||
digit := c >= '0' && c <= '9'
|
||||
if !lower && !digit && c != '.' && c != '-' && c != '_' && c != '+' {
|
||||
return fmt.Errorf("local part may contain only lower-case letters, digits, '.', '-', '_' and '+'")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseAddresses normalises, validates and de-duplicates a list of submitted
|
||||
// sender addresses for a list-mode application. It requires at least one address
|
||||
// and that each belongs to domain. The returned slice is de-duplicated but keeps
|
||||
// submission order stable for display; the store sorts on read.
|
||||
func parseAddresses(raw []string, domain string) ([]string, error) {
|
||||
seen := make(map[string]bool)
|
||||
out := make([]string, 0, len(raw))
|
||||
for _, r := range raw {
|
||||
addr := normalizeAddress(r)
|
||||
if addr == "" {
|
||||
continue
|
||||
}
|
||||
if err := validateSenderAddress(addr, domain); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if seen[addr] {
|
||||
continue
|
||||
}
|
||||
seen[addr] = true
|
||||
out = append(out, addr)
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil, fmt.Errorf("list mode requires at least one address")
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package app
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestValidateLogin(t *testing.T) {
|
||||
good := []string{"alerts", "prod-server", "app_1", "News.Letter"}
|
||||
for _, l := range good {
|
||||
if err := validateLogin(l); err != nil {
|
||||
t.Errorf("validateLogin(%q) = %v, want nil", l, err)
|
||||
}
|
||||
}
|
||||
bad := []string{
|
||||
"ab", // too short
|
||||
"alerts@example.com", // '@' not allowed (sasldb realm separator)
|
||||
"has space", // whitespace
|
||||
"inject\nline", // newline
|
||||
"comma,login", // map value separator
|
||||
"colon:login", // config separator
|
||||
}
|
||||
for _, l := range bad {
|
||||
if err := validateLogin(l); err == nil {
|
||||
t.Errorf("validateLogin(%q) = nil, want error", l)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateSenderAddressDomainOwnership(t *testing.T) {
|
||||
// The critical check (spec 7.6.2): an address must belong to the app's domain.
|
||||
if err := validateSenderAddress("alerts@example.com", "example.com"); err != nil {
|
||||
t.Errorf("same-domain address rejected: %v", err)
|
||||
}
|
||||
if err := validateSenderAddress("alerts@evil.com", "example.com"); err == nil {
|
||||
t.Error("cross-domain address accepted, want rejection")
|
||||
}
|
||||
// A trailing-domain trick must not pass as ownership.
|
||||
if err := validateSenderAddress("a@notexample.com", "example.com"); err == nil {
|
||||
t.Error("suffix-domain address accepted, want rejection")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateSenderAddressForm(t *testing.T) {
|
||||
bad := []string{
|
||||
"noat.example.com", // no '@'
|
||||
"@example.com", // empty local part
|
||||
".dot@example.com", // leading dot
|
||||
"dot.@example.com", // trailing dot
|
||||
"in ject@example.com", // space
|
||||
"quote\"@example.com", // disallowed char
|
||||
}
|
||||
for _, a := range bad {
|
||||
if err := validateSenderAddress(a, "example.com"); err == nil {
|
||||
t.Errorf("validateSenderAddress(%q) = nil, want error", a)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAddresses(t *testing.T) {
|
||||
// Normalises case, trims, drops blanks, de-duplicates.
|
||||
got, err := parseAddresses([]string{" Alerts@Example.com ", "", "noreply@example.com", "alerts@example.com"}, "example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("parseAddresses: %v", err)
|
||||
}
|
||||
if len(got) != 2 || got[0] != "alerts@example.com" || got[1] != "noreply@example.com" {
|
||||
t.Fatalf("parseAddresses = %v", got)
|
||||
}
|
||||
|
||||
// Empty list in list mode is an error.
|
||||
if _, err := parseAddresses([]string{"", " "}, "example.com"); err == nil {
|
||||
t.Error("empty address list accepted, want error")
|
||||
}
|
||||
// A cross-domain address rejects the whole submission.
|
||||
if _, err := parseAddresses([]string{"ok@example.com", "bad@other.com"}, "example.com"); err == nil {
|
||||
t.Error("cross-domain address in list accepted, want error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeneratePasswordStrength(t *testing.T) {
|
||||
seen := make(map[string]bool)
|
||||
for i := 0; i < 50; i++ {
|
||||
p, err := generatePassword()
|
||||
if err != nil {
|
||||
t.Fatalf("generatePassword: %v", err)
|
||||
}
|
||||
if len(p) < 30 {
|
||||
t.Fatalf("password too short: %d chars", len(p))
|
||||
}
|
||||
if seen[p] {
|
||||
t.Fatalf("duplicate password generated: %q", p)
|
||||
}
|
||||
seen[p] = true
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user