import: the export file held to the forms' rules before anything is created (audit M10)
test / test (push) Canceled after 0s
test / test (push) Canceled after 0s
domain.Import trusted the file for everything but the domain name, the selector and the password: DMARCRua was not validate.Email'ed, client IPs not parsed, a rate limit's mode, window and count not checked - a window of 0 counted nothing, a garbage IP made the application refuse every client. handlers.checkDomainImport runs in HandleImportDomain after the domain name check and before Import, and refuses the file with "Invalid <what> in export file: <the form's own text>": the report address (validate.Email), each rate limit (parseRateLimitMode; auto - checkAutoMultiplier, 0 meaning the default as an empty field does; manual - checkManualLimit, which the form now uses too: count > 0, <= the level-1 backstop, window > 0; both 0 is an export without a limit of its own), each application's client addresses and the legacy ones on its rate limit (parseIPList, written back as the form stores them; allow-list on with none is refused). Login, sending addresses and password stay checked in app.ImportApplication. Tests: a good export passes and its addresses come out normalised; nine broken fields each refused with their message; a refused file posted to the handler creates no domain. CHANGELOG [Unreleased], audit plan line ticked, roadmap 18/62. Evidence: go vet clean; the import, rate-limit and application tests of ./internal/web/handlers ok; the server run follows before the push. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -277,6 +277,11 @@ func (h *Handlers) HandleImportDomain(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if msg := h.checkDomainImport(&exp); msg != "" {
|
||||
h.renderBackupPage(w, r, http.StatusBadRequest, msg)
|
||||
return
|
||||
}
|
||||
|
||||
d, err := h.domains.Import(exp)
|
||||
if err != nil {
|
||||
logf("panel: import domain %q: %v", exp.Domain, err)
|
||||
@@ -365,3 +370,67 @@ func importErrorMessage(err error) (int, string) {
|
||||
return http.StatusBadRequest, "Could not import the domain: " + err.Error()
|
||||
}
|
||||
}
|
||||
|
||||
// checkDomainImport holds an export file to the rules the forms hold the same
|
||||
// settings to - the report address, the rate limits, the client addresses -
|
||||
// before anything is created, and says what is wrong in the validator's own
|
||||
// words; "" when nothing is. The file is untrusted input: a window of no
|
||||
// seconds made a limit that counted nothing, and an address that is not one
|
||||
// made an application refuse every client without a word at import (audit
|
||||
// 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).
|
||||
func (h *Handlers) checkDomainImport(exp *domain.DomainExport) string {
|
||||
invalid := func(what string, err error) string {
|
||||
return "Invalid " + what + " in export file: " + err.Error()
|
||||
}
|
||||
if exp.DMARCRua != "" {
|
||||
if err := validate.Email(exp.DMARCRua); err != nil {
|
||||
return invalid("report address", err)
|
||||
}
|
||||
}
|
||||
limit := func(rl *domain.RateLimitExport) error {
|
||||
if rl == nil {
|
||||
return nil
|
||||
}
|
||||
mode, err := parseRateLimitMode(rl.Mode)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if mode == store.RateLimitModeAuto {
|
||||
if rl.AutoMultiplier == 0 {
|
||||
return nil // as an empty field on the form: the default
|
||||
}
|
||||
return checkAutoMultiplier(rl.AutoMultiplier)
|
||||
}
|
||||
if rl.MaxMessages <= 0 && rl.WindowSeconds <= 0 {
|
||||
return nil // no limit of its own (an export of client addresses only)
|
||||
}
|
||||
return checkManualLimit(rl.MaxMessages, rl.WindowSeconds, h.l1Messages())
|
||||
}
|
||||
if err := limit(exp.RateLimit); err != nil {
|
||||
return invalid("rate limit for the domain", err)
|
||||
}
|
||||
for i := range exp.Applications {
|
||||
a := &exp.Applications[i]
|
||||
if err := limit(a.RateLimit); err != nil {
|
||||
return invalid("rate limit for "+a.Login, err)
|
||||
}
|
||||
ips, err := parseIPList(strings.Join(a.AuthAllowedIPs, ","))
|
||||
if err != nil {
|
||||
return invalid("client addresses for "+a.Login, err)
|
||||
}
|
||||
a.AuthAllowedIPs = ips
|
||||
if a.RateLimit != nil && len(a.RateLimit.AllowedIPs) > 0 {
|
||||
legacy, err := parseIPList(strings.Join(a.RateLimit.AllowedIPs, ","))
|
||||
if err != nil {
|
||||
return invalid("client addresses for "+a.Login, err)
|
||||
}
|
||||
a.RateLimit.AllowedIPs = legacy
|
||||
}
|
||||
if a.AuthIPRestrict && len(a.AuthAllowedIPs) == 0 && (a.RateLimit == nil || len(a.RateLimit.AllowedIPs) == 0) {
|
||||
return invalid("client addresses for "+a.Login, errors.New("enter at least one client IP when the allow-list is enabled"))
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/mixeme/selfpost/internal/domain"
|
||||
"github.com/mixeme/selfpost/internal/store"
|
||||
"github.com/mixeme/selfpost/internal/web/auth"
|
||||
)
|
||||
|
||||
// A domain export is held to the rules the forms hold the same settings to,
|
||||
// field by field, and refused in the validator's words (audit 2026-10-12,
|
||||
// M10).
|
||||
func TestDomainImportIsCheckedLikeTheForms(t *testing.T) {
|
||||
h, _ := inboundHandlers(t)
|
||||
good := func() domain.DomainExport {
|
||||
return domain.DomainExport{
|
||||
Domain: "example.com",
|
||||
DMARCRua: "reports@example.com",
|
||||
RateLimit: &domain.RateLimitExport{Mode: store.RateLimitModeManual, MaxMessages: 10, WindowSeconds: 3600},
|
||||
Applications: []domain.AppExport{{
|
||||
Login: "app1", AddressMode: store.AddressModeWildcard, Password: "imported-password",
|
||||
AuthIPRestrict: true, AuthAllowedIPs: []string{"192.0.2.1", " 2001:DB8::1 "},
|
||||
RateLimit: &domain.RateLimitExport{Mode: store.RateLimitModeAuto, AutoMultiplier: 2},
|
||||
}},
|
||||
}
|
||||
}
|
||||
exp := good()
|
||||
if msg := h.checkDomainImport(&exp); msg != "" {
|
||||
t.Fatalf("a good export refused: %s", msg)
|
||||
}
|
||||
if got := strings.Join(exp.Applications[0].AuthAllowedIPs, " "); got != "192.0.2.1 2001:db8::1" {
|
||||
t.Errorf("client addresses stored as %q, want as the form stores them", got)
|
||||
}
|
||||
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
break_ func(*domain.DomainExport)
|
||||
want string
|
||||
}{
|
||||
{"report address", func(e *domain.DomainExport) { e.DMARCRua = "not an address" },
|
||||
"Invalid report address in export file: "},
|
||||
{"no window", func(e *domain.DomainExport) { e.RateLimit.WindowSeconds = 0 },
|
||||
"Invalid rate limit for the domain in export file: enter a time window greater than zero seconds"},
|
||||
{"no messages", func(e *domain.DomainExport) { e.RateLimit.MaxMessages = 0 },
|
||||
"Invalid rate limit for the domain in export file: enter a message limit greater than zero"},
|
||||
{"over the backstop", func(e *domain.DomainExport) { e.RateLimit.MaxMessages = 1 << 20 },
|
||||
"Invalid rate limit for the domain in export file: message limit cannot exceed the level-1 backstop"},
|
||||
{"mode", func(e *domain.DomainExport) { e.RateLimit.Mode = "sometimes" },
|
||||
"Invalid rate limit for the domain in export file: choose manual or auto mode"},
|
||||
{"multiplier", func(e *domain.DomainExport) { e.Applications[0].RateLimit.AutoMultiplier = 1000 },
|
||||
"Invalid rate limit for app1 in export file: multiplier must be between"},
|
||||
{"client address", func(e *domain.DomainExport) { e.Applications[0].AuthAllowedIPs = []string{"not-an-ip"} },
|
||||
`Invalid client addresses for app1 in export file: "not-an-ip" is not a valid IP address`},
|
||||
{"allow-list on, empty", func(e *domain.DomainExport) { e.Applications[0].AuthAllowedIPs = nil },
|
||||
"Invalid client addresses for app1 in export file: enter at least one client IP when the allow-list is enabled"},
|
||||
{"legacy client address", func(e *domain.DomainExport) {
|
||||
e.Applications[0].AuthAllowedIPs = nil
|
||||
e.Applications[0].RateLimit = &domain.RateLimitExport{AllowedIPs: []string{"300.1.1.1"}}
|
||||
}, `Invalid client addresses for app1 in export file: "300.1.1.1" is not a valid IP address`},
|
||||
} {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
exp := good()
|
||||
c.break_(&exp)
|
||||
if msg := h.checkDomainImport(&exp); !strings.HasPrefix(msg, c.want) {
|
||||
t.Fatalf("got %q, want it to start with %q", msg, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The check runs before anything is created: a refused file leaves no domain.
|
||||
func TestDomainImportRefusedCreatesNothing(t *testing.T) {
|
||||
h, st := inboundHandlers(t)
|
||||
root := userWith(t, st, "root", store.RoleGlobal, store.Reach{})
|
||||
raw, _ := json.Marshal(domain.DomainExport{
|
||||
Format: domain.FormatDomainExport, Domain: "example.com", DKIMSelector: "mail",
|
||||
RateLimit: &domain.RateLimitExport{Mode: store.RateLimitModeManual, MaxMessages: 10, WindowSeconds: 0},
|
||||
})
|
||||
var body bytes.Buffer
|
||||
mw := multipart.NewWriter(&body)
|
||||
fw, _ := mw.CreateFormFile("file", "example.com.json")
|
||||
fw.Write(raw)
|
||||
mw.Close()
|
||||
req := httptest.NewRequest("POST", "/server/backup/import", &body)
|
||||
req.Header.Set("Content-Type", mw.FormDataContentType())
|
||||
req = auth.RequestWithPrincipal(req, root)
|
||||
rec := httptest.NewRecorder()
|
||||
h.HandleImportDomain(rec, req)
|
||||
if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), "enter a time window greater than zero seconds") {
|
||||
t.Fatalf("import of a limit with no window = %d:\n%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if domains, _ := st.ListDomains(); len(domains) != 0 {
|
||||
t.Fatalf("the refused import created %v", domains)
|
||||
}
|
||||
}
|
||||
@@ -45,10 +45,31 @@ func parseAutoMultiplier(raw string) (float64, error) {
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("enter a valid multiplier (%.1f–%.1f)", store.MinAutoMultiplier, store.MaxAutoMultiplier)
|
||||
}
|
||||
return v, checkAutoMultiplier(v)
|
||||
}
|
||||
|
||||
// checkAutoMultiplier is the rule for an auto limit's multiplier, shared by
|
||||
// the form and a domain import.
|
||||
func checkAutoMultiplier(v float64) error {
|
||||
if v < store.MinAutoMultiplier || v > store.MaxAutoMultiplier {
|
||||
return 0, fmt.Errorf("multiplier must be between %.1f and %.1f", store.MinAutoMultiplier, store.MaxAutoMultiplier)
|
||||
return fmt.Errorf("multiplier must be between %.1f and %.1f", store.MinAutoMultiplier, store.MaxAutoMultiplier)
|
||||
}
|
||||
return v, nil
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkManualLimit is the rule for a manual limit, shared by the form and a
|
||||
// domain import: a window of no seconds would count nothing.
|
||||
func checkManualLimit(maxMessages, windowSeconds, l1Max int) error {
|
||||
if maxMessages <= 0 {
|
||||
return fmt.Errorf("enter a message limit greater than zero")
|
||||
}
|
||||
if maxMessages > l1Max {
|
||||
return fmt.Errorf("message limit cannot exceed the level-1 backstop (%d)", l1Max)
|
||||
}
|
||||
if windowSeconds <= 0 {
|
||||
return fmt.Errorf("enter a time window greater than zero seconds")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseRateLimitMode(raw string) (string, error) {
|
||||
@@ -86,15 +107,15 @@ func parseRateLimitForm(r *http.Request, l1Max int) (rateLimitInput, error) {
|
||||
return rateLimitInput{clear: true}, nil
|
||||
}
|
||||
maxMessages, err := parsePositiveInt(rawMax, 0)
|
||||
if err != nil || maxMessages <= 0 {
|
||||
return rateLimitInput{}, fmt.Errorf("enter a message limit greater than zero")
|
||||
}
|
||||
if maxMessages > l1Max {
|
||||
return rateLimitInput{}, fmt.Errorf("message limit cannot exceed the level-1 backstop (%d)", l1Max)
|
||||
if err != nil {
|
||||
maxMessages = 0
|
||||
}
|
||||
windowSeconds, err := parsePositiveInt(r.PostFormValue("window_seconds"), defaultRateLimitWindowSeconds)
|
||||
if err != nil || windowSeconds <= 0 {
|
||||
return rateLimitInput{}, fmt.Errorf("enter a time window greater than zero seconds")
|
||||
if err != nil {
|
||||
windowSeconds = 0
|
||||
}
|
||||
if err := checkManualLimit(maxMessages, windowSeconds, l1Max); err != nil {
|
||||
return rateLimitInput{}, err
|
||||
}
|
||||
return rateLimitInput{mode: store.RateLimitModeManual, maxMessages: maxMessages, windowSeconds: windowSeconds}, nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user