import: bounds on a domain export (audit W4, W5, W8)

Security audit 2026-10-17, findings W4/W5/W8 (low), stage 4 - all
global-only and self-inflicted, but unbounded:
- W4: ImportKey took any RSA key; a 16 384-bit key makes every signature
  of the domain cost seconds. Now 1024-4096 bits (MinImportKeyBits,
  MaxImportKeyBits); under 1024 Go's parser already refuses.
- W5: an export's applications were unbounded, each a copy of sasldb2 and
  a saslpasswd2 run on the panel's one connection. Now at most 500
  (maxImportApplications).
- W8: an import named as the hostname silently replaced the server's own
  signing key while DNS still held the old record. Now refused with a
  sentence.

Tests: TestImportKeyRefusesAnOversizedKey (4104 bits); the import cases.
Docs: guide § Moving a domain; CHANGELOG; plan.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mix
2026-10-17 22:27:21 +03:00
co-authored by Claude Opus 5.5
parent 668e299c69
commit bde6a4000e
7 changed files with 66 additions and 3 deletions
+15
View File
@@ -440,6 +440,9 @@ func importErrorMessage(err error) (int, string) {
// 2026-10-12, M10). Client addresses are written back as the form would store
// them. The login, the addresses an application may send as and the password
// are checked where the application is created (app.ImportApplication).
// maxImportApplications is how many applications a domain import takes.
const maxImportApplications = 500
func (h *Handlers) checkDomainImport(exp *domain.DomainExport) string {
invalid := func(what string, err error) string {
return "Invalid " + what + " in export file: " + err.Error()
@@ -447,6 +450,18 @@ func (h *Handlers) checkDomainImport(exp *domain.DomainExport) string {
if err := validate.Selector(exp.DKIMSelector); err != nil {
return invalid("DKIM selector", err)
}
// The server's own name is not a domain to import: its key signs the
// panel's own mail, and an import would replace it while DNS still
// holds the old record (security audit 2026-10-17, W8).
if h.cfg.Hostname != "" && strings.EqualFold(strings.TrimSpace(exp.Domain), h.cfg.Hostname) {
return "The export is of this server's own name, whose key signs the panel's own mail. It cannot be imported as a domain."
}
// Each application is a copy of sasldb2 and a saslpasswd2 run, all on
// the panel's one connection: an export is held to what a domain plainly
// needs (security audit 2026-10-17, W5).
if n := len(exp.Applications); n > maxImportApplications {
return fmt.Sprintf("The export holds %d applications; an import takes at most %d.", n, maxImportApplications)
}
if exp.DMARCRua != "" {
if err := validate.Email(exp.DMARCRua); err != nil {
return invalid("report address", err)
@@ -71,6 +71,14 @@ func TestDomainImportIsCheckedLikeTheForms(t *testing.T) {
{"report address", func(e *domain.DomainExport) { e.DMARCRua = "not an address" },
"Invalid report address in export file: "},
// As on Domain settings (security audit 2026-10-17, W1).
// Security audit 2026-10-17, W8 and W5.
{"the server's own name", func(e *domain.DomainExport) { e.Domain = "MAIL.example.org" },
"The export is of this server's own name"},
{"too many applications", func(e *domain.DomainExport) {
for i := 0; i < 500; i++ {
e.Applications = append(e.Applications, e.Applications[0])
}
}, "The export holds 501 applications; an import takes at most 500."},
{"report address on the hostname", func(e *domain.DomainExport) { e.DMARCRua = "ops@MAIL.example.org" },
"The export's report address is on this server's own name"},
{"another domain's hosted address", func(e *domain.DomainExport) { e.DMARCRua = "dmarc-reports+victim.example@mail.example.org" },