From 469d263a5cdbdfb9fc321c8e21e0a34945c8f53c Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sat, 18 Jul 2026 08:41:25 +0200 Subject: [PATCH] Hotfix: Add ban-file/ban-threshold/ban-weights as a more lightweight mechanism abuse counter --- cmd/serve.go | 31 +++++ docs/releases.md | 6 + server/config.go | 108 ++++++++++++++++- server/server.go | 11 +- server/server.yml | 33 +++++ server/server_auth.go | 18 +-- server/server_middleware.go | 1 + server/server_test.go | 55 +++++++-- server/visitor.go | 77 ++++++++++++ server/visitor_test.go | 232 ++++++++++++++++++++++++++++++++++++ 10 files changed, 554 insertions(+), 18 deletions(-) create mode 100644 server/visitor_test.go diff --git a/cmd/serve.go b/cmd/serve.go index 9712f94f..2e0aaab0 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -10,6 +10,7 @@ import ( "net" "net/netip" "net/url" + "path/filepath" "runtime" "strings" "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.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.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.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"}), @@ -215,6 +220,10 @@ func execServe(c *cli.Context) error { visitorTopicCreationLimitReplenishStr := c.String("visitor-topic-creation-limit-replenish") visitorPrefixBitsIPv4 := c.Int("visitor-prefix-bits-ipv4") 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") proxyForwardedHeader := c.String("proxy-forwarded-header") proxyTrustedHosts := util.SplitNoEmpty(c.String("proxy-trusted-hosts"), ",") @@ -270,6 +279,16 @@ func execServe(c *cli.Context) error { if err != nil { 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 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") } else if visitorPrefixBitsIPv6 < 1 || visitorPrefixBitsIPv6 > 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 != "" { return errors.New("listen-unix is not supported on Windows") } @@ -512,6 +539,10 @@ func execServe(c *cli.Context) error { conf.VisitorTopicCreationLimitReplenish = visitorTopicCreationLimitReplenish conf.VisitorPrefixBitsIPv4 = visitorPrefixBitsIPv4 conf.VisitorPrefixBitsIPv6 = visitorPrefixBitsIPv6 + conf.BanFile = banFile + conf.BanWindow = banWindow + conf.BanThreshold = banThreshold + conf.BanWeights = banWeights conf.BehindProxy = behindProxy conf.ProxyForwardedHeader = proxyForwardedHeader conf.ProxyTrustedPrefixes = trustedProxyPrefixes diff --git a/docs/releases.md b/docs/releases.md index bf52ef7a..ee4b1534 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -2007,6 +2007,12 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release ## 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) This release makes the "connection lost" alert configurable and turns it off by default. Folks did not like it and many reached out diff --git a/server/config.go b/server/config.go index de94b4ca..15938a47 100644 --- a/server/config.go +++ b/server/config.go @@ -7,6 +7,8 @@ import ( "io/fs" "net/netip" "reflect" + "strconv" + "strings" "text/template" "time" @@ -42,6 +44,18 @@ const ( 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 // - message size limit: the max number of bytes for a message // - total topic limit: max number of topics overall @@ -197,9 +211,13 @@ type Config struct { WebPushStartupQueries string WebPushExpiryDuration time.Duration WebPushExpiryWarningDuration time.Duration - BuildVersion string // Injected by App - BuildDate string // Injected by App - BuildCommit string // Injected by App + BanFile string // Abuse ban-feed: file that fail2ban tails; empty string disables the feature + BanWindow time.Duration // Abuse ban-feed: rolling window over which weighted strikes are counted + 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 @@ -300,6 +318,10 @@ func NewConfig() *Config { WebPushEmailAddress: "", WebPushExpiryDuration: DefaultWebPushExpiryDuration, WebPushExpiryWarningDuration: DefaultWebPushExpiryWarningDuration, + BanFile: "", + BanWindow: DefaultBanWindow, + BanThreshold: DefaultBanThreshold, + BanWeights: nil, BuildVersion: "", BuildDate: "", BuildCommit: "", @@ -323,3 +345,83 @@ func (c *Config) Hash() string { } 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 +} diff --git a/server/server.go b/server/server.go index 284d6d48..094d0d38 100644 --- a/server/server.go +++ b/server/server.go @@ -472,7 +472,7 @@ func (s *Server) closeDatabases() { // handle is the main entry point for all HTTP requests 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 { s.handleError(w, r, v, err) 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.WriteHeader(httpErr.HTTPCode) 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 { diff --git a/server/server.yml b/server/server.yml index ee23dd8b..38132b8f 100644 --- a/server/server.yml +++ b/server/server.yml @@ -370,6 +370,39 @@ # visitor-topic-creation-limit-burst: 100 # 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 +# " ", 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 +# is 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 # - 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) diff --git a/server/server_auth.go b/server/server_auth.go index 3593b330..02690dc7 100644 --- a/server/server_auth.go +++ b/server/server_auth.go @@ -21,31 +21,35 @@ import ( // // 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. -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 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) if s.userManager == nil { - return vip, nil + return r, vip, nil } header, err := readAuthHeader(r) if err != nil { - return vip, err + return r, vip, err } else if !supportedAuthHeader(header) { - return vip, nil + return r, vip, nil } // If we're trying to auth, check the rate limiter first 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) if err != nil { vip.AuthFailed() 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 - 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 ...). diff --git a/server/server_middleware.go b/server/server_middleware.go index 3e65a66a..4405daea 100644 --- a/server/server_middleware.go +++ b/server/server_middleware.go @@ -13,6 +13,7 @@ const ( contextRateVisitor contextKey = iota + 2586 contextTopic contextMatrixPushKey + contextVisitorIP // Client IP extracted in maybeAuthenticate; reused by the abuse ban-feed (see recordStatus) ) func (s *Server) limitRequests(next handleFunc) handleFunc { diff --git a/server/server_test.go b/server/server_test.go index 6697ad4d..3961ad3a 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -2851,7 +2851,7 @@ func TestServer_Visitor_XForwardedFor_None(t *testing.T) { r, _ := http.NewRequest("GET", "/bla", nil) r.RemoteAddr = "8.9.10.11:1234" r.Header.Set("X-Forwarded-For", " ") // Spaces, not empty! - v, err := s.maybeAuthenticate(r) + _, v, err := s.maybeAuthenticate(r) require.Nil(t, err) 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.RemoteAddr = "8.9.10.11:1234" 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.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.RemoteAddr = "8.9.10.11:1234" 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.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.RemoteAddr = "8.9.10.11:1234" 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.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.RemoteAddr = "[2001:db8:9999::1]:1234" 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.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.RemoteAddr = "8.9.10.11:1234" 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.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.RemoteAddr = "[2001:db8:2222::1]:1234" 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.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, "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") // +} + +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) +} diff --git a/server/visitor.go b/server/visitor.go index 2f07d273..38eaec9f 100644 --- a/server/visitor.go +++ b/server/visitor.go @@ -4,6 +4,7 @@ import ( "fmt" "math" "net/netip" + "os" "sync" "time" @@ -70,6 +71,8 @@ type visitor struct { authLimiter *rate.Limiter // Limiter for incorrect login attempts, may be nil firebase time.Time // Next allowed Firebase message 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 } @@ -142,6 +145,13 @@ func newVisitor(conf *Config, messageCache *message.Cache, userManager *user.Man accountLimiter: 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) return v } @@ -551,6 +561,73 @@ func dailyLimitToRate(limit int64) rate.Limit { 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: +// +// +// +// e.g. "2026-07-18T20:56:32Z 1.2.3.4 1.2.3.4/32 429 42901". is 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 func visitorID(ip netip.Addr, u *user.User, conf *Config) string { if u != nil && u.Tier != nil { diff --git a/server/visitor_test.go b/server/visitor_test.go new file mode 100644 index 00000000..eb9d1abb --- /dev/null +++ b/server/visitor_test.go @@ -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")) // +} + +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) // " " + 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)) +}