Files
selfpost/internal/web/handlers/handlers_ratelimit.go
T
mixandClaude Opus 5.5 e469034477
test / test (push) Canceled after 0s
import: the export file held to the forms' rules before anything is created (audit M10)
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>
2026-10-14 15:28:29 +03:00

253 lines
7.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package handlers
import (
"errors"
"fmt"
"net"
"net/http"
"strconv"
"strings"
"github.com/mixeme/selfpost/internal/store"
)
const defaultRateLimitWindowSeconds = 3600
type rateLimitInput struct {
clear bool
mode string
ips []string
maxMessages int
windowSeconds int
autoMultiplier float64
}
func (h *Handlers) l1Messages() int {
if h.cfg.RateLimitMessagesPerIP > 0 {
return h.cfg.RateLimitMessagesPerIP
}
return 100
}
func (h *Handlers) l1Window() int {
if h.cfg.RateLimitWindowSeconds > 0 {
return h.cfg.RateLimitWindowSeconds
}
return defaultRateLimitWindowSeconds
}
func parseAutoMultiplier(raw string) (float64, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return store.DefaultAutoMultiplier, nil
}
v, err := strconv.ParseFloat(raw, 64)
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 fmt.Errorf("multiplier must be between %.1f and %.1f", store.MinAutoMultiplier, store.MaxAutoMultiplier)
}
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) {
raw = strings.TrimSpace(raw)
if raw == "" {
return store.RateLimitModeManual, nil
}
if raw != store.RateLimitModeManual && raw != store.RateLimitModeAuto {
return "", fmt.Errorf("choose manual or auto mode")
}
return raw, nil
}
func parseRateLimitForm(r *http.Request, l1Max int) (rateLimitInput, error) {
if err := r.ParseForm(); err != nil {
return rateLimitInput{}, fmt.Errorf("invalid form submission")
}
if r.PostFormValue("clear") != "" {
return rateLimitInput{clear: true}, nil
}
mode, err := parseRateLimitMode(r.PostFormValue("mode"))
if err != nil {
return rateLimitInput{}, err
}
if mode == store.RateLimitModeAuto {
mult, err := parseAutoMultiplier(r.PostFormValue("auto_multiplier"))
if err != nil {
return rateLimitInput{}, err
}
return rateLimitInput{mode: mode, autoMultiplier: mult}, nil
}
rawMax := strings.TrimSpace(r.PostFormValue("max_messages"))
if rawMax == "" {
return rateLimitInput{clear: true}, nil
}
maxMessages, err := parsePositiveInt(rawMax, 0)
if err != nil {
maxMessages = 0
}
windowSeconds, err := parsePositiveInt(r.PostFormValue("window_seconds"), defaultRateLimitWindowSeconds)
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
}
func parseAppAuthIPsForm(r *http.Request) (bool, []string, error) {
if err := r.ParseForm(); err != nil {
return false, nil, fmt.Errorf("invalid form submission")
}
restrict := r.PostFormValue("auth_ip_restrict") != ""
if !restrict {
return false, nil, nil
}
ips, err := parseIPList(r.PostFormValue("auth_allowed_ips"))
if err != nil {
return false, nil, err
}
if len(ips) == 0 {
return false, nil, fmt.Errorf("enter at least one client IP when the allow-list is enabled")
}
return true, ips, nil
}
func parseIPList(raw string) ([]string, error) {
fields := strings.FieldsFunc(raw, func(r rune) bool {
return r == '\n' || r == '\r' || r == ',' || r == ' ' || r == '\t' || r == ';'
})
var out []string
seen := make(map[string]bool)
for _, f := range fields {
ip := net.ParseIP(f)
if ip == nil {
return nil, fmt.Errorf("%q is not a valid IP address", f)
}
c := ip.String()
if !seen[c] {
seen[c] = true
out = append(out, c)
}
}
return out, nil
}
func parsePositiveInt(raw string, def int) (int, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return def, nil
}
return strconv.Atoi(raw)
}
func (h *Handlers) HandleDomainRateLimit(w http.ResponseWriter, r *http.Request) {
d, ok := h.lookupDomain(w, r)
if !ok {
return
}
in, err := parseRateLimitForm(r, h.l1Messages())
if err != nil {
h.renderDomainSettings(w, r, http.StatusBadRequest, d, settingsView{Err: err.Error()})
return
}
if err := h.applyRateLimit(in, store.RateLimitScopeDomain, d.ID, h.domains); err != nil {
logf("panel: domain %d: save rate limit: %v", d.ID, err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
http.Redirect(w, r, fmt.Sprintf("/outbound/domains/%d?ratelimit=1", d.ID), http.StatusSeeOther)
}
func (h *Handlers) HandleDomainRateLimitRecalc(w http.ResponseWriter, r *http.Request) {
d, ok := h.lookupDomain(w, r)
if !ok {
return
}
if err := h.recalcRateLimit(store.RateLimitScopeDomain, d.ID); err != nil {
h.renderDomainSettings(w, r, http.StatusBadRequest, d, settingsView{Err: recalcErrorMessage(err, "domain", d.ID)})
return
}
http.Redirect(w, r, fmt.Sprintf("/outbound/domains/%d?recalculated=1", d.ID), http.StatusSeeOther)
}
func (h *Handlers) HandleAppRateLimitRecalc(w http.ResponseWriter, r *http.Request) {
a, d, ok := h.lookupDomainApplication(w, r)
if !ok {
return
}
if err := h.recalcRateLimit(store.RateLimitScopeApp, a.ID); err != nil {
h.renderApplicationForm(w, r, http.StatusBadRequest, d, &a, nil,
fmt.Sprintf("%s: %s", a.Login, recalcErrorMessage(err, "application", a.ID)))
return
}
http.Redirect(w, r, fmt.Sprintf("/outbound/domains/%d?recalculated=1", a.DomainID), http.StatusSeeOther)
}
// recalcErrorMessage is what the page says about a recalculation that did not
// happen. A limit that is not automatic has nothing to recalculate, and the
// administrator is told so; any other failure goes to the log.
func recalcErrorMessage(err error, what string, id int64) string {
if errors.Is(err, store.ErrNotAutoRateLimit) {
return err.Error()
}
logf("panel: %s %d: recalc rate limit: %v", what, id, err)
return "Could not recalculate the limit. Check the logs and try again."
}
func (h *Handlers) recalcRateLimit(scope string, refID int64) error {
return h.store.RecalcAutoRateLimit(scope, refID, h.sendLogRetentionDays(), h.l1Messages(), h.l1Window())
}
type rateLimitSaver interface {
ClearRateLimit(id int64) error
SaveRateLimit(id int64, rl store.RateLimit) error
}
func (h *Handlers) applyRateLimit(in rateLimitInput, scope string, refID int64, saver rateLimitSaver) error {
if in.clear {
return saver.ClearRateLimit(refID)
}
rl := store.RateLimit{
Scope: scope,
RefID: refID,
Mode: in.mode,
MaxMessages: in.maxMessages,
WindowSeconds: in.windowSeconds,
AutoMultiplier: in.autoMultiplier,
}
if in.mode == store.RateLimitModeAuto {
rl.WindowSeconds = h.l1Window()
if err := saver.SaveRateLimit(refID, rl); err != nil {
return err
}
return h.recalcRateLimit(scope, refID)
}
return saver.SaveRateLimit(refID, rl)
}