Redo the banning logic, ban.Service

This commit is contained in:
binwiederhier
2026-07-20 21:34:47 +02:00
parent 469d263a5c
commit c674985699
16 changed files with 779 additions and 417 deletions
+55
View File
@@ -0,0 +1,55 @@
package server
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/stretchr/testify/require"
"heckel.io/ntfy/v2/ban"
)
// TestServer_BanFeed_WritesOffenderToFile is the end-to-end wiring test: a rejected request flows
// through s.handle -> the error responder -> s.ban.Record, and once the offender's prefix
// breaches, its ban line lands in the ban file (flushed on Close).
func TestServer_BanFeed_WritesOffenderToFile(t *testing.T) {
banFile := filepath.Join(t.TempDir(), "ban.log")
conf := newTestConfig(t, "")
conf.BanFile = banFile
conf.BanWindow = time.Minute
conf.BanThreshold = 1 // capacity 1: the 2nd rejection breaches
conf.BanWeights = ban.Weights{"*": 1} // any 4xx counts one strike
s := newTestServer(t, conf)
require.NotNil(t, s.ban)
// A delayed message with caching disabled is a deterministic 400 (errHTTPBadRequestDelayNoCache).
// request() sends from RemoteAddr 9.9.9.9.
reject := map[string]string{"Cache": "no", "In": "30 min"}
for i := 0; i < 3; i++ {
response := request(t, s, "PUT", "/mytopic", "", reject)
require.Equal(t, 400, response.Code)
}
// Writes are async; Close flushes the buffer. The offender's prefix must be in the feed exactly
// once (throttled to one line per window).
s.ban.Close()
data, err := os.ReadFile(banFile)
require.NoError(t, err)
lines := strings.Split(strings.TrimRight(string(data), "\n"), "\n")
require.Len(t, lines, 1)
require.Contains(t, lines[0], " 9.9.9.9 9.9.9.9/32 400 ") // <ip> <prefix> <http-code> <ntfy-code>
}
// TestServer_BanFeed_DisabledByDefault verifies the feature is off with no ban file: s.ban is
// nil and the error path skips it (guarded), so a rejected request must not panic.
func TestServer_BanFeed_DisabledByDefault(t *testing.T) {
conf := newTestConfig(t, "") // no BanFile
s := newTestServer(t, conf)
require.Nil(t, s.ban)
reject := map[string]string{"Cache": "no", "In": "30 min"}
response := request(t, s, "PUT", "/mytopic", "", reject) // guarded callsite, no Record
require.Equal(t, 400, response.Code)
}
+25 -96
View File
@@ -7,11 +7,10 @@ import (
"io/fs"
"net/netip"
"reflect"
"strconv"
"strings"
"text/template"
"time"
"heckel.io/ntfy/v2/ban"
"heckel.io/ntfy/v2/user"
)
@@ -46,15 +45,25 @@ const (
// 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
DefaultBanWindow = 10 * time.Minute
DefaultBanThreshold = 100 // Weighted strikes per BanWindow before a prefix 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"}
// DefaultBanWeights is the ban-feed's default per-code strike weights: the auth-failure flood bans fast,
// legit quota codes are exempt (weight 0), and everything else defaults to weight 1 (no "*"; see WeightFor).
var DefaultBanWeights = []string{
banWeight(errHTTPTooManyRequestsLimitAuthFailure, 10), // brute-force auth flood -> ban fast
banWeight(errHTTPTooManyRequestsLimitMessages, 0), // daily message quota -> legit, exempt
banWeight(errHTTPTooManyRequestsLimitSubscriptions, 0), // too many subscriptions -> legit, exempt
banWeight(errHTTPTooManyRequestsLimitAttachmentBandwidth, 0), // daily attachment bandwidth -> legit, exempt
banWeight(errHTTPTooManyRequestsLimitCalls, 0), // daily phone-call quota -> legit, exempt
}
// banWeight formats a "CODE:WEIGHT" ban-feed default from an ntfy error, so the codes stay in sync with
// the errHTTP definitions instead of being duplicated as string literals.
func banWeight(err *errHTTP, weight int) string {
return fmt.Sprintf("%d:%d", err.Code, weight)
}
// Defines all global and per-visitor limits
// - message size limit: the max number of bytes for a message
@@ -211,13 +220,13 @@ type Config struct {
WebPushStartupQueries string
WebPushExpiryDuration time.Duration
WebPushExpiryWarningDuration time.Duration
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
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 prefix is banned
BanWeights ban.Weights // Abuse ban-feed: code matcher -> strike weight (see ban.ParseWeights, ban.Weights.WeightFor)
BuildVersion string // Injected by App
BuildDate string // Injected by App
BuildCommit string // Injected by App
}
// NewConfig instantiates a default new server config
@@ -345,83 +354,3 @@ 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
}
+19 -6
View File
@@ -31,6 +31,7 @@ import (
"golang.org/x/sync/errgroup"
"heckel.io/ntfy/v2/action"
"heckel.io/ntfy/v2/attachment"
"heckel.io/ntfy/v2/ban"
"heckel.io/ntfy/v2/db"
"heckel.io/ntfy/v2/db/pg"
"heckel.io/ntfy/v2/log"
@@ -57,6 +58,7 @@ type Server struct {
mailer mail.Sender
topics map[string]*topic
visitors map[string]*visitor // ip:<ip> or user:<user>
ban *ban.Service // Abuse ban-feed; nil when the feature is disabled (no ban file)
firebaseClient *firebaseClient
twilio *twilioClient
messages int64 // Total number of messages (persisted if messageCache enabled)
@@ -286,6 +288,17 @@ func New(conf *Config) (*Server, error) {
}
firebaseClient = newFirebaseClient(sender, auther)
}
var banner *ban.Service
if conf.BanFile != "" {
banner = ban.NewService(&ban.Config{
File: conf.BanFile,
Window: conf.BanWindow,
Threshold: conf.BanThreshold,
Weights: conf.BanWeights,
PrefixBitsIPv4: conf.VisitorPrefixBitsIPv4,
PrefixBitsIPv6: conf.VisitorPrefixBitsIPv6,
})
}
s := &Server{
config: conf,
db: pool,
@@ -295,6 +308,7 @@ func New(conf *Config) (*Server, error) {
firebaseClient: firebaseClient,
twilio: newTwilioClient(conf, userManager),
mailer: sender,
ban: banner,
topics: topics,
userManager: userManager,
messages: messages,
@@ -450,6 +464,9 @@ func (s *Server) Stop() {
s.attachment.Close()
}
s.closeDatabases()
if s.ban != nil {
s.ban.Close()
}
if s.closeChan != nil {
close(s.closeChan)
}
@@ -538,13 +555,9 @@ 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 s.ban != nil {
if ip, err := fromContext[netip.Addr](r, contextVisitorIP); err == nil {
v.recordStatus(ip, httpErr.HTTPCode, httpErr.Code)
s.ban.Record(ip, httpErr.HTTPCode, httpErr.Code)
}
}
}
+1 -1
View File
@@ -13,7 +13,7 @@ const (
contextRateVisitor contextKey = iota + 2586
contextTopic
contextMatrixPushKey
contextVisitorIP // Client IP extracted in maybeAuthenticate; reused by the abuse ban-feed (see recordStatus)
contextVisitorIP // Client IP extracted in maybeAuthenticate; reused by the abuse ban-feed (see ban.Service.Record)
)
func (s *Server) limitRequests(next handleFunc) handleFunc {
+2
View File
@@ -5167,6 +5167,7 @@ func TestServer_BanFeed_RateLimitedIPBanned(t *testing.T) {
}
}
require.Greater(t, got429, 2)
s.ban.Close() // Writes are async (runWriteLoop); Close flushes the buffer before we read
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>
@@ -5186,5 +5187,6 @@ func TestServer_BanFeed_SuccessfulRequestsNotBanned(t *testing.T) {
rr := request(t, s, "PUT", "/mytopic", fmt.Sprintf("m%d", i), nil)
require.Equal(t, 200, rr.Code)
}
s.ban.Close() // Flush any buffered bans (there should be none) before asserting no file
require.NoFileExists(t, banFile)
}
-77
View File
@@ -4,7 +4,6 @@ import (
"fmt"
"math"
"net/netip"
"os"
"sync"
"time"
@@ -71,8 +70,6 @@ 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
}
@@ -145,13 +142,6 @@ 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
}
@@ -561,73 +551,6 @@ 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:
//
// <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
func visitorID(ip netip.Addr, u *user.User, conf *Config) string {
if u != nil && u.Tier != nil {
-232
View File
@@ -1,232 +0,0 @@
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))
}