Merge remote-tracking branch 'origin/main' into up-payer

# Conflicts:
#	cmd/serve_test.go
#	docs/releases.md
This commit is contained in:
binwiederhier committed 2026-11-02 01:15:09 +00:00
commit bfb257b3eb
12 files changed
+139 -54

No files matched your search

+14 -4
View File
@@ -5,7 +5,12 @@ on:
pull_request:
jobs:
test:
name: test (${{ matrix.part }})
runs-on: ubuntu-24.04
strategy:
fail-fast: false
matrix:
part: [checks, server, rest] # In parallel: the server package takes as long as all others together
services:
postgres:
image: postgres:17
@@ -43,7 +48,12 @@ jobs:
run: make docs
- name: Build web app (required for tests)
run: make web
- name: Run tests, formatting, vetting and linting
run: make checkv
- name: Run coverage
run: make coverage
- name: Run web tests, formatting, vetting and linting
if: matrix.part == 'checks'
run: make web-test web-fmt-check fmt-check vet web-lint lint staticcheck template-check go-check
- name: Run Go tests of the server package (race detector)
if: matrix.part == 'server'
run: go test -race ./server
- name: Run Go tests of all other packages (race detector)
if: matrix.part == 'rest'
run: go test -race $(go list -f '{{if or .TestGoFiles .XTestGoFiles}}{{.ImportPath}}{{end}}' ./... | grep -vE 'ntfy/v2/(test|examples|tools|web|server)$')
+15 -2
View File
@@ -5,6 +5,7 @@ package ban
import (
"fmt"
"math"
"net/netip"
"os"
"sync"
@@ -18,6 +19,17 @@ const (
tag = "ban"
pruneInterval = 10 * time.Minute
writeInterval = 3 * time.Second
// strikeUnits is how finely strikes are counted: weights go down to 0.001 (e.g. 0.05 = 20 hits
// make one strike), and the token bucket only takes whole tokens
strikeUnits = 1000
)
// Strikes are counted in thousandths of a strike, so these bounds keep the counts far from
// overflowing; any weight above the threshold bans on the first hit anyway
const (
MaxWeight = 1_000_000
MaxThreshold = 1_000_000_000
)
// Config is the Service's config, kept separate from server.Config to avoid an import cycle.
@@ -81,11 +93,12 @@ func (s *Service) Record(ip netip.Addr, httpCode, errorCode int) {
defer s.mu.Unlock()
t := s.trackers[prefix]
if t == nil {
t = &tracker{limiter: rate.NewLimiter(rate.Limit(float64(s.conf.Threshold)/s.conf.Window.Seconds()), s.conf.Threshold)}
budget := s.conf.Threshold * strikeUnits
t = &tracker{limiter: rate.NewLimiter(rate.Limit(float64(budget)/s.conf.Window.Seconds()), budget)}
s.trackers[prefix] = t
}
t.seen = now
if t.limiter.AllowN(now, weight) {
if t.limiter.AllowN(now, int(math.Round(weight*strikeUnits))) {
return // Within the strike budget, no breach
}
if !t.emitted.IsZero() && now.Sub(t.emitted) < s.conf.Window {
+28 -13
View File
@@ -15,7 +15,7 @@ var testIP = netip.MustParseAddr("1.2.3.4")
// newTestService creates a Service wired for 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 newTestService(t *testing.T, banFile string, threshold int, weights map[string]int) *Service {
func newTestService(t *testing.T, banFile string, threshold int, weights map[string]float64) *Service {
t.Helper()
s := NewService(&Config{
File: banFile,
@@ -42,7 +42,7 @@ func flushAndRead(t *testing.T, s *Service, path string) []string {
func TestService_Record_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.
s := newTestService(t, banFile, 10, map[string]int{"*": 2})
s := newTestService(t, banFile, 10, map[string]float64{"*": 2})
for i := 0; i < 5; i++ {
s.Record(testIP, 400, 40001)
}
@@ -57,7 +57,7 @@ func TestService_Record_Weight2BansAtHalfThreshold(t *testing.T) {
func TestService_Record_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.
s := newTestService(t, banFile, 10, map[string]int{"42909": 10, "*": 1})
s := newTestService(t, banFile, 10, map[string]float64{"42909": 10, "*": 1})
s.Record(testIP, 429, 42909)
s.flush()
require.NoFileExists(t, banFile)
@@ -70,7 +70,7 @@ func TestService_Record_Weight10BansFast(t *testing.T) {
func TestService_Record_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.
s := newTestService(t, banFile, 10, map[string]int{"42908": 0, "*": 1})
s := newTestService(t, banFile, 10, map[string]float64{"42908": 0, "*": 1})
for i := 0; i < 100; i++ {
s.Record(testIP, 429, 42908)
}
@@ -78,12 +78,27 @@ func TestService_Record_Weight0NeverBans(t *testing.T) {
require.NoFileExists(t, banFile)
}
func TestService_Record_FractionalWeightBansAfterManyHits(t *testing.T) {
banFile := filepath.Join(t.TempDir(), "ban.log")
// Threshold 10, code weight 0.05 -> the budget covers exactly 200 hits, so the 201st breaches.
s := newTestService(t, banFile, 10, map[string]float64{"50701": 0.05, "*": 1})
for i := 0; i < 200; i++ {
s.Record(testIP, 507, 50701)
}
s.flush()
require.NoFileExists(t, banFile)
s.Record(testIP, 507, 50701)
lines := flushAndRead(t, s, banFile)
require.Len(t, lines, 1)
require.True(t, strings.HasSuffix(lines[0], " 1.2.3.4 1.2.3.4/32 507 50701"))
}
func TestService_Record_SingleBucketNoRelaxation(t *testing.T) {
banFile := filepath.Join(t.TempDir(), "ban.log")
// One shared bucket per prefix: 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).
s := newTestService(t, banFile, 10, map[string]int{"403*": 2, "*": 1})
s := newTestService(t, banFile, 10, map[string]float64{"403*": 2, "*": 1})
s.Record(testIP, 403, 40301) // weight 2 -> 8 left
s.Record(testIP, 403, 40301) // weight 2 -> 6 left
s.Record(testIP, 403, 40301) // weight 2 -> 4 left
@@ -101,7 +116,7 @@ func TestService_Record_SingleBucketNoRelaxation(t *testing.T) {
func TestService_Record_ExactLineFormat(t *testing.T) {
banFile := filepath.Join(t.TempDir(), "ban.log")
s := newTestService(t, banFile, 1, map[string]int{"*": 1})
s := newTestService(t, banFile, 1, map[string]float64{"*": 1})
before := time.Now().UTC().Truncate(time.Second)
for i := 0; i < 3; i++ {
s.Record(testIP, 429, 42901)
@@ -124,7 +139,7 @@ func TestService_Record_ExactLineFormat(t *testing.T) {
func TestService_Record_IPv6MaskedToPrefix(t *testing.T) {
banFile := filepath.Join(t.TempDir(), "ban.log")
s := newTestService(t, banFile, 1, map[string]int{"*": 1})
s := newTestService(t, banFile, 1, map[string]float64{"*": 1})
ip := netip.MustParseAddr("2001:db8::abcd")
for i := 0; i < 3; i++ {
s.Record(ip, 429, 42901)
@@ -139,7 +154,7 @@ func TestService_Record_PerPrefixIsolation(t *testing.T) {
// Each source prefix gets its own bucket: one IP hammering to a breach must not push a different,
// quiet IP over the edge. This is the whole point of keying by prefix instead of by visitor.
banFile := filepath.Join(t.TempDir(), "ban.log")
s := newTestService(t, banFile, 2, map[string]int{"*": 1})
s := newTestService(t, banFile, 2, map[string]float64{"*": 1})
noisy := netip.MustParseAddr("1.1.1.1")
quiet := netip.MustParseAddr("2.2.2.2")
for i := 0; i < 5; i++ {
@@ -155,7 +170,7 @@ func TestService_Record_OncePerWindowThrottle(t *testing.T) {
// Once a prefix has been written, further breaches within the window must not re-append it, so a
// persistent offender produces exactly one line per window (mirrors the old per-visitor banEmit).
banFile := filepath.Join(t.TempDir(), "ban.log")
s := newTestService(t, banFile, 1, map[string]int{"*": 1})
s := newTestService(t, banFile, 1, map[string]float64{"*": 1})
for i := 0; i < 50; i++ {
s.Record(testIP, 429, 42901)
}
@@ -167,7 +182,7 @@ func TestService_Record_BansPassedIP(t *testing.T) {
// The Service bans the exact IP passed to Record -- the caller passes the offending request's IP,
// which for an account-keyed visitor is not the visitor's stored IP.
banFile := filepath.Join(t.TempDir(), "ban.log")
s := newTestService(t, banFile, 1, map[string]int{"*": 1})
s := newTestService(t, banFile, 1, map[string]float64{"*": 1})
offender := netip.MustParseAddr("5.6.7.8")
for i := 0; i < 3; i++ {
s.Record(offender, 429, 42901)
@@ -179,7 +194,7 @@ func TestService_Record_BansPassedIP(t *testing.T) {
func TestService_Record_Ignores2xx3xx(t *testing.T) {
banFile := filepath.Join(t.TempDir(), "ban.log")
s := newTestService(t, banFile, 3, map[string]int{"*": 1})
s := newTestService(t, banFile, 3, map[string]float64{"*": 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++ {
@@ -201,7 +216,7 @@ func TestService_Record_BuffersUntilFlush(t *testing.T) {
// Writes are async: a breach buffers the ban line rather than writing it synchronously on the
// request path. The line only reaches the file when runWriteLoop (or an explicit flush) runs.
banFile := filepath.Join(t.TempDir(), "ban.log")
s := newTestService(t, banFile, 1, map[string]int{"*": 1})
s := newTestService(t, banFile, 1, map[string]float64{"*": 1})
for i := 0; i < 3; i++ {
s.Record(testIP, 429, 42901)
}
@@ -232,7 +247,7 @@ func TestService_Prune_DropsIdlePrefixes(t *testing.T) {
// A prefix idle for a full window has a refilled bucket, so prune drops it to bound memory. An
// active prefix (seen within the window) is kept.
banFile := filepath.Join(t.TempDir(), "ban.log")
s := newTestService(t, banFile, 10, map[string]int{"*": 1})
s := newTestService(t, banFile, 10, map[string]float64{"*": 1})
idle := netip.MustParseAddr("9.9.9.9")
s.Record(idle, 400, 40001)
s.Record(testIP, 400, 40001)
+13 -8
View File
@@ -2,16 +2,17 @@ package ban
import (
"fmt"
"math"
"strconv"
"strings"
)
// Weights maps a matcher key to a strike weight for the abuse ban-feed (see ParseWeights, WeightFor).
type Weights map[string]int
type Weights map[string]float64
// ParseWeights normalizes a list like ["42909:10","403:2","*:1"] into a Weights map. A key is an
// ParseWeights normalizes a list like ["42909:10","403:2","50701:0.05","*:1"] into a Weights map. A key is an
// exact ntfy code, a family ("429*"), a bare HTTP status ("403" -> "403*"), or "*"; weights are ints
// >= 0 (0 = exempt). Malformed entries are rejected so misconfiguration fails at startup.
// >= 0 in steps of 0.001 (0 = exempt). Malformed entries are rejected so misconfiguration fails at startup.
func ParseWeights(entries []string) (Weights, error) {
out := make(Weights, len(entries))
for _, entry := range entries {
@@ -19,9 +20,13 @@ func ParseWeights(entries []string) (Weights, error) {
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)
weight, err := strconv.ParseFloat(strings.TrimSpace(weightStr), 64)
if err != nil || weight < 0 || math.IsInf(weight, 0) || math.IsNaN(weight) {
return nil, fmt.Errorf("invalid ban-weight value in %q, want a non-negative number", entry)
} else if weight > 0 && weight < 1.0/strikeUnits {
return nil, fmt.Errorf("invalid ban-weight value in %q, want 0 or at least %g", entry, 1.0/strikeUnits)
} else if weight > MaxWeight {
return nil, fmt.Errorf("invalid ban-weight value in %q, want at most %d", entry, MaxWeight)
}
key = strings.TrimSpace(key)
if !validWeightKey(key) {
@@ -39,9 +44,9 @@ func ParseWeights(entries []string) (Weights, error) {
// WeightFor returns the strike weight for an ntfy error code, longest-match-wins (exact > family > "*").
// If nothing matches (no "*" catch-all) it returns the implied default 1, so a forgotten "*" still
// bans; use "*:0" to exempt everything not explicitly weighted.
func (w Weights) WeightFor(errorCode int) int {
func (w Weights) WeightFor(errorCode int) float64 {
code := strconv.Itoa(errorCode)
weight, bestLen, matched := 0, -1, false
weight, bestLen, matched := 0.0, -1, false
for key, wt := range w {
matchLen := -1
switch {
+27 -13
View File
@@ -39,18 +39,32 @@ func TestParseWeights(t *testing.T) {
require.Error(t, err)
}
func TestParseWeights_Fractional(t *testing.T) {
weights, err := ParseWeights([]string{"50701:0.05", "42909:2"})
require.Nil(t, err)
require.Equal(t, 0.05, weights.WeightFor(50701))
require.Equal(t, 2.0, weights.WeightFor(42909))
weights, err = ParseWeights([]string{"50701:0.001"})
require.Nil(t, err)
require.Equal(t, 0.001, weights.WeightFor(50701))
_, err = ParseWeights([]string{"50701:0.0001"}) // Below the 0.001 resolution
require.Error(t, err)
_, err = ParseWeights([]string{"42909:1e16"}) // Would overflow once counted in thousandths
require.Error(t, err)
}
func TestWeights_WeightFor(t *testing.T) {
weights, err := ParseWeights([]string{"42908:0", "42903:0", "42905:0", "42910:0", "42909:10", "429*:1", "403*:2", "4*:1", "5*:1"})
require.NoError(t, err)
// Longest-match-wins: exact 5-digit beats "429*" beats "4*" beats "*".
require.Equal(t, 0, weights.WeightFor(42908))
require.Equal(t, 10, weights.WeightFor(42909))
require.Equal(t, 1, weights.WeightFor(42901))
require.Equal(t, 2, weights.WeightFor(40311))
require.Equal(t, 1, weights.WeightFor(40011))
require.Equal(t, 1, weights.WeightFor(50312))
require.Equal(t, 0.0, weights.WeightFor(42908))
require.Equal(t, 10.0, weights.WeightFor(42909))
require.Equal(t, 1.0, weights.WeightFor(42901))
require.Equal(t, 2.0, weights.WeightFor(40311))
require.Equal(t, 1.0, weights.WeightFor(40011))
require.Equal(t, 1.0, weights.WeightFor(50312))
// No rule matches (this config has 4*/5* but no "*"), so the implied default weight 1 applies.
require.Equal(t, 1, weights.WeightFor(30012))
require.Equal(t, 1.0, weights.WeightFor(30012))
}
func TestWeights_WeightFor_NoStarRuleImpliesWeight1(t *testing.T) {
@@ -58,16 +72,16 @@ func TestWeights_WeightFor_NoStarRuleImpliesWeight1(t *testing.T) {
// feature can't be silently turned into a no-op by forgetting "*". Explicit codes still win.
weights, err := ParseWeights([]string{"42908:0", "42909:10"})
require.NoError(t, err)
require.Equal(t, 0, weights.WeightFor(42908)) // explicitly exempt
require.Equal(t, 10, weights.WeightFor(42909)) // explicit
require.Equal(t, 1, weights.WeightFor(42901)) // unmatched -> implied 1
require.Equal(t, 1, weights.WeightFor(40001)) // unmatched -> implied 1
require.Equal(t, 0.0, weights.WeightFor(42908)) // explicitly exempt
require.Equal(t, 10.0, weights.WeightFor(42909)) // explicit
require.Equal(t, 1.0, weights.WeightFor(42901)) // unmatched -> implied 1
require.Equal(t, 1.0, weights.WeightFor(40001)) // unmatched -> implied 1
}
func TestWeights_WeightFor_ExplicitStarZeroExemptsAll(t *testing.T) {
// An explicit "*:0" is the opt-out: exempt everything not otherwise weighted.
weights, err := ParseWeights([]string{"42909:10", "*:0"})
require.NoError(t, err)
require.Equal(t, 10, weights.WeightFor(42909)) // explicit
require.Equal(t, 0, weights.WeightFor(42901)) // *:0 -> exempt everything else
require.Equal(t, 10.0, weights.WeightFor(42909)) // explicit
require.Equal(t, 0.0, weights.WeightFor(42901)) // *:0 -> exempt everything else
}
+2
View File
@@ -439,6 +439,8 @@ func execServe(c *cli.Context) error {
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 != "" && banThreshold > ban.MaxThreshold {
return fmt.Errorf("if ban-file is set, ban-threshold must be at most %d", ban.MaxThreshold)
} 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)) {
+11
View File
@@ -766,3 +766,14 @@ func (b *syncBuffer) Reset() {
defer b.mu.Unlock()
b.buf.Reset()
}
func TestCLI_Serve_BanThresholdTooLarge(t *testing.T) {
// Strikes are counted in thousandths, so a huge threshold would overflow and never ban; an
// invalid auth-default-access fails right after the checks, so the server never starts
configFile := newEmptyFile(t)
app, _, _, _ := newTestApp()
banFile := filepath.Join(t.TempDir(), "ban.log")
err := app.Run([]string{"ntfy", "serve", "--config=" + configFile, "--ban-file=" + banFile, "--ban-threshold=10000000000000000", "--auth-default-access=invalid"})
require.Error(t, err)
require.Contains(t, err.Error(), "ban-threshold")
}
+5 -3
View File
@@ -2228,7 +2228,8 @@ every line is already a confirmed offender, the fail2ban jail can ban on first s
- `ban-weights` assigns a strike weight per matcher key, formatted as `KEY:WEIGHT`. A key is an exact
ntfy error code (`42909`), a code family (`429*`, `403*`, `4*`), a bare HTTP status (`403`, short for
`403*`), or `*`. The longest matching key wins. A weight of `0` exempts a code entirely (it never
counts toward a ban), useful to spare a specific code from a `*` catch-all. Heavier weights ban faster. If you do not
counts toward a ban), useful to spare a specific code from a `*` catch-all. Heavier weights ban faster;
weights may be fractional down to `0.001` (e.g. `0.05`: 20 rejections make one strike). If you do not
include a `*` rule, any code that matches nothing defaults to weight `1` (i.e. it can be banned);
set `*:0` to exempt everything that is not explicitly weighted.
@@ -2253,7 +2254,8 @@ applies the prefix):
ban-window: "10m"
ban-threshold: 100
ban-weights:
- "42909:10" # too many auth failures -> brute force, ban fast
- "42909:2" # too many auth failures -> brute force, ban at half the threshold
- "50701:0.05" # UnifiedPush push without a subscriber -> app servers with many offline phones
# everything else 4xx/5xx defaults to weight 1
```
@@ -2629,7 +2631,7 @@ variable before running the `ntfy` command (e.g. `export NTFY_LISTEN_HTTP=:80`).
| `ban-file` | `NTFY_BAN_FILE` | *filename* | - | Abuse ban-feed: file confirmed abusive visitor IPs are appended to, for fail2ban to tail. Empty disables the feature. See [Banning bad actors](#banning-bad-actors-fail2ban) |
| `ban-window` | `NTFY_BAN_WINDOW` | *duration* | 10m | Abuse ban-feed: rolling window over which weighted strikes are counted, per IP prefix |
| `ban-threshold` | `NTFY_BAN_THRESHOLD` | *number* | 100 | Abuse ban-feed: weighted strikes per `ban-window` before a prefix is written to `ban-file` |
| `ban-weights` | `NTFY_BAN_WEIGHTS` | *list of KEY:WEIGHT* | `42909:10`| Abuse ban-feed: per-code strike weights (exact code, family `429*`, or `*`; longest match wins; `0` exempts). See [Banning bad actors](#banning-bad-actors-fail2ban) |
| `ban-weights` | `NTFY_BAN_WEIGHTS` | *list of KEY:WEIGHT* | `42909:2`, `50701:0.05`| Abuse ban-feed: per-code strike weights (exact code, family `429*`, or `*`; longest match wins; `0` exempts). See [Banning bad actors](#banning-bad-actors-fail2ban) |
| `web-root` | `NTFY_WEB_ROOT` | *path*, e.g. `/` or `/app`, or `disable` | `/` | Sets root of the web app (e.g. /, or /app), or disables it entirely (disable) |
| `enable-signup` | `NTFY_ENABLE_SIGNUP` | *boolean* (`true` or `false`) | `false` | Allows users to sign up via the web app, or API |
| `enable-login` | `NTFY_ENABLE_LOGIN` | *boolean* (`true` or `false`) | `false` | Allows users to log in via the web app, or API |
+1
View File
@@ -2326,6 +2326,7 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
* With `visitor-subscriber-rate-limiting`, a UnifiedPush push to a topic that has had no subscriber (subscribe, poll or open stream) for 4 days is answered with `410` (error code 41002) instead of `507`, so app servers like Mastodon remove the expired subscription ([#664](https://github.com/binwiederhier/ntfy/issues/664)); a Matrix push to such a topic gets its pushkey rejected (before: after 12 hours without a subscriber on the server that got the push)
* With `visitor-subscriber-rate-limiting`, UnifiedPush messages are counted against the subscriber's account only while it has a tier, and against the subscriber's IP address otherwise (also after the account lost its tier or was deleted); a tier change applies to the next message
* Ban feed: weights may be fractional (down to `0.001`), auth failures (`42909`) now weigh 2 instead of 10, and a UnifiedPush push without a subscriber (`50701`) weighs 0.05, so app servers pushing to many offline phones are not banned for it
* The rate limit state of a visitor is forgotten about a minute after it has fully recovered (all buckets refilled) instead of after a day without use, which frees the memory of one-off visitors such as floods of new IPs. The `visitors` stat and the `ntfy_visitors_total` metric therefore count the visitors of roughly the last minute, not of the last day ([#2126](https://github.com/binwiederhier/ntfy/pull/2126))
**Bug fixes + maintenance:**
+10
View File
@@ -53,3 +53,13 @@ func TestServer_BanFeed_DisabledByDefault(t *testing.T) {
response := request(t, s, "PUT", "/mytopic", "", reject) // guarded callsite, no Record
require.Equal(t, 400, response.Code)
}
func TestServer_BanFeed_DefaultWeights(t *testing.T) {
// Auth-failure floods still ban faster than other rejections, and a refused UnifiedPush push
// counts for little: a big app server pushing to many offline phones is not banned for it
weights, err := ban.ParseWeights(DefaultBanWeights)
require.Nil(t, err)
require.Equal(t, 2.0, weights.WeightFor(errHTTPTooManyRequestsLimitAuthFailure.Code))
require.Equal(t, 0.05, weights.WeightFor(errHTTPInsufficientStorageUnifiedPush.Code))
require.Equal(t, 1.0, weights.WeightFor(errHTTPTooManyRequestsLimitRequests.Code))
}
+7 -5
View File
@@ -8,6 +8,7 @@ import (
"net/netip"
"reflect"
"runtime"
"strconv"
"text/template"
"time"
@@ -56,16 +57,17 @@ const (
DefaultBanThreshold = 100 // Weighted strikes per BanWindow before a prefix is banned
)
// DefaultBanWeights is the ban-feed's default per-code strike weights: the auth-failure flood bans fast,
// and everything else defaults to weight 1 (no "*" rule needed; see BanWeights.WeightFor).
// DefaultBanWeights is the ban-feed's default per-code strike weights; everything else defaults to
// weight 1 (no "*" rule needed; see BanWeights.WeightFor).
var DefaultBanWeights = []string{
banWeight(errHTTPTooManyRequestsLimitAuthFailure, 10), // brute-force auth flood -> ban fast
banWeight(errHTTPTooManyRequestsLimitAuthFailure, 2), // Brute-force auth flood: bans at half the threshold
banWeight(errHTTPInsufficientStorageUnifiedPush, 0.05), // An app server pushing to many offline phones is not an attack
}
// 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)
func banWeight(err *errHTTP, weight float64) string {
return fmt.Sprintf("%d:%s", err.Code, strconv.FormatFloat(weight, 'f', -1, 64))
}
// Defines all global and per-visitor limits
+6 -6
View File
@@ -5963,9 +5963,9 @@ func TestServer_BanFeed_RateLimitedIPBanned(t *testing.T) {
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
c.BanThreshold = 2 // Ban after the weighted budget of 2 is exhausted
c.BanWeights = map[string]float64{"*": 1} // Every rejection costs 1 strike
c.VisitorRequestLimitBurst = 2 // 429 quickly
s := newTestServer(t, c)
got429 := 0
for i := 0; i < 10; i++ {
@@ -5987,9 +5987,9 @@ func TestServer_BanFeed_SuccessfulRequestsNotBanned(t *testing.T) {
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
c.BanThreshold = 3 // Low threshold that would catch 200s if 2xx were not skipped
c.BanWeights = map[string]float64{"*": 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)