mirror of
https://github.com/multipleof4/ntfy.git
synced 2026-10-08 21:05:21 +00:00
254 lines
9.8 KiB
Go
254 lines
9.8 KiB
Go
package ban
|
|
|
|
import (
|
|
"net/netip"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
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 {
|
|
t.Helper()
|
|
s := NewService(&Config{
|
|
File: banFile,
|
|
Window: time.Minute,
|
|
Threshold: threshold,
|
|
Weights: weights,
|
|
PrefixBitsIPv4: 32,
|
|
PrefixBitsIPv6: 64,
|
|
})
|
|
t.Cleanup(s.Close)
|
|
return s
|
|
}
|
|
|
|
// flushAndRead forces a synchronous flush of the buffered bans (writes are otherwise async, on the
|
|
// runWriteLoop ticker) and returns the ban file's lines.
|
|
func flushAndRead(t *testing.T, s *Service, path string) []string {
|
|
t.Helper()
|
|
s.flush()
|
|
data, err := os.ReadFile(path)
|
|
require.NoError(t, err)
|
|
return strings.Split(strings.TrimRight(string(data), "\n"), "\n")
|
|
}
|
|
|
|
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})
|
|
for i := 0; i < 5; i++ {
|
|
s.Record(testIP, 400, 40001)
|
|
}
|
|
s.flush()
|
|
require.NoFileExists(t, banFile) // 5 hits * weight 2 = 10 == budget, exactly at the limit, not over
|
|
s.Record(testIP, 400, 40001) // 6th hit cannot be covered -> breach
|
|
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 400 40001")) // <ip> <prefix> <http> <ntfy-code>
|
|
}
|
|
|
|
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.Record(testIP, 429, 42909)
|
|
s.flush()
|
|
require.NoFileExists(t, banFile)
|
|
s.Record(testIP, 429, 42909) // 2nd hit cannot be covered -> breach
|
|
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 429 42909"))
|
|
}
|
|
|
|
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})
|
|
for i := 0; i < 100; i++ {
|
|
s.Record(testIP, 429, 42908)
|
|
}
|
|
s.flush()
|
|
require.NoFileExists(t, banFile)
|
|
}
|
|
|
|
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.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
|
|
s.flush()
|
|
require.NoFileExists(t, banFile)
|
|
for i := 0; i < 4; i++ {
|
|
s.Record(testIP, 400, 40001) // weight 1 each -> drains the remaining 4 -> 0 left
|
|
}
|
|
s.flush()
|
|
require.NoFileExists(t, banFile) // 6 + 4 = 10 == budget exactly, still not over
|
|
s.Record(testIP, 400, 40001) // one more cannot be covered -> breach
|
|
lines := flushAndRead(t, s, banFile)
|
|
require.Len(t, lines, 1)
|
|
}
|
|
|
|
func TestService_Record_ExactLineFormat(t *testing.T) {
|
|
banFile := filepath.Join(t.TempDir(), "ban.log")
|
|
s := newTestService(t, banFile, 1, map[string]int{"*": 1})
|
|
before := time.Now().UTC().Truncate(time.Second)
|
|
for i := 0; i < 3; i++ {
|
|
s.Record(testIP, 429, 42901)
|
|
}
|
|
after := time.Now().UTC()
|
|
lines := flushAndRead(t, s, 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 TestService_Record_IPv6MaskedToPrefix(t *testing.T) {
|
|
banFile := filepath.Join(t.TempDir(), "ban.log")
|
|
s := newTestService(t, banFile, 1, map[string]int{"*": 1})
|
|
ip := netip.MustParseAddr("2001:db8::abcd")
|
|
for i := 0; i < 3; i++ {
|
|
s.Record(ip, 429, 42901)
|
|
}
|
|
parts := strings.Split(flushAndRead(t, s, 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 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})
|
|
noisy := netip.MustParseAddr("1.1.1.1")
|
|
quiet := netip.MustParseAddr("2.2.2.2")
|
|
for i := 0; i < 5; i++ {
|
|
s.Record(noisy, 429, 42901) // breaches its own bucket
|
|
}
|
|
s.Record(quiet, 429, 42901) // single hit, well under threshold
|
|
lines := flushAndRead(t, s, banFile)
|
|
require.Len(t, lines, 1) // only the noisy prefix is written
|
|
require.True(t, strings.HasSuffix(lines[0], " 1.1.1.1 1.1.1.1/32 429 42901"))
|
|
}
|
|
|
|
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})
|
|
for i := 0; i < 50; i++ {
|
|
s.Record(testIP, 429, 42901)
|
|
}
|
|
lines := flushAndRead(t, s, banFile)
|
|
require.Len(t, lines, 1)
|
|
}
|
|
|
|
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})
|
|
offender := netip.MustParseAddr("5.6.7.8")
|
|
for i := 0; i < 3; i++ {
|
|
s.Record(offender, 429, 42901)
|
|
}
|
|
parts := strings.Split(flushAndRead(t, s, banFile)[0], " ")
|
|
require.Equal(t, "5.6.7.8", parts[1]) // the IP passed to Record
|
|
require.Equal(t, "5.6.7.8/32", parts[2]) // its prefix
|
|
}
|
|
|
|
func TestService_Record_Ignores2xx3xx(t *testing.T) {
|
|
banFile := filepath.Join(t.TempDir(), "ban.log")
|
|
s := newTestService(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++ {
|
|
s.Record(testIP, 200, 20000)
|
|
s.Record(testIP, 302, 30000)
|
|
}
|
|
s.flush()
|
|
require.NoFileExists(t, banFile)
|
|
// A 4xx over the same budget still gets written.
|
|
for i := 0; i < 5; i++ {
|
|
s.Record(testIP, 400, 40001)
|
|
}
|
|
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 400 40001"))
|
|
}
|
|
|
|
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})
|
|
for i := 0; i < 3; i++ {
|
|
s.Record(testIP, 429, 42901)
|
|
}
|
|
require.NoFileExists(t, banFile) // not written synchronously
|
|
s.mu.Lock()
|
|
require.Len(t, s.pending, 1) // one line buffered (throttled to once per window)
|
|
s.mu.Unlock()
|
|
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 429 42901"))
|
|
}
|
|
|
|
func TestService_Close_FlushesPending(t *testing.T) {
|
|
// Close must flush buffered bans so nothing is lost on shutdown, and must block until it has.
|
|
banFile := filepath.Join(t.TempDir(), "ban.log")
|
|
s := NewService(&Config{File: banFile, Window: time.Minute, Threshold: 1, Weights: Weights{"*": 1}, PrefixBitsIPv4: 32, PrefixBitsIPv6: 64})
|
|
for i := 0; i < 3; i++ {
|
|
s.Record(testIP, 429, 42901)
|
|
}
|
|
require.NoFileExists(t, banFile) // still buffered
|
|
s.Close() // blocks until the final flush completes
|
|
data, err := os.ReadFile(banFile)
|
|
require.NoError(t, err)
|
|
require.Len(t, strings.Split(strings.TrimRight(string(data), "\n"), "\n"), 1)
|
|
}
|
|
|
|
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})
|
|
idle := netip.MustParseAddr("9.9.9.9")
|
|
s.Record(idle, 400, 40001)
|
|
s.Record(testIP, 400, 40001)
|
|
require.Len(t, s.trackers, 2)
|
|
|
|
// Backdate the idle prefix past the window, then prune.
|
|
idlePrefix := s.prefix(idle)
|
|
s.mu.Lock()
|
|
s.trackers[idlePrefix].seen = time.Now().Add(-2 * time.Minute)
|
|
s.mu.Unlock()
|
|
s.prune()
|
|
|
|
require.Len(t, s.trackers, 1)
|
|
_, ok := s.trackers[idlePrefix]
|
|
require.False(t, ok) // idle prefix dropped
|
|
_, ok = s.trackers[s.prefix(testIP)]
|
|
require.True(t, ok) // active prefix kept
|
|
}
|