Hotfix: Add ban-file/ban-threshold/ban-weights as a more lightweight mechanism abuse counter

This commit is contained in:
binwiederhier
2026-07-18 08:41:25 +02:00
parent 432da44dc4
commit 469d263a5c
10 changed files with 554 additions and 18 deletions
+31
View File
@@ -10,6 +10,7 @@ import (
"net" "net"
"net/netip" "net/netip"
"net/url" "net/url"
"path/filepath"
"runtime" "runtime"
"strings" "strings"
"text/template" "text/template"
@@ -98,6 +99,10 @@ var flagsServe = append(
altsrc.NewStringFlag(&cli.StringFlag{Name: "visitor-topic-creation-limit-replenish", Aliases: []string{"visitor_topic_creation_limit_replenish"}, EnvVars: []string{"NTFY_VISITOR_TOPIC_CREATION_LIMIT_REPLENISH"}, Value: util.FormatDuration(server.DefaultVisitorTopicCreationLimitReplenish), Usage: "interval at which topic-creation tokens are refilled (one per x)"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "visitor-topic-creation-limit-replenish", Aliases: []string{"visitor_topic_creation_limit_replenish"}, EnvVars: []string{"NTFY_VISITOR_TOPIC_CREATION_LIMIT_REPLENISH"}, Value: util.FormatDuration(server.DefaultVisitorTopicCreationLimitReplenish), Usage: "interval at which topic-creation tokens are refilled (one per x)"}),
altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-prefix-bits-ipv4", Aliases: []string{"visitor_prefix_bits_ipv4"}, EnvVars: []string{"NTFY_VISITOR_PREFIX_BITS_IPV4"}, Value: server.DefaultVisitorPrefixBitsIPv4, Usage: "number of bits of the IPv4 address to use for rate limiting (default: 32, full address)"}), altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-prefix-bits-ipv4", Aliases: []string{"visitor_prefix_bits_ipv4"}, EnvVars: []string{"NTFY_VISITOR_PREFIX_BITS_IPV4"}, Value: server.DefaultVisitorPrefixBitsIPv4, Usage: "number of bits of the IPv4 address to use for rate limiting (default: 32, full address)"}),
altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-prefix-bits-ipv6", Aliases: []string{"visitor_prefix_bits_ipv6"}, EnvVars: []string{"NTFY_VISITOR_PREFIX_BITS_IPV6"}, Value: server.DefaultVisitorPrefixBitsIPv6, Usage: "number of bits of the IPv6 address to use for rate limiting (default: 64, /64 subnet)"}), altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-prefix-bits-ipv6", Aliases: []string{"visitor_prefix_bits_ipv6"}, EnvVars: []string{"NTFY_VISITOR_PREFIX_BITS_IPV6"}, Value: server.DefaultVisitorPrefixBitsIPv6, Usage: "number of bits of the IPv6 address to use for rate limiting (default: 64, /64 subnet)"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "ban-file", Aliases: []string{"ban_file"}, EnvVars: []string{"NTFY_BAN_FILE"}, Value: "", Usage: "if set, append IPs of abusive visitors to this file for fail2ban to tail (empty disables)"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "ban-window", Aliases: []string{"ban_window"}, EnvVars: []string{"NTFY_BAN_WINDOW"}, Value: util.FormatDuration(server.DefaultBanWindow), Usage: "rolling window over which weighted strikes are counted for the ban file"}),
altsrc.NewIntFlag(&cli.IntFlag{Name: "ban-threshold", Aliases: []string{"ban_threshold"}, EnvVars: []string{"NTFY_BAN_THRESHOLD"}, Value: server.DefaultBanThreshold, Usage: "weighted strikes per window before a visitor is banned"}),
altsrc.NewStringSliceFlag(&cli.StringSliceFlag{Name: "ban-weights", Aliases: []string{"ban_weights"}, EnvVars: []string{"NTFY_BAN_WEIGHTS"}, Value: cli.NewStringSlice(server.DefaultBanWeights...), Usage: "per-code strike weights as KEY:WEIGHT, where KEY is an ntfy code, an HTTP status, a PREFIX*, or '*' (weight 0 exempts)"}),
altsrc.NewBoolFlag(&cli.BoolFlag{Name: "behind-proxy", Aliases: []string{"behind_proxy", "P"}, EnvVars: []string{"NTFY_BEHIND_PROXY"}, Value: false, Usage: "if set, use forwarded header (e.g. X-Forwarded-For, X-Client-IP) to determine visitor IP address (for rate limiting)"}), altsrc.NewBoolFlag(&cli.BoolFlag{Name: "behind-proxy", Aliases: []string{"behind_proxy", "P"}, EnvVars: []string{"NTFY_BEHIND_PROXY"}, Value: false, Usage: "if set, use forwarded header (e.g. X-Forwarded-For, X-Client-IP) to determine visitor IP address (for rate limiting)"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "proxy-forwarded-header", Aliases: []string{"proxy_forwarded_header"}, EnvVars: []string{"NTFY_PROXY_FORWARDED_HEADER"}, Value: "X-Forwarded-For", Usage: "use specified header to determine visitor IP address (for rate limiting)"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "proxy-forwarded-header", Aliases: []string{"proxy_forwarded_header"}, EnvVars: []string{"NTFY_PROXY_FORWARDED_HEADER"}, Value: "X-Forwarded-For", Usage: "use specified header to determine visitor IP address (for rate limiting)"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "proxy-trusted-hosts", Aliases: []string{"proxy_trusted_hosts"}, EnvVars: []string{"NTFY_PROXY_TRUSTED_HOSTS"}, Value: "", Usage: "comma-separated list of trusted IP addresses, hosts, or CIDRs to remove from forwarded header"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "proxy-trusted-hosts", Aliases: []string{"proxy_trusted_hosts"}, EnvVars: []string{"NTFY_PROXY_TRUSTED_HOSTS"}, Value: "", Usage: "comma-separated list of trusted IP addresses, hosts, or CIDRs to remove from forwarded header"}),
@@ -215,6 +220,10 @@ func execServe(c *cli.Context) error {
visitorTopicCreationLimitReplenishStr := c.String("visitor-topic-creation-limit-replenish") visitorTopicCreationLimitReplenishStr := c.String("visitor-topic-creation-limit-replenish")
visitorPrefixBitsIPv4 := c.Int("visitor-prefix-bits-ipv4") visitorPrefixBitsIPv4 := c.Int("visitor-prefix-bits-ipv4")
visitorPrefixBitsIPv6 := c.Int("visitor-prefix-bits-ipv6") visitorPrefixBitsIPv6 := c.Int("visitor-prefix-bits-ipv6")
banFile := c.String("ban-file")
banWindowStr := c.String("ban-window")
banThreshold := c.Int("ban-threshold")
banWeightsRaw := c.StringSlice("ban-weights")
behindProxy := c.Bool("behind-proxy") behindProxy := c.Bool("behind-proxy")
proxyForwardedHeader := c.String("proxy-forwarded-header") proxyForwardedHeader := c.String("proxy-forwarded-header")
proxyTrustedHosts := util.SplitNoEmpty(c.String("proxy-trusted-hosts"), ",") proxyTrustedHosts := util.SplitNoEmpty(c.String("proxy-trusted-hosts"), ",")
@@ -270,6 +279,16 @@ func execServe(c *cli.Context) error {
if err != nil { if err != nil {
return fmt.Errorf("invalid web push expiry warning duration: %s", webPushExpiryWarningDurationStr) return fmt.Errorf("invalid web push expiry warning duration: %s", webPushExpiryWarningDurationStr)
} }
banWindow, err := util.ParseDuration(banWindowStr)
if err != nil {
return fmt.Errorf("invalid ban window: %s", banWindowStr)
}
// Parse abuse ban-feed weights ("KEY:WEIGHT" list, "*" fallback)
banWeights, err := server.ParseBanWeights(banWeightsRaw)
if err != nil {
return err
}
// Convert sizes to bytes // Convert sizes to bytes
messageSizeLimit, err := util.ParseSize(messageSizeLimitStr) messageSizeLimit, err := util.ParseSize(messageSizeLimitStr)
@@ -372,6 +391,14 @@ func execServe(c *cli.Context) error {
return errors.New("visitor-prefix-bits-ipv4 must be between 1 and 32") return errors.New("visitor-prefix-bits-ipv4 must be between 1 and 32")
} else if visitorPrefixBitsIPv6 < 1 || visitorPrefixBitsIPv6 > 128 { } else if visitorPrefixBitsIPv6 < 1 || visitorPrefixBitsIPv6 > 128 {
return errors.New("visitor-prefix-bits-ipv6 must be between 1 and 128") return errors.New("visitor-prefix-bits-ipv6 must be between 1 and 128")
} else if banFile != "" && banWindow <= 0 {
return errors.New("if ban-file is set, ban-window must be greater than zero")
} else if banFile != "" && banThreshold <= 0 {
return errors.New("if ban-file is set, ban-threshold must be greater than zero")
} else if banFile != "" && len(banWeights) == 0 {
return errors.New("if ban-file is set, ban-weights must not be empty")
} else if banFile != "" && !util.FileExists(filepath.Dir(banFile)) {
return fmt.Errorf("if ban-file is set, its directory (%s) must exist", filepath.Dir(banFile))
} else if runtime.GOOS == "windows" && listenUnix != "" { } else if runtime.GOOS == "windows" && listenUnix != "" {
return errors.New("listen-unix is not supported on Windows") return errors.New("listen-unix is not supported on Windows")
} }
@@ -512,6 +539,10 @@ func execServe(c *cli.Context) error {
conf.VisitorTopicCreationLimitReplenish = visitorTopicCreationLimitReplenish conf.VisitorTopicCreationLimitReplenish = visitorTopicCreationLimitReplenish
conf.VisitorPrefixBitsIPv4 = visitorPrefixBitsIPv4 conf.VisitorPrefixBitsIPv4 = visitorPrefixBitsIPv4
conf.VisitorPrefixBitsIPv6 = visitorPrefixBitsIPv6 conf.VisitorPrefixBitsIPv6 = visitorPrefixBitsIPv6
conf.BanFile = banFile
conf.BanWindow = banWindow
conf.BanThreshold = banThreshold
conf.BanWeights = banWeights
conf.BehindProxy = behindProxy conf.BehindProxy = behindProxy
conf.ProxyForwardedHeader = proxyForwardedHeader conf.ProxyForwardedHeader = proxyForwardedHeader
conf.ProxyTrustedPrefixes = trustedProxyPrefixes conf.ProxyTrustedPrefixes = trustedProxyPrefixes
+6
View File
@@ -2007,6 +2007,12 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
## Not released yet ## Not released yet
### ntfy server v2.26.1 (UNRELEASED)
**Features:**
* Add an abuse ban-feed: when enabled via `ban-file`, ntfy tracks a weighted strike budget per visitor and appends abusive IPs to a file that fail2ban can tail and ban on sight (see `ban-file`, `ban-window`, `ban-threshold`, `ban-weights`)
### ntfy Android v1.25.1 (UNRELEASED) ### ntfy Android v1.25.1 (UNRELEASED)
This release makes the "connection lost" alert configurable and turns it off by default. Folks did not like it and many reached out This release makes the "connection lost" alert configurable and turns it off by default. Folks did not like it and many reached out
+105 -3
View File
@@ -7,6 +7,8 @@ import (
"io/fs" "io/fs"
"net/netip" "net/netip"
"reflect" "reflect"
"strconv"
"strings"
"text/template" "text/template"
"time" "time"
@@ -42,6 +44,18 @@ const (
DefaultWebPushExpiryDuration = 60 * 24 * time.Hour DefaultWebPushExpiryDuration = 60 * 24 * time.Hour
) )
// Defines default abuse ban-feed settings (see BanFile, BanWindow, BanThreshold, BanWeights)
const (
DefaultBanWindow = time.Minute
DefaultBanThreshold = 100 // Weighted strikes per BanWindow before a visitor is banned
)
// DefaultBanWeights defines the per-code strike weights used when the ban-feed is enabled but no
// explicit ban-weights are configured. Format is "KEY:WEIGHT" (see ParseBanWeights). The auth-failure
// flood is weighted heavily so it bans fast, the legit quota 429s are exempt (weight 0), and every
// other rejection costs one strike via the "*" fallback.
var DefaultBanWeights = []string{"42909:10", "42908:0", "42903:0", "42905:0", "42910:0", "*:1"}
// Defines all global and per-visitor limits // Defines all global and per-visitor limits
// - message size limit: the max number of bytes for a message // - message size limit: the max number of bytes for a message
// - total topic limit: max number of topics overall // - total topic limit: max number of topics overall
@@ -197,9 +211,13 @@ type Config struct {
WebPushStartupQueries string WebPushStartupQueries string
WebPushExpiryDuration time.Duration WebPushExpiryDuration time.Duration
WebPushExpiryWarningDuration time.Duration WebPushExpiryWarningDuration time.Duration
BuildVersion string // Injected by App BanFile string // Abuse ban-feed: file that fail2ban tails; empty string disables the feature
BuildDate string // Injected by App BanWindow time.Duration // Abuse ban-feed: rolling window over which weighted strikes are counted
BuildCommit string // Injected by App BanThreshold int // Abuse ban-feed: weighted strikes per window before a visitor is banned
BanWeights map[string]int // Abuse ban-feed: normalized code matcher -> strike weight (see ParseBanWeights, weightFor)
BuildVersion string // Injected by App
BuildDate string // Injected by App
BuildCommit string // Injected by App
} }
// NewConfig instantiates a default new server config // NewConfig instantiates a default new server config
@@ -300,6 +318,10 @@ func NewConfig() *Config {
WebPushEmailAddress: "", WebPushEmailAddress: "",
WebPushExpiryDuration: DefaultWebPushExpiryDuration, WebPushExpiryDuration: DefaultWebPushExpiryDuration,
WebPushExpiryWarningDuration: DefaultWebPushExpiryWarningDuration, WebPushExpiryWarningDuration: DefaultWebPushExpiryWarningDuration,
BanFile: "",
BanWindow: DefaultBanWindow,
BanThreshold: DefaultBanThreshold,
BanWeights: nil,
BuildVersion: "", BuildVersion: "",
BuildDate: "", BuildDate: "",
BuildCommit: "", BuildCommit: "",
@@ -323,3 +345,83 @@ func (c *Config) Hash() string {
} }
return fmt.Sprintf("%x", sha256.Sum256([]byte(result))) return fmt.Sprintf("%x", sha256.Sum256([]byte(result)))
} }
// ParseBanWeights turns a list like ["42909:10","403:2","42908:0","*:1"] into a map of normalized
// matcher key -> strike weight for the abuse ban-feed's single weighted bucket. A key may be an exact
// ntfy code ("42909"), a prefix family ("429*"), or "*". A bare 3-digit HTTP status ("403") is a
// shorthand normalized to its family ("403*"), so operators can weight a whole status at once. Weights
// must be integers >= 0; a weight of 0 is valid and means the code is exempt (never contributes to a
// ban), which lets a "*" catch-all coexist with carved-out legit-quota codes. A malformed entry is
// rejected so misconfiguration surfaces at startup rather than silently disabling bans.
func ParseBanWeights(entries []string) (map[string]int, error) {
out := make(map[string]int, len(entries))
for _, entry := range entries {
key, weightStr, ok := strings.Cut(entry, ":")
if !ok {
return nil, fmt.Errorf("invalid ban-weight %q, want KEY:WEIGHT", entry)
}
weight, err := strconv.Atoi(strings.TrimSpace(weightStr))
if err != nil || weight < 0 {
return nil, fmt.Errorf("invalid ban-weight value in %q, want a non-negative integer", entry)
}
key = strings.TrimSpace(key)
if !validBanWeightKey(key) {
return nil, fmt.Errorf("invalid ban-weight key in %q, want %q, an ntfy code, an HTTP status, or a PREFIX*", entry, "*")
}
// A bare 3-digit HTTP status is shorthand for the whole family (e.g. "403" -> "403*").
if len(key) == 3 && isAllDigits(key) {
key += "*"
}
out[key] = weight
}
return out, nil
}
// validBanWeightKey reports whether key is a legal ban-weight matcher: "*", an all-digits code,
// or an all-digits prefix followed by "*".
func validBanWeightKey(key string) bool {
if key == "*" {
return true
}
digits := strings.TrimSuffix(key, "*")
return digits != "" && isAllDigits(digits)
}
func isAllDigits(s string) bool {
for _, r := range s {
if r < '0' || r > '9' {
return false
}
}
return s != ""
}
// weightFor returns the strike weight for a rejection's 5-digit ntfy code using the ban-weights
// matcher, longest-match-wins. An exact key ("42909") matches with length len(code); a family key
// ("429*") matches a code with that prefix, with length equal to the prefix; "*" matches everything
// with length 0. The weight of the longest match is returned, or 0 if no key matches (which the
// caller treats as "no strike").
func (c *Config) weightFor(ntfyCode int) int {
code := strconv.Itoa(ntfyCode)
weight, bestLen, matched := 0, -1, false
for key, w := range c.BanWeights {
matchLen := -1
switch {
case key == "*":
matchLen = 0
case strings.HasSuffix(key, "*"):
if prefix := strings.TrimSuffix(key, "*"); strings.HasPrefix(code, prefix) {
matchLen = len(prefix)
}
case key == code:
matchLen = len(code)
}
if matchLen > bestLen {
weight, bestLen, matched = w, matchLen, true
}
}
if !matched {
return 0
}
return weight
}
+10 -1
View File
@@ -472,7 +472,7 @@ func (s *Server) closeDatabases() {
// handle is the main entry point for all HTTP requests // handle is the main entry point for all HTTP requests
func (s *Server) handle(w http.ResponseWriter, r *http.Request) { func (s *Server) handle(w http.ResponseWriter, r *http.Request) {
v, err := s.maybeAuthenticate(r) // Note: Always returns v, even when error is returned r, v, err := s.maybeAuthenticate(r) // Note: Always returns v (and r, with the client IP in its context), even on error
if err != nil { if err != nil {
s.handleError(w, r, v, err) s.handleError(w, r, v, err)
return return
@@ -538,6 +538,15 @@ func (s *Server) handleError(w http.ResponseWriter, r *http.Request, v *visitor,
w.Header().Set("Access-Control-Allow-Origin", s.config.AccessControlAllowOrigin) // CORS, allow cross-origin requests w.Header().Set("Access-Control-Allow-Origin", s.config.AccessControlAllowOrigin) // CORS, allow cross-origin requests
w.WriteHeader(httpErr.HTTPCode) w.WriteHeader(httpErr.HTTPCode)
io.WriteString(w, httpErr.JSON()+"\n") io.WriteString(w, httpErr.JSON()+"\n")
if s.config.BanFile != "" {
// Abuse ban-feed: record against the OFFENDING request's IP, not v.ip. An account-keyed
// (tier'd) visitor is one object shared across all its source IPs, so v.ip is stale. The IP was
// already extracted in maybeAuthenticate and stashed in the request context (contextVisitorIP),
// so reuse it here rather than re-parsing headers.
if ip, err := fromContext[netip.Addr](r, contextVisitorIP); err == nil {
v.recordStatus(ip, httpErr.HTTPCode, httpErr.Code)
}
}
} }
func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visitor) error { func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visitor) error {
+33
View File
@@ -370,6 +370,39 @@
# visitor-topic-creation-limit-burst: 100 # visitor-topic-creation-limit-burst: 100
# visitor-topic-creation-limit-replenish: "1m" # visitor-topic-creation-limit-replenish: "1m"
# Abuse ban-feed: Count HTTP response statuses per visitor and append abusive IPs to a file that
# fail2ban (or similar) can tail and ban on sight. This captures ntfy-layer rejections (e.g. ACL
# 403s and ntfy's own 429s), so fail2ban does not have to regex-parse the full access log.
# - ban-file is the file abusive IPs are appended to; leave empty to disable the feature. Its
# directory must exist and be writable by ntfy. Rotate it (e.g. logrotate, copytruncate) so it
# cannot grow unbounded.
# - ban-window is the rolling window over which weighted strikes are counted, per visitor.
# - ban-threshold is the number of weighted strikes per window before a visitor is banned. Each
# visitor has ONE strike budget; rejections draw it down, so there is no way to game it by mixing
# codes.
# - ban-weights assigns a strike weight to a matcher KEY (KEY:WEIGHT). A KEY is an exact ntfy code
# ("42909"), a prefix family ("429*"), a bare HTTP status ("403", shorthand for "403*"), or "*".
# Longest match wins. A weight of 0 exempts a code (never contributes to a ban), so the legit quota
# 429s can be carved out from a "*" catch-all. Heavier weights ban faster (auth-failure floods).
#
# Each appended line has the exact format
# "<RFC3339-UTC-timestamp> <ip> <prefix> <http-code> <ntfy-code>", for example:
# 2026-07-17T20:56:32Z 1.2.3.4 1.2.3.4/32 429 42901
# 2026-07-17T20:56:32Z 2001:db8::abcd 2001:db8::/64 429 42909
# <prefix> is <ip> masked to the rate-limiting prefix (visitor-prefix-bits-ipv4/ipv6); that is the
# unit a fail2ban jail should ban, so a whole IPv6 subnet is banned as one.
#
# ban-file: "/var/log/ntfy-ban.log"
# ban-window: "1m"
# ban-threshold: 100
# ban-weights:
# - "42909:10" # auth-failure flood: bans in ~10
# - "42908:0" # daily message quota reached -> legit, never counts
# - "42903:0" # subscription limit -> legit
# - "42905:0" # daily bandwidth reached -> legit
# - "42910:0" # daily phone call quota reached -> legit
# - "*:1" # everything else 4xx/5xx
# Rate limiting: IPv4/IPv6 address prefix bits used for rate limiting # Rate limiting: IPv4/IPv6 address prefix bits used for rate limiting
# - visitor-prefix-bits-ipv4: number of bits of the IPv4 address to use for rate limiting (default: 32, full address) # - visitor-prefix-bits-ipv4: number of bits of the IPv4 address to use for rate limiting (default: 32, full address)
# - visitor-prefix-bits-ipv6: number of bits of the IPv6 address to use for rate limiting (default: 64, /64 subnet) # - visitor-prefix-bits-ipv6: number of bits of the IPv6 address to use for rate limiting (default: 64, /64 subnet)
+11 -7
View File
@@ -21,31 +21,35 @@ import (
// //
// This function will ALWAYS return a visitor, even if an error occurs (e.g. unauthorized), so // This function will ALWAYS return a visitor, even if an error occurs (e.g. unauthorized), so
// that subsequent logging calls still have a visitor context. // that subsequent logging calls still have a visitor context.
func (s *Server) maybeAuthenticate(r *http.Request) (*visitor, error) { func (s *Server) maybeAuthenticate(r *http.Request) (*http.Request, *visitor, error) {
// Read the "Authorization" header value and exit out early if it's not set // Read the "Authorization" header value and exit out early if it's not set
ip := extractIPAddress(r, s.config.BehindProxy, s.config.ProxyForwardedHeader, s.config.ProxyTrustedPrefixes) ip := extractIPAddress(r, s.config.BehindProxy, s.config.ProxyForwardedHeader, s.config.ProxyTrustedPrefixes)
// Stash the extracted client IP in the request context so downstream code (the abuse ban-feed in
// handleError) can reuse it without re-parsing headers, and so an account-keyed (tier'd) visitor --
// whose shared visitor object has a stale v.ip -- is still attributed to the actual request IP.
r = withContext(r, map[contextKey]any{contextVisitorIP: ip})
vip := s.visitor(ip, nil) vip := s.visitor(ip, nil)
if s.userManager == nil { if s.userManager == nil {
return vip, nil return r, vip, nil
} }
header, err := readAuthHeader(r) header, err := readAuthHeader(r)
if err != nil { if err != nil {
return vip, err return r, vip, err
} else if !supportedAuthHeader(header) { } else if !supportedAuthHeader(header) {
return vip, nil return r, vip, nil
} }
// If we're trying to auth, check the rate limiter first // If we're trying to auth, check the rate limiter first
if !vip.AuthAllowed() { if !vip.AuthAllowed() {
return vip, errHTTPTooManyRequestsLimitAuthFailure // Always return visitor, even when error occurs! return r, vip, errHTTPTooManyRequestsLimitAuthFailure // Always return visitor, even when error occurs!
} }
u, err := s.authenticate(r, header) u, err := s.authenticate(r, header)
if err != nil { if err != nil {
vip.AuthFailed() vip.AuthFailed()
logr(r).Err(err).Debug("Authentication failed") logr(r).Err(err).Debug("Authentication failed")
return vip, errHTTPUnauthorized // Always return visitor, even when error occurs! return r, vip, errHTTPUnauthorized // Always return visitor, even when error occurs!
} }
// Authentication with user was successful // Authentication with user was successful
return s.visitor(ip, u), nil return r, s.visitor(ip, u), nil
} }
// authenticate a user based on basic auth username/password (Authorization: Basic ...), or token auth (Authorization: Bearer ...). // authenticate a user based on basic auth username/password (Authorization: Basic ...), or token auth (Authorization: Bearer ...).
+1
View File
@@ -13,6 +13,7 @@ const (
contextRateVisitor contextKey = iota + 2586 contextRateVisitor contextKey = iota + 2586
contextTopic contextTopic
contextMatrixPushKey contextMatrixPushKey
contextVisitorIP // Client IP extracted in maybeAuthenticate; reused by the abuse ban-feed (see recordStatus)
) )
func (s *Server) limitRequests(next handleFunc) handleFunc { func (s *Server) limitRequests(next handleFunc) handleFunc {
+48 -7
View File
@@ -2851,7 +2851,7 @@ func TestServer_Visitor_XForwardedFor_None(t *testing.T) {
r, _ := http.NewRequest("GET", "/bla", nil) r, _ := http.NewRequest("GET", "/bla", nil)
r.RemoteAddr = "8.9.10.11:1234" r.RemoteAddr = "8.9.10.11:1234"
r.Header.Set("X-Forwarded-For", " ") // Spaces, not empty! r.Header.Set("X-Forwarded-For", " ") // Spaces, not empty!
v, err := s.maybeAuthenticate(r) _, v, err := s.maybeAuthenticate(r)
require.Nil(t, err) require.Nil(t, err)
require.Equal(t, "8.9.10.11", v.ip.String()) require.Equal(t, "8.9.10.11", v.ip.String())
}) })
@@ -2865,7 +2865,7 @@ func TestServer_Visitor_XForwardedFor_Single(t *testing.T) {
r, _ := http.NewRequest("GET", "/bla", nil) r, _ := http.NewRequest("GET", "/bla", nil)
r.RemoteAddr = "8.9.10.11:1234" r.RemoteAddr = "8.9.10.11:1234"
r.Header.Set("X-Forwarded-For", "1.1.1.1") r.Header.Set("X-Forwarded-For", "1.1.1.1")
v, err := s.maybeAuthenticate(r) _, v, err := s.maybeAuthenticate(r)
require.Nil(t, err) require.Nil(t, err)
require.Equal(t, "1.1.1.1", v.ip.String()) require.Equal(t, "1.1.1.1", v.ip.String())
}) })
@@ -2879,7 +2879,7 @@ func TestServer_Visitor_XForwardedFor_Multiple(t *testing.T) {
r, _ := http.NewRequest("GET", "/bla", nil) r, _ := http.NewRequest("GET", "/bla", nil)
r.RemoteAddr = "8.9.10.11:1234" r.RemoteAddr = "8.9.10.11:1234"
r.Header.Set("X-Forwarded-For", "1.2.3.4 , 2.4.4.2,234.5.2.1 ") r.Header.Set("X-Forwarded-For", "1.2.3.4 , 2.4.4.2,234.5.2.1 ")
v, err := s.maybeAuthenticate(r) _, v, err := s.maybeAuthenticate(r)
require.Nil(t, err) require.Nil(t, err)
require.Equal(t, "234.5.2.1", v.ip.String()) require.Equal(t, "234.5.2.1", v.ip.String())
}) })
@@ -2894,7 +2894,7 @@ func TestServer_Visitor_Custom_ClientIP_Header(t *testing.T) {
r, _ := http.NewRequest("GET", "/bla", nil) r, _ := http.NewRequest("GET", "/bla", nil)
r.RemoteAddr = "8.9.10.11:1234" r.RemoteAddr = "8.9.10.11:1234"
r.Header.Set("X-Client-IP", "1.2.3.4") r.Header.Set("X-Client-IP", "1.2.3.4")
v, err := s.maybeAuthenticate(r) _, v, err := s.maybeAuthenticate(r)
require.Nil(t, err) require.Nil(t, err)
require.Equal(t, "1.2.3.4", v.ip.String()) require.Equal(t, "1.2.3.4", v.ip.String())
}) })
@@ -2909,7 +2909,7 @@ func TestServer_Visitor_Custom_ClientIP_Header_IPv6(t *testing.T) {
r, _ := http.NewRequest("GET", "/bla", nil) r, _ := http.NewRequest("GET", "/bla", nil)
r.RemoteAddr = "[2001:db8:9999::1]:1234" r.RemoteAddr = "[2001:db8:9999::1]:1234"
r.Header.Set("X-Client-IP", "2001:db8:7777::1") r.Header.Set("X-Client-IP", "2001:db8:7777::1")
v, err := s.maybeAuthenticate(r) _, v, err := s.maybeAuthenticate(r)
require.Nil(t, err) require.Nil(t, err)
require.Equal(t, "2001:db8:7777::1", v.ip.String()) require.Equal(t, "2001:db8:7777::1", v.ip.String())
}) })
@@ -2925,7 +2925,7 @@ func TestServer_Visitor_Custom_Forwarded_Header(t *testing.T) {
r, _ := http.NewRequest("GET", "/bla", nil) r, _ := http.NewRequest("GET", "/bla", nil)
r.RemoteAddr = "8.9.10.11:1234" r.RemoteAddr = "8.9.10.11:1234"
r.Header.Set("Forwarded", " for=5.6.7.8, by=example.com;for=1.2.3.4") r.Header.Set("Forwarded", " for=5.6.7.8, by=example.com;for=1.2.3.4")
v, err := s.maybeAuthenticate(r) _, v, err := s.maybeAuthenticate(r)
require.Nil(t, err) require.Nil(t, err)
require.Equal(t, "5.6.7.8", v.ip.String()) require.Equal(t, "5.6.7.8", v.ip.String())
}) })
@@ -2941,7 +2941,7 @@ func TestServer_Visitor_Custom_Forwarded_Header_IPv6(t *testing.T) {
r, _ := http.NewRequest("GET", "/bla", nil) r, _ := http.NewRequest("GET", "/bla", nil)
r.RemoteAddr = "[2001:db8:2222::1]:1234" r.RemoteAddr = "[2001:db8:2222::1]:1234"
r.Header.Set("Forwarded", " for=[2001:db8:1111::1], by=example.com;for=[2001:db8:3333::1]") r.Header.Set("Forwarded", " for=[2001:db8:1111::1], by=example.com;for=[2001:db8:3333::1]")
v, err := s.maybeAuthenticate(r) _, v, err := s.maybeAuthenticate(r)
require.Nil(t, err) require.Nil(t, err)
require.Equal(t, "2001:db8:3333::1", v.ip.String()) require.Equal(t, "2001:db8:3333::1", v.ip.String())
}) })
@@ -5147,3 +5147,44 @@ func TestServer_Publish_InvalidUTF8WithFirebase(t *testing.T) {
require.Equal(t, "\uFFFDclipse", sender.Messages()[0].Data["title"]) require.Equal(t, "\uFFFDclipse", sender.Messages()[0].Data["title"])
require.Equal(t, "probl\uFFFDme", sender.Messages()[0].Data["tags"]) require.Equal(t, "probl\uFFFDme", sender.Messages()[0].Data["tags"])
} }
func TestServer_BanFeed_RateLimitedIPBanned(t *testing.T) {
// Real requests: exhaust the visitor request limit so ntfy returns 429s, and confirm the
// client IP is written to the ban file after it breaches the per-status ban limit.
banFile := filepath.Join(t.TempDir(), "ntfy-ban.log")
c := newTestConfig(t, "")
c.BanFile = banFile
c.BanWindow = time.Minute
c.BanThreshold = 2 // Ban after the weighted budget of 2 is exhausted
c.BanWeights = map[string]int{"*": 1} // Every rejection costs 1 strike
c.VisitorRequestLimitBurst = 2 // 429 quickly
s := newTestServer(t, c)
got429 := 0
for i := 0; i < 10; i++ {
rr := request(t, s, "PUT", "/mytopic", "x", nil)
if rr.Code == 429 {
got429++
}
}
require.Greater(t, got429, 2)
data, err := os.ReadFile(banFile)
require.NoError(t, err)
require.Contains(t, string(data), "9.9.9.9 9.9.9.9/32 429 42901") // <ip> <prefix> <http> <ntfy-code>
}
func TestServer_BanFeed_SuccessfulRequestsNotBanned(t *testing.T) {
// Real requests that all succeed (200) must never trigger a ban, even with a low "*" fallback.
banFile := filepath.Join(t.TempDir(), "ntfy-ban.log")
c := newTestConfig(t, "")
c.BanFile = banFile
c.BanWindow = time.Minute
c.BanThreshold = 3 // Low threshold that would catch 200s if 2xx were not skipped
c.BanWeights = map[string]int{"*": 1} // Every rejection costs 1 strike
c.VisitorRequestLimitBurst = 100 // Stay under the request limit so every request is 200
s := newTestServer(t, c)
for i := 0; i < 10; i++ {
rr := request(t, s, "PUT", "/mytopic", fmt.Sprintf("m%d", i), nil)
require.Equal(t, 200, rr.Code)
}
require.NoFileExists(t, banFile)
}
+77
View File
@@ -4,6 +4,7 @@ import (
"fmt" "fmt"
"math" "math"
"net/netip" "net/netip"
"os"
"sync" "sync"
"time" "time"
@@ -70,6 +71,8 @@ type visitor struct {
authLimiter *rate.Limiter // Limiter for incorrect login attempts, may be nil authLimiter *rate.Limiter // Limiter for incorrect login attempts, may be nil
firebase time.Time // Next allowed Firebase message firebase time.Time // Next allowed Firebase message
seen time.Time // Last seen time of this visitor (needed for removal of stale visitors) seen time.Time // Last seen time of this visitor (needed for removal of stale visitors)
banLimiter *rate.Limiter // Abuse ban-feed: single weighted breach detector (rate.Limiter is concurrency-safe), nil when feature disabled
banEmit *rate.Limiter // Abuse ban-feed: throttles writes so a persistent offender only re-appears occasionally, nil when disabled
mu sync.RWMutex mu sync.RWMutex
} }
@@ -142,6 +145,13 @@ func newVisitor(conf *Config, messageCache *message.Cache, userManager *user.Man
accountLimiter: nil, // Set in resetLimiters, may be nil accountLimiter: nil, // Set in resetLimiters, may be nil
authLimiter: nil, // Set in resetLimiters, may be nil authLimiter: nil, // Set in resetLimiters, may be nil
} }
// Abuse ban-feed: only wire up per-visitor tracking when the feature is enabled. One weighted
// token bucket (capacity BanThreshold, refilled at BanThreshold/BanWindow per second) is the
// breach detector; banEmit throttles writes so the feed stays tiny.
if conf.BanFile != "" {
v.banLimiter = rate.NewLimiter(rate.Limit(float64(conf.BanThreshold)/conf.BanWindow.Seconds()), conf.BanThreshold)
v.banEmit = rate.NewLimiter(rate.Every(conf.BanWindow), 1)
}
v.resetLimitersNoLock(messages, emails, calls, false) v.resetLimitersNoLock(messages, emails, calls, false)
return v return v
} }
@@ -551,6 +561,73 @@ func dailyLimitToRate(limit int64) rate.Limit {
return rate.Limit(limit) * rate.Every(oneDay) return rate.Limit(limit) * rate.Every(oneDay)
} }
// recordStatus is called once per request with the offending request's IP and the final HTTP status
// and ntfy code. Each rejection (4xx/5xx only) consumes weightFor(ntfyCode) tokens from the visitor's
// single weighted ban bucket; a code weighted 0 (or one that matches no rule) is exempt and never
// counts. When the bucket cannot cover a rejection the visitor has breached, and ip is appended to
// the ban file that fail2ban tails (throttled via banEmit so a persistent offender only re-appears
// occasionally). ip is passed in rather than read from v.ip because an account-keyed (tier'd) visitor
// is shared across all its source IPs, so v.ip would be stale; we ban the address that breached.
// This is on the hot path, so it no-ops immediately when the feature is disabled.
func (v *visitor) recordStatus(ip netip.Addr, httpCode, ntfyCode int) {
if v.config.BanFile == "" {
return // Feature disabled
}
if httpCode < 400 {
return // Only rejections (4xx/5xx) count toward a ban; success and redirects never do
}
// Note: tier'd (account-keyed) visitors are NOT exempt. A persistent 429 stream is abusive
// regardless of the account behind it, and banning the offending IP is the right response --
// exactly what the pre-existing nginx-side jails already did. The legit case (a paid user merely
// hitting a plan/quota limit) is spared by the per-code weights instead: quota codes like 42908
// are weighted 0 below. Only request-limiter floods (42901, weight 1) actually accrue strikes.
weight := v.config.weightFor(ntfyCode)
if weight <= 0 {
return // Exempt (weight 0) or unmatched code: no strike
}
if v.banLimiter.AllowN(time.Now(), weight) {
return // The bucket covered this rejection; still within the strike budget
}
// Breached. Throttle so the feed stays tiny even if the offender keeps hammering.
if v.banEmit.Allow() {
writeBanLine(v.config.BanFile, ip, visitorPrefix(ip, v.config), httpCode, ntfyCode)
}
}
// banFileMu serializes appends to the ban file across all visitors (multiple visitors may breach
// concurrently). The feed is pre-filtered, so write volume is low and a single mutex is plenty.
var banFileMu sync.Mutex
// writeBanLine appends a single line to the ban file:
//
// <RFC3339-UTC-timestamp> <ip> <prefix> <http-code> <ntfy-code>
//
// e.g. "2026-07-18T20:56:32Z 1.2.3.4 1.2.3.4/32 429 42901". <prefix> is <ip> masked to the configured
// rate-limiting prefix (VisitorPrefixBitsIPv4/IPv6); that is the unit fail2ban should ban, so an IPv6
// client (which owns a whole /64) is banned as one, matching how ntfy rate-limits it. This exact
// format is a contract that a fail2ban jail parses. Best-effort; any error is swallowed, because a
// failure to write the ban feed must never fail the underlying request.
func writeBanLine(path string, ip netip.Addr, prefix netip.Prefix, httpCode, ntfyCode int) {
banFileMu.Lock()
defer banFileMu.Unlock()
f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
return
}
defer f.Close()
fmt.Fprintf(f, "%s %s %s %d %d\n", time.Now().UTC().Format(time.RFC3339), ip.String(), prefix.String(), httpCode, ntfyCode)
}
// visitorPrefix masks ip to the configured rate-limiting prefix (VisitorPrefixBitsIPv4/IPv6), so the
// ban feed reports the same unit ntfy rate-limits by -- e.g. a whole /64 for an IPv6 client -- rather
// than a single address. fail2ban bans that prefix.
func visitorPrefix(ip netip.Addr, conf *Config) netip.Prefix {
if ip.Is4() {
return netip.PrefixFrom(ip, conf.VisitorPrefixBitsIPv4).Masked()
}
return netip.PrefixFrom(ip, conf.VisitorPrefixBitsIPv6).Masked()
}
// visitorID returns a unique identifier for a visitor based on user or IP, using configurable prefix bits for IPv4/IPv6 // visitorID returns a unique identifier for a visitor based on user or IP, using configurable prefix bits for IPv4/IPv6
func visitorID(ip netip.Addr, u *user.User, conf *Config) string { func visitorID(ip netip.Addr, u *user.User, conf *Config) string {
if u != nil && u.Tier != nil { if u != nil && u.Tier != nil {
+232
View File
@@ -0,0 +1,232 @@
package server
import (
"net/netip"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/stretchr/testify/require"
"heckel.io/ntfy/v2/user"
)
// newBanTestVisitor creates a visitor wired for ban-feed testing, with the given ban file,
// weighted-bucket threshold, and weights, plus a 1-minute window (so the emit throttle only
// fires once per test).
func newBanTestVisitor(t *testing.T, banFile string, threshold int, weights map[string]int) *visitor {
conf := NewConfig()
conf.BanFile = banFile
conf.BanWindow = time.Minute
conf.BanThreshold = threshold
conf.BanWeights = weights
return newVisitor(conf, nil, nil, netip.MustParseAddr("1.2.3.4"), nil)
}
func readBanLines(t *testing.T, path string) []string {
data, err := os.ReadFile(path)
require.NoError(t, err)
return strings.Split(strings.TrimRight(string(data), "\n"), "\n")
}
func TestVisitor_RecordStatus_Weight2BansAtHalfThreshold(t *testing.T) {
banFile := filepath.Join(t.TempDir(), "ban.log")
// Threshold 10, code weight 2 -> the budget covers exactly 5 hits, so the 6th breaches.
v := newBanTestVisitor(t, banFile, 10, map[string]int{"*": 2})
for i := 0; i < 5; i++ {
v.recordStatus(v.ip, 400, 40001)
}
require.NoFileExists(t, banFile) // 5 hits * weight 2 = 10 == budget, exactly at the limit, not over
v.recordStatus(v.ip, 400, 40001) // 6th hit cannot be covered -> breach
lines := readBanLines(t, banFile)
require.Len(t, lines, 1)
require.True(t, strings.HasSuffix(lines[0], " 1.2.3.4 1.2.3.4/32 400 40001")) // <ip> <prefix> <http> <ntfy-code>
}
func TestVisitor_RecordStatus_Weight10BansFast(t *testing.T) {
banFile := filepath.Join(t.TempDir(), "ban.log")
// Threshold 10, code weight 10 -> a single hit drains the whole budget, so the 2nd breaches.
v := newBanTestVisitor(t, banFile, 10, map[string]int{"42909": 10, "*": 1})
v.recordStatus(v.ip, 429, 42909)
require.NoFileExists(t, banFile)
v.recordStatus(v.ip, 429, 42909) // 2nd hit cannot be covered -> breach
lines := readBanLines(t, banFile)
require.Len(t, lines, 1)
require.True(t, strings.HasSuffix(lines[0], " 1.2.3.4 1.2.3.4/32 429 42909"))
}
func TestVisitor_RecordStatus_Weight0NeverBans(t *testing.T) {
banFile := filepath.Join(t.TempDir(), "ban.log")
// The legit-quota code is exempt (weight 0), so no number of hits ever bans.
v := newBanTestVisitor(t, banFile, 10, map[string]int{"42908": 0, "*": 1})
for i := 0; i < 100; i++ {
v.recordStatus(v.ip, 429, 42908)
}
require.NoFileExists(t, banFile)
}
func TestVisitor_RecordStatus_SingleBucketNoRelaxation(t *testing.T) {
banFile := filepath.Join(t.TempDir(), "ban.log")
// One shared bucket: different codes draw down the SAME budget, so mixing them creates no extra
// headroom (unlike per-code buckets, which would relax the effective limit for a mixed offender).
v := newBanTestVisitor(t, banFile, 10, map[string]int{"403*": 2, "*": 1})
v.recordStatus(v.ip, 403, 40301) // weight 2 -> 8 left
v.recordStatus(v.ip, 403, 40301) // weight 2 -> 6 left
v.recordStatus(v.ip, 403, 40301) // weight 2 -> 4 left
require.NoFileExists(t, banFile)
for i := 0; i < 4; i++ {
v.recordStatus(v.ip, 400, 40001) // weight 1 each -> drains the remaining 4 -> 0 left
}
require.NoFileExists(t, banFile) // 6 + 4 = 10 == budget exactly, still not over
v.recordStatus(v.ip, 400, 40001) // one more cannot be covered -> breach
lines := readBanLines(t, banFile)
require.Len(t, lines, 1)
}
func TestVisitor_RecordStatus_ExactLineFormat(t *testing.T) {
banFile := filepath.Join(t.TempDir(), "ban.log")
v := newBanTestVisitor(t, banFile, 1, map[string]int{"*": 1})
before := time.Now().UTC().Truncate(time.Second)
for i := 0; i < 3; i++ {
v.recordStatus(v.ip, 429, 42901)
}
after := time.Now().UTC()
lines := readBanLines(t, banFile)
require.Len(t, lines, 1)
parts := strings.Split(lines[0], " ")
require.Len(t, parts, 5) // "<timestamp> <ip> <prefix> <http-code> <ntfy-code>"
ts, err := time.Parse(time.RFC3339, parts[0])
require.NoError(t, err)
require.Equal(t, time.UTC, ts.Location())
require.False(t, ts.Before(before))
require.False(t, ts.After(after.Add(time.Second)))
require.Equal(t, "1.2.3.4", parts[1]) // full IP
require.Equal(t, "1.2.3.4/32", parts[2]) // masked to the default IPv4 prefix (/32)
require.Equal(t, "429", parts[3]) // HTTP status
require.Equal(t, "42901", parts[4]) // ntfy code
}
func TestVisitor_RecordStatus_IPv6MaskedToPrefix(t *testing.T) {
banFile := filepath.Join(t.TempDir(), "ban.log")
conf := NewConfig()
conf.BanFile = banFile
conf.BanWindow = time.Minute
conf.BanThreshold = 1
conf.BanWeights = map[string]int{"*": 1}
v := newVisitor(conf, nil, nil, netip.MustParseAddr("2001:db8::abcd"), nil)
for i := 0; i < 3; i++ {
v.recordStatus(v.ip, 429, 42901)
}
parts := strings.Split(readBanLines(t, banFile)[0], " ")
require.Len(t, parts, 5)
require.Equal(t, "2001:db8::abcd", parts[1]) // full IPv6 address
require.Equal(t, "2001:db8::/64", parts[2]) // masked to the default IPv6 prefix (/64)
}
func TestVisitor_RecordStatus_TierUserAlsoBanned(t *testing.T) {
// A tier'd user who keeps hammering the request limiter (42901) is banned by IP like anyone
// else -- a persistent 429 stream is abusive regardless of the account behind it. The legit
// case (hitting a paid plan/quota limit) is spared by the per-code weights, not by a tier skip:
// codes like 42908 are weight 0. So tier does not exempt a visitor from the ban feed.
banFile := filepath.Join(t.TempDir(), "ban.log")
v := newBanTestVisitor(t, banFile, 1, map[string]int{"*": 1})
v.user = &user.User{ID: "u_test", Name: "test", Tier: &user.Tier{ID: "ti_test"}}
for i := 0; i < 10; i++ {
v.recordStatus(v.ip, 429, 42901)
}
lines := readBanLines(t, banFile)
require.Len(t, lines, 1)
require.True(t, strings.HasSuffix(lines[0], " 1.2.3.4 1.2.3.4/32 429 42901"))
}
func TestVisitor_RecordStatus_BansOffendingIPNotVisitorIP(t *testing.T) {
// For an account-keyed (tier'd) visitor, one visitor object serves many source IPs and its
// stored v.ip is whichever IP created it first -- stale. The ban feed must write the IP of the
// request that actually breached, passed in per-call, not the visitor's stored IP.
banFile := filepath.Join(t.TempDir(), "ban.log")
v := newBanTestVisitor(t, banFile, 1, map[string]int{"*": 1}) // v.ip == 1.2.3.4
offender := netip.MustParseAddr("5.6.7.8")
for i := 0; i < 3; i++ {
v.recordStatus(offender, 429, 42901)
}
parts := strings.Split(readBanLines(t, banFile)[0], " ")
require.Equal(t, "5.6.7.8", parts[1]) // the offending request IP, not v.ip (1.2.3.4)
require.Equal(t, "5.6.7.8/32", parts[2]) // its prefix, not the visitor's stored-IP prefix
}
func TestVisitor_RecordStatus_DisabledWhenNoBanFile(t *testing.T) {
v := newBanTestVisitor(t, "", 10, map[string]int{"*": 1})
for i := 0; i < 5; i++ {
v.recordStatus(v.ip, 403, 40301) // Feature disabled: must be a no-op, must not panic
}
require.Nil(t, v.banEmit) // No throttle limiter is created when the feature is off
}
func TestVisitor_RecordStatus_Ignores2xx3xx(t *testing.T) {
banFile := filepath.Join(t.TempDir(), "ban.log")
v := newBanTestVisitor(t, banFile, 3, map[string]int{"*": 1})
// Success and redirects must never count toward a ban, even over the threshold -- otherwise a
// legit high-volume publisher (lots of 200s) would get banned.
for i := 0; i < 20; i++ {
v.recordStatus(v.ip, 200, 20000)
v.recordStatus(v.ip, 302, 30000)
}
require.NoFileExists(t, banFile)
// A 4xx over the same budget still gets written.
for i := 0; i < 5; i++ {
v.recordStatus(v.ip, 400, 40001)
}
lines := readBanLines(t, banFile)
require.Len(t, lines, 1)
require.True(t, strings.HasSuffix(lines[0], " 1.2.3.4 1.2.3.4/32 400 40001"))
}
func TestParseBanWeights(t *testing.T) {
// Exact codes, a bare 3-digit HTTP status (normalized to a family), an exempt code, and "*".
weights, err := ParseBanWeights([]string{"42909:10", "403:2", "42908:0", "*:1"})
require.NoError(t, err)
require.Equal(t, map[string]int{"42909": 10, "403*": 2, "42908": 0, "*": 1}, weights)
// A bare 3-digit HTTP status normalizes to its family.
weights, err = ParseBanWeights([]string{"429:5"})
require.NoError(t, err)
require.Equal(t, map[string]int{"429*": 5}, weights)
// An explicit family key stays as-is.
weights, err = ParseBanWeights([]string{"429*:5"})
require.NoError(t, err)
require.Equal(t, map[string]int{"429*": 5}, weights)
// Weight 0 is valid and means exempt.
weights, err = ParseBanWeights([]string{"42908:0"})
require.NoError(t, err)
require.Equal(t, map[string]int{"42908": 0}, weights)
_, err = ParseBanWeights([]string{"401"}) // Missing weight
require.Error(t, err)
_, err = ParseBanWeights([]string{"401:-1"}) // Negative weight
require.Error(t, err)
_, err = ParseBanWeights([]string{"401:abc"}) // Non-integer weight
require.Error(t, err)
_, err = ParseBanWeights([]string{"abc:10"}) // Non-numeric key
require.Error(t, err)
_, err = ParseBanWeights([]string{"4*3:10"}) // Star not at the end
require.Error(t, err)
}
func TestConfig_WeightFor(t *testing.T) {
conf := NewConfig()
weights, err := ParseBanWeights([]string{"42908:0", "42903:0", "42905:0", "42910:0", "42909:10", "429*:1", "403*:2", "4*:1", "5*:1"})
require.NoError(t, err)
conf.BanWeights = weights
// Longest-match-wins: exact 5-digit beats "429*" beats "4*" beats "*".
require.Equal(t, 0, conf.weightFor(42908))
require.Equal(t, 10, conf.weightFor(42909))
require.Equal(t, 1, conf.weightFor(42901))
require.Equal(t, 2, conf.weightFor(40311))
require.Equal(t, 1, conf.weightFor(40011))
require.Equal(t, 1, conf.weightFor(50312))
// A code that matches nothing (no "*" here) returns 0.
require.Equal(t, 0, conf.weightFor(30012))
}