Rule files, and bans for a clear sign of attack (closes #24)
check / check (push) Successful in 3m13s

Every *.rules file in SWWAF_RULES_DIR is read at start and on each
change, and each request is checked against the rules after the rate
limits: log notes a match, block refuses with 403, ban refuses and bans
the netblock for SWWAF_ATTACK_BAN_DURATION, made permanent by its next
request or clear sign of attack. path, query and uri are matched as the
request line sent them. bans.json gains each ban's cause, and ban notes
count earlier bans by cause. The image ships 00-default.rules.

Judgement call: a header sent twice is matched with its values joined
by ", ".
Judgement call: SWWAF_MAX_BAN_DURATION does not cap a ban for an attack.
Not in this unit: offences for rule matches, with the error burst.

Model: opus-5-5
This commit is contained in:
2026-10-06 14:33:26 +00:00
parent 6ec52e5b87
commit 7f472c40e2
27 changed files with 2164 additions and 263 deletions
+183 -83
View File
@@ -1,7 +1,8 @@
// Package bans is the ban ledger: the bans smallwebwaf makes on the
// netblocks of clients that break a rate limit, with their notes, as the
// "Bans" section of SPEC.md describes. The bans are kept in memory, and
// written to bans.json and read from it by the state package.
// netblocks of clients that break a rate limit or show a clear sign of
// attack, with their notes, as the "Bans" section of SPEC.md describes.
// The bans are kept in memory, and written to bans.json and read from it
// by the state package.
package bans
import (
@@ -14,6 +15,15 @@ import (
"github.com/hashicorp/golang-lru/v2/simplelru"
)
// The causes of the bans smallwebwaf makes. A ban an admin adds to
// bans.json may have no cause.
const (
// CauseLimit is a ban for a broken limit.
CauseLimit = "limit"
// CauseAttack is a ban for a clear sign of attack.
CauseAttack = "attack"
)
// repeatFactor is how many times as long as the netblock's last ban a ban
// for a limit broken again within the repeat window lasts.
const repeatFactor = 3
@@ -21,32 +31,37 @@ const repeatFactor = 3
// maxTextBytes is how much of each text in a ban's notes is kept.
const maxTextBytes = 256
// Rules are how long a ban for a broken limit lasts, and how many bans
// are held.
// Rules are how long a ban lasts, and how many bans are held.
type Rules struct {
// LimitBanDuration is how long a first ban lasts.
// LimitBanDuration is how long a first ban for a broken limit lasts.
LimitBanDuration time.Duration
// LimitBanRepeatWindow is how soon after the end of the netblock's
// ban that ended last a broken limit counts as a repeat, which bans
// for repeatFactor times as long as that ban.
// ban that ended last, other than one for a clear sign of attack, a
// broken limit counts as a repeat, which bans for repeatFactor times as
// long as that ban.
LimitBanRepeatWindow time.Duration
// MaxBanDuration is the longest ban; a ban that would be longer is
// permanent instead.
// MaxBanDuration is the longest ban for a broken limit; one that would
// be longer is permanent instead.
MaxBanDuration time.Duration
// AttackBanDuration is how long a first ban for a clear sign of attack
// lasts.
AttackBanDuration time.Duration
// MaxBans is the most bans held, at least one. Past it, the earliest
// ban of the netblock that has gone longest without a request is
// dropped.
MaxBans int
}
// Ban is a ban on a netblock for a broken limit, the only kind of ban
// smallwebwaf makes so far.
// Ban is a ban on a netblock.
type Ban struct {
Netblock netip.Prefix
Start time.Time
// Expires is when the ban ends, zero for a permanent ban.
Expires time.Time
Notes Notes
// Cause is CauseLimit or CauseAttack, or "" for a ban an admin added
// without one.
Cause string
Notes Notes
}
// Permanent reports whether the ban never runs out.
@@ -66,23 +81,39 @@ func (b Ban) ActiveAt(now time.Time) bool {
type Notes struct {
// Country is the client's country, when it was looked up.
Country string `json:"country"`
// Limit, Window and Count are the limit that was broken, its window,
// "minute", "hour" or "day", and the count reached: the client's
// requests in the window, the one that broke the limit included.
// These are the requests that counted toward the ban, and the window
// is the time over which they came.
Limit int64 `json:"limit"`
Window string `json:"window"`
Count float64 `json:"count"`
// Request is the request that broke the limit.
// Limit, Window and Count are, for a ban for a broken limit, the limit
// that was broken, its window, "minute", "hour" or "day", and the
// count reached: the client's requests in the window, the one that
// broke the limit included. These are the requests that counted
// toward the ban, and the window is the time over which they came.
Limit int64 `json:"limit,omitempty"`
Window string `json:"window,omitempty"`
Count float64 `json:"count,omitempty"`
// RuleID and Target are, for a ban for a clear sign of attack, the id
// of the rule file rule that matched, and its target.
RuleID string `json:"rule_id,omitempty"`
Target string `json:"target,omitempty"`
// Request is the request that broke the limit, or that was the clear
// sign of attack.
Request Request `json:"request"`
// Requests is how many requests the netblock has sent since it was
// first seen, and Refused how many of them the ban has refused so
// far. Both go up with each request the ban refuses.
Requests int64 `json:"requests"`
Refused int64 `json:"refused"`
// EarlierBans is how many bans the netblock had before this one.
EarlierBans int `json:"earlier_bans"`
// EarlierBans is how many bans the netblock had before this one, by
// cause.
EarlierBans EarlierBans `json:"earlier_bans"`
}
// EarlierBans counts a netblock's bans before a ban, by cause.
//
//nolint:tagliatelle // the state files use snake_case, as the request log does
type EarlierBans struct {
Limit int `json:"limit"`
Attack int `json:"attack"`
// WithoutCause counts the bans an admin added without a cause.
WithoutCause int `json:"without_cause"`
}
// Request is a request in a ban's notes. Each text is cut to 256 bytes.
@@ -112,8 +143,8 @@ type Ledger struct {
netblocks *simplelru.LRU[netip.Prefix, *[]Ban]
// held is how many bans netblocks holds, at most rules.MaxBans.
held int
// made is how many bans BanForLimit has made since the start.
made int
// made is how many bans the ledger has made since the start, by cause.
made map[string]int
// v4Lengths and v6Lengths are the lengths of the IPv4 and IPv6
// netblocks that have been banned. Check looks for a ban at each of
// them, so that a ban read from bans.json refuses every client in its
@@ -135,18 +166,21 @@ func New(rules Rules) *Ledger {
rules: rules,
changed: make(chan struct{}, 1),
netblocks: netblocks,
made: map[string]int{},
}
}
// Changed receives a value after a ban is made, so that bans.json can be
// written. Several bans made before it is read leave one value.
// Changed receives a value after a ban is made or made permanent, so that
// bans.json can be written. Several changes before it is read leave one
// value.
func (l *Ledger) Changed() <-chan struct{} {
return l.changed
}
// Check is called for a request from client, at now. It reports whether
// a ban on a netblock client is in is active, and returns that ban, with
// the request counted among those it refused.
// the request counted among those it refused. A ban for a clear sign of
// attack is made permanent by the request: the netblock is malicious.
func (l *Ledger) Check(client netip.Addr, now time.Time) (Ban, bool) {
l.mu.Lock()
defer l.mu.Unlock()
@@ -159,6 +193,12 @@ func (l *Ledger) Check(client netip.Addr, now time.Time) (Ban, bool) {
ban.Notes.Requests++
ban.Notes.Refused++
if ban.Cause == CauseAttack && !ban.Permanent() {
ban.Expires = time.Time{}
l.markChanged()
}
return *ban, true
}
@@ -192,54 +232,22 @@ func activeBan(bans []Ban, now time.Time) *Ban {
// BanForLimit bans netblock at now for a broken limit, with notes, and
// returns the ban. A first ban lasts LimitBanDuration. A ban made within
// LimitBanRepeatWindow after the netblock's ban that ended last lasts
// repeatFactor times as long as that one. A ban that would be longer
// than MaxBanDuration is permanent instead. If a ban on netblock is still
// active, as when two of its requests break a limit at once, that ban is
// returned and no other is made. The ledger fills in the notes' Refused
// and EarlierBans itself.
// LimitBanRepeatWindow after the netblock's ban that ended last, other
// than one for a clear sign of attack, lasts repeatFactor times as long as
// that one. A ban that would be longer than MaxBanDuration is permanent
// instead. If a ban on netblock is still active, as when two of its
// requests break a limit at once, that ban is returned and no other is
// made. The ledger fills in the notes' Refused and EarlierBans itself.
func (l *Ledger) BanForLimit(netblock netip.Prefix, now time.Time, notes Notes) Ban {
l.mu.Lock()
defer l.mu.Unlock()
return l.ban(netblock, now, CauseLimit, notes)
}
var last *Ban
bans, found := l.netblocks.Get(netblock)
if found {
active := activeBan(*bans, now)
if active != nil {
return *active
}
// No ban is active, so each has an end. A ban an admin adds to
// bans.json can start after another and end before it, so the
// ban that ended last is looked for among them all.
ended := slices.MaxFunc(*bans, func(a, b Ban) int {
return a.Expires.Compare(b.Expires)
})
last = &ended
// The netblock's first ban held counts the bans it had before that
// one, since dropped to make room, and each ban held adds one.
notes.EarlierBans = (*bans)[0].Notes.EarlierBans + len(*bans)
}
notes.Request = notes.Request.cut()
ban := Ban{
Netblock: netblock,
Start: now,
Expires: l.expiry(last, now),
Notes: notes,
}
l.add(ban)
l.made++
select {
case l.changed <- struct{}{}:
default: // a value is waiting already
}
return ban
// BanForAttack bans netblock at now for a clear sign of attack, with
// notes, and returns the ban, as BanForLimit does. A first ban lasts
// AttackBanDuration; once the netblock has had one, the next is
// permanent.
func (l *Ledger) BanForAttack(netblock netip.Prefix, now time.Time, notes Notes) Ban {
return l.ban(netblock, now, CauseAttack, notes)
}
// Bans returns the bans held on netblock, oldest first. It is not a
@@ -256,13 +264,13 @@ func (l *Ledger) Bans(netblock netip.Prefix) []Ban {
return slices.Clone(*bans)
}
// Made returns how many bans the ledger has made since the start; bans
// read from bans.json are not among them.
func (l *Ledger) Made() int {
// Made returns how many bans for cause the ledger has made since the
// start; bans read from bans.json are not among them.
func (l *Ledger) Made(cause string) int {
l.mu.Lock()
defer l.mu.Unlock()
return l.made
return l.made[cause]
}
// Count returns how many of the bans held are active at now, and how many
@@ -332,6 +340,73 @@ func (l *Ledger) Load(bans []Ban) {
}
}
// ban bans netblock at now for cause, with notes, as BanForLimit and
// BanForAttack describe, and returns the ban.
func (l *Ledger) ban(
netblock netip.Prefix, now time.Time, cause string, notes Notes,
) Ban {
l.mu.Lock()
defer l.mu.Unlock()
// held are the netblock's bans, none of them active.
var held []Ban
bans, found := l.netblocks.Get(netblock)
if found {
active := activeBan(*bans, now)
if active != nil {
return *active
}
held = *bans
notes.EarlierBans = earlierBans(held)
}
notes.Request = notes.Request.cut()
ban := Ban{Netblock: netblock, Start: now, Cause: cause, Notes: notes}
if cause == CauseAttack {
ban.Expires = l.attackExpiry(held, now)
} else {
ban.Expires = l.limitExpiry(held, now)
}
l.add(ban)
l.made[cause]++
l.markChanged()
return ban
}
// earlierBans returns how many bans a netblock with the bans held, oldest
// first, has had, by cause: the first ban held counts the bans the
// netblock had before that one, since dropped to make room, and each ban
// held adds one.
func earlierBans(held []Ban) EarlierBans {
earlier := held[0].Notes.EarlierBans
for _, ban := range held {
switch ban.Cause {
case CauseLimit:
earlier.Limit++
case CauseAttack:
earlier.Attack++
default:
earlier.WithoutCause++
}
}
return earlier
}
// markChanged has Changed receive a value, unless one is waiting already.
func (l *Ledger) markChanged() {
select {
case l.changed <- struct{}{}:
default: // a value is waiting already
}
}
// active returns the ban active at now on a netblock client is in, or
// nil.
func (l *Ledger) active(client netip.Addr, now time.Time) *Ban {
@@ -383,12 +458,23 @@ func (l *Ledger) add(ban Ban) {
}
}
// expiry returns when a ban for a broken limit made at now ends, or zero
// when it is permanent. last is the netblock's ban that ended last, or nil
// when it has none.
func (l *Ledger) expiry(last *Ban, now time.Time) time.Time {
// limitExpiry returns when a ban for a broken limit made at now ends, or
// zero when it is permanent. held are the netblock's bans, none of them
// active, of which the one that ended last, other than a ban for a clear
// sign of attack, can make the new ban longer. A ban an admin adds to
// bans.json can start after another and end before it, so that one is
// looked for among them all.
func (l *Ledger) limitExpiry(held []Ban, now time.Time) time.Time {
length := l.rules.LimitBanDuration
var last *Ban
for i, ban := range held {
if ban.Cause != CauseAttack && (last == nil || ban.Expires.After(last.Expires)) {
last = &held[i]
}
}
if last != nil && now.Sub(last.Expires) <= l.rules.LimitBanRepeatWindow {
lastLength := last.Expires.Sub(last.Start)
// This is repeatFactor * lastLength > MaxBanDuration, written so
@@ -407,6 +493,20 @@ func (l *Ledger) expiry(last *Ban, now time.Time) time.Time {
return now.Add(length)
}
// attackExpiry returns when a ban for a clear sign of attack made at now
// ends. held are the netblock's bans, none of them active: if one of them
// is for a clear sign of attack too, the new ban is permanent, and its
// end zero; otherwise it ends AttackBanDuration later.
func (l *Ledger) attackExpiry(held []Ban, now time.Time) time.Time {
for _, ban := range held {
if ban.Cause == CauseAttack {
return time.Time{}
}
}
return now.Add(l.rules.AttackBanDuration)
}
// dropOne drops the earliest ban of the netblock that has gone longest
// without a request, and the netblock with it if that was its only ban.
func (l *Ledger) dropOne() {
+103 -7
View File
@@ -24,8 +24,9 @@ func TestRepeatsTripleUntilPermanent(t *testing.T) {
ban := ledger.BanForLimit(netblock, now, bans.Notes{})
length := time.Duration(hours) * time.Hour
if !ban.Expires.Equal(now.Add(length)) || ban.Notes.EarlierBans != i {
t.Fatalf("ban %d lasts %s with %d earlier bans, want %d hours and %d",
if !ban.Expires.Equal(now.Add(length)) ||
ban.Notes.EarlierBans != (bans.EarlierBans{Limit: i}) {
t.Fatalf("ban %d lasts %s with earlier bans %+v, want %d hours and %d for a limit",
i+1, ban.Expires.Sub(now), ban.Notes.EarlierBans, hours, i)
}
@@ -66,8 +67,9 @@ func TestRepeatWindowRunsOut(t *testing.T) {
first := ledger.BanForLimit(netblock, midnight(), bans.Notes{})
second := ledger.BanForLimit(netblock, first.Expires.Add(tc.gap), bans.Notes{})
if second.Expires.Sub(second.Start) != tc.want || second.Notes.EarlierBans != 1 {
t.Errorf("second ban lasts %s with %d earlier bans, want %s and 1",
if second.Expires.Sub(second.Start) != tc.want ||
second.Notes.EarlierBans != (bans.EarlierBans{Limit: 1}) {
t.Errorf("second ban lasts %s with earlier bans %+v, want %s and 1 for a limit",
second.Expires.Sub(second.Start), second.Notes.EarlierBans, tc.want)
}
})
@@ -235,9 +237,102 @@ func TestFullLedgerDropsTheEarlierBanOfTheNetblockBannedAgain(t *testing.T) {
second := ledger.BanForLimit(netblock, first.Expires, bans.Notes{})
held := ledger.Bans(netblock)
if len(held) != 1 || held[0] != second || held[0].Notes.EarlierBans != 1 {
t.Errorf("the ledger holds %+v, want only the second ban, with 1 earlier ban",
held)
if len(held) != 1 || held[0] != second ||
held[0].Notes.EarlierBans != (bans.EarlierBans{Limit: 1}) {
t.Errorf("the ledger holds %+v, want only the second ban, "+
"with 1 earlier ban for a limit", held)
}
}
func TestRequestDuringAnAttackBanMakesItPermanent(t *testing.T) {
t.Parallel()
ledger := bans.New(defaultRules())
netblock := netip.MustParsePrefix("203.0.113.9/32")
notes := bans.Notes{RuleID: "env-file", Target: "path"}
ban := ledger.BanForAttack(netblock, midnight(), notes)
if !ban.Expires.Equal(midnight().Add(7*day)) || ban.Cause != bans.CauseAttack ||
ban.Notes.RuleID != "env-file" || ledger.Made(bans.CauseAttack) != 1 ||
ledger.Made(bans.CauseLimit) != 0 {
t.Fatalf("the ban is %+v, with %d made for an attack and %d for a limit, "+
"want one for an attack, of seven days", ban,
ledger.Made(bans.CauseAttack), ledger.Made(bans.CauseLimit))
}
wantChanged(t, ledger, true)
// In observe mode the ban refuses nothing, and stays as it is.
got, _ := ledger.Find(netblock.Addr(), midnight().Add(time.Hour))
if got.Permanent() {
t.Fatal("a request found under the ban made it permanent")
}
// A request it refuses makes it permanent, and bans.json due.
got, _ = ledger.Check(netblock.Addr(), midnight().Add(time.Hour))
if !got.Permanent() || !ledger.Bans(netblock)[0].Permanent() {
t.Fatalf("after a request during the ban, it is %+v, want it permanent", got)
}
wantChanged(t, ledger, true)
_, banned := ledger.Check(netblock.Addr(), midnight().Add(100*365*day))
if !banned {
t.Error("the permanent ban ended")
}
}
func TestAttackAfterAnAttackBanHasEndedBansPermanently(t *testing.T) {
t.Parallel()
ledger := bans.New(defaultRules())
netblock := netip.MustParsePrefix("203.0.113.9/32")
// A ban for a broken limit before does not count.
first := ledger.BanForLimit(netblock, midnight(), bans.Notes{})
second := ledger.BanForAttack(netblock, first.Expires, bans.Notes{})
if second.Expires.Sub(second.Start) != 7*day {
t.Fatalf("the first ban for an attack lasts %s, want 7 days",
second.Expires.Sub(second.Start))
}
// Once that has run out without a request, the netblock is served, and
// its next clear sign of attack bans it for good.
_, banned := ledger.Check(netblock.Addr(), second.Expires)
if banned {
t.Fatal("the ban did not end")
}
// Its notes show the earlier ban for an attack that makes it permanent,
// beside the one for a limit.
third := ledger.BanForAttack(netblock, second.Expires.Add(30*day), bans.Notes{})
if !third.Permanent() ||
third.Notes.EarlierBans != (bans.EarlierBans{Limit: 1, Attack: 1}) {
t.Errorf("the next ban for an attack is %+v, want a permanent one, "+
"with 1 earlier ban for a limit and 1 for an attack", third)
}
}
func TestAttackBanDoesNotLengthenTheNextBanForALimit(t *testing.T) {
t.Parallel()
ledger := bans.New(defaultRules())
netblock := netip.MustParsePrefix("203.0.113.9/32")
// Three times the seven days would be permanent; a limit broken as the
// ban for an attack ends bans for an hour, as a first broken limit does.
attack := ledger.BanForAttack(netblock, midnight(), bans.Notes{})
limit := ledger.BanForLimit(netblock, attack.Expires, bans.Notes{})
if limit.Expires.Sub(limit.Start) != time.Hour || limit.Cause != bans.CauseLimit {
t.Errorf("the ban for a limit is %+v, want one of an hour", limit)
}
// And a request during the ban for a limit leaves it as it is.
got, _ := ledger.Check(netblock.Addr(), limit.Start)
if got.Permanent() {
t.Error("a request during a ban for a limit made it permanent")
}
}
@@ -269,6 +364,7 @@ func defaultRules() bans.Rules {
LimitBanDuration: time.Hour,
LimitBanRepeatWindow: day,
MaxBanDuration: 7 * day,
AttackBanDuration: 7 * day,
MaxBans: 5000,
}
}
+11 -6
View File
@@ -82,8 +82,9 @@ func TestLoadedBansCarryOn(t *testing.T) {
}
again := after.BanForLimit(netblock, ban.Expires, bans.Notes{})
if again.Expires.Sub(again.Start) != 3*time.Hour || again.Notes.EarlierBans != 1 {
t.Errorf("the next ban lasts %s with %d earlier bans, want 3h and 1",
if again.Expires.Sub(again.Start) != 3*time.Hour ||
again.Notes.EarlierBans != (bans.EarlierBans{Limit: 1}) {
t.Errorf("the next ban lasts %s with earlier bans %+v, want 3h and 1 for a limit",
again.Expires.Sub(again.Start), again.Notes.EarlierBans)
}
}
@@ -178,7 +179,8 @@ func TestNextBanWorkedOutFromTheBanThatEndedLast(t *testing.T) {
Netblock: netblock,
Start: midnight(),
Expires: midnight().Add(9 * time.Hour),
Notes: bans.Notes{EarlierBans: 2},
Cause: bans.CauseLimit,
Notes: bans.Notes{EarlierBans: bans.EarlierBans{Limit: 2}},
}
admins := bans.Ban{
Netblock: netblock,
@@ -191,10 +193,13 @@ func TestNextBanWorkedOutFromTheBanThatEndedLast(t *testing.T) {
// Once both have ended, a limit broken within the repeat window bans
// for three times the 9 hours, and the notes count the two bans
// before the 9-hour one, it, and the admin's.
// before the 9-hour one and it, for a limit, and the admin's, without
// a cause.
ban := ledger.BanForLimit(netblock, nineHours.Expires.Add(time.Hour), bans.Notes{})
if ban.Expires.Sub(ban.Start) != 27*time.Hour || ban.Notes.EarlierBans != 4 {
t.Errorf("the next ban lasts %s with %d earlier bans, want 27h and 4",
if ban.Expires.Sub(ban.Start) != 27*time.Hour ||
ban.Notes.EarlierBans != (bans.EarlierBans{Limit: 3, WithoutCause: 1}) {
t.Errorf("the next ban lasts %s with earlier bans %+v, "+
"want 27h, 3 for a limit and 1 without a cause",
ban.Expires.Sub(ban.Start), ban.Notes.EarlierBans)
}
}
+25 -3
View File
@@ -29,8 +29,8 @@ type Config struct {
UpstreamURL *url.URL
// Observe is true in observe mode, when SWWAF_MODE is observe rather
// than enforce: a request that SWWAF_DENY_NETS, a ban, the country
// lists or a rate limit would refuse is passed to the app instead, and
// no ban is made.
// lists, a rate limit or a rule would refuse is passed to the app
// instead, and no ban is made.
Observe bool
// TrustedProxies are the netblocks whose X-Forwarded-For is
// believed (SWWAF_TRUSTED_PROXIES).
@@ -85,7 +85,8 @@ type Config struct {
// BanResponse is the status a refused client is answered with, 403
// or 429, or 0 to close the connection without an answer
// (SWWAF_BAN_RESPONSE). It answers a banned client, a request that
// breaks a rate limit, SWWAF_DENY_NETS and the country lists.
// breaks a rate limit or matches a ban rule, SWWAF_DENY_NETS and the
// country lists.
BanResponse int
// LimitBanDuration is the ban for a first broken rate limit
// (SWWAF_LIMIT_BAN_DURATION). A limit broken again within
@@ -96,6 +97,9 @@ type Config struct {
LimitBanDuration time.Duration
LimitBanRepeatWindow time.Duration
MaxBanDuration time.Duration
// AttackBanDuration is the ban for a first clear sign of attack
// (SWWAF_ATTACK_BAN_DURATION). It cannot be off.
AttackBanDuration time.Duration
// MaxBans is the most bans held (SWWAF_MAX_BANS).
MaxBans int
// BanScopeV4Prefix is the length of the netblock around an IPv4
@@ -115,6 +119,10 @@ type Config struct {
// metrics (SWWAF_METRICS_TOP_N).
MetricsToken string
MetricsTopN int
// RulesDir is the directory of the rule files (SWWAF_RULES_DIR), read
// unless RulesEnabled is false (SWWAF_RULES_ENABLED).
RulesDir string
RulesEnabled bool
// settings are the values read, as given or by default, for the
// log line at start.
@@ -168,6 +176,7 @@ var (
"is not an absolute path, such as /var/lib/smallwebwaf")
errShortToken = errors.New("is shorter than 32 characters")
errNotMode = errors.New("is not enforce or observe")
errNotBoolean = errors.New("is not true or false")
)
// FromEnvironment reads the settings with lookupEnv, normally
@@ -202,6 +211,7 @@ func FromEnvironment(lookupEnv func(string) (string, bool)) (*Config, error) {
LimitBanDuration: env.durationNotOff("SWWAF_LIMIT_BAN_DURATION", "1h"),
LimitBanRepeatWindow: env.durationNotOff("SWWAF_LIMIT_BAN_REPEAT_WINDOW", "24h"),
MaxBanDuration: env.durationNotOff("SWWAF_MAX_BAN_DURATION", "7d"),
AttackBanDuration: env.durationNotOff("SWWAF_ATTACK_BAN_DURATION", "7d"),
MaxBans: env.numberNotOff("SWWAF_MAX_BANS", "5000"),
BanScopeV4Prefix: env.v4Prefix("SWWAF_BAN_SCOPE_V4_PREFIX", "32"),
StateDir: env.absolutePath("SWWAF_STATE_DIR", "/var/lib/smallwebwaf"),
@@ -209,6 +219,8 @@ func FromEnvironment(lookupEnv func(string) (string, bool)) (*Config, error) {
StateCounterInterval: env.durationNotOff("SWWAF_STATE_COUNTER_INTERVAL", "15m"),
MetricsToken: env.token("SWWAF_METRICS_TOKEN"),
MetricsTopN: env.numberNotOff("SWWAF_METRICS_TOP_N", "50"),
RulesDir: env.value("SWWAF_RULES_DIR", "/etc/smallwebwaf/rules.d"),
RulesEnabled: env.boolean("SWWAF_RULES_ENABLED", "true"),
}
for _, country := range cfg.ExclusivelyAllowedCountries {
@@ -292,6 +304,16 @@ func (e *environment) observe(name, defaultValue string) bool {
return mode == "observe"
}
// boolean reads a setting that is true or false.
func (e *environment) boolean(name, defaultValue string) bool {
value := e.value(name, defaultValue)
if value != "true" && value != "false" {
e.check(name, fmt.Errorf("%q %w", value, errNotBoolean))
}
return value == "true"
}
// netblocks reads a setting that is a list of netblocks.
func (e *environment) netblocks(name, defaultValue string) []netip.Prefix {
netblocks, err := parseNetblocks(e.value(name, defaultValue))
+25 -3
View File
@@ -40,6 +40,7 @@ const (
limitBanDuration = "SWWAF_LIMIT_BAN_DURATION"
limitBanRepeatWindow = "SWWAF_LIMIT_BAN_REPEAT_WINDOW"
maxBanDuration = "SWWAF_MAX_BAN_DURATION"
attackBanDuration = "SWWAF_ATTACK_BAN_DURATION"
maxBans = "SWWAF_MAX_BANS"
banScopeV4Prefix = "SWWAF_BAN_SCOPE_V4_PREFIX"
stateDir = "SWWAF_STATE_DIR"
@@ -47,6 +48,8 @@ const (
stateCounterInterval = "SWWAF_STATE_COUNTER_INTERVAL"
metricsToken = "SWWAF_METRICS_TOKEN" //nolint:gosec // the setting's name
metricsTopN = "SWWAF_METRICS_TOP_N"
rulesDir = "SWWAF_RULES_DIR"
rulesEnabled = "SWWAF_RULES_ENABLED"
)
// token is a token of 32 characters, the shortest allowed.
@@ -100,6 +103,7 @@ func TestDefaults(t *testing.T) {
LimitBanDuration: time.Hour,
LimitBanRepeatWindow: 24 * time.Hour,
MaxBanDuration: 7 * 24 * time.Hour,
AttackBanDuration: 7 * 24 * time.Hour,
MaxBans: 5000,
BanScopeV4Prefix: 32,
StateDir: "/var/lib/smallwebwaf",
@@ -107,6 +111,8 @@ func TestDefaults(t *testing.T) {
StateCounterInterval: 15 * time.Minute,
MetricsToken: "",
MetricsTopN: 50,
RulesDir: "/etc/smallwebwaf/rules.d",
RulesEnabled: true,
})
if cfg.UpstreamURL.String() != "http://127.0.0.1:8081" {
@@ -150,6 +156,7 @@ func TestValuesAsSet(t *testing.T) {
limitBanDuration: "15m",
limitBanRepeatWindow: "2d",
maxBanDuration: "30d",
attackBanDuration: "1d",
maxBans: "100",
banScopeV4Prefix: "24",
stateDir: "/srv/waf-state",
@@ -157,6 +164,8 @@ func TestValuesAsSet(t *testing.T) {
stateCounterInterval: "1h",
metricsToken: token,
metricsTopN: "10",
rulesDir: "/srv/waf-rules",
rulesEnabled: "false",
})
wantSettings(t, cfg, config.Config{
@@ -177,6 +186,7 @@ func TestValuesAsSet(t *testing.T) {
LimitBanDuration: 15 * time.Minute,
LimitBanRepeatWindow: 48 * time.Hour,
MaxBanDuration: 30 * 24 * time.Hour,
AttackBanDuration: 24 * time.Hour,
MaxBans: 100,
BanScopeV4Prefix: 24,
StateDir: "/srv/waf-state",
@@ -184,6 +194,8 @@ func TestValuesAsSet(t *testing.T) {
StateCounterInterval: time.Hour,
MetricsToken: token,
MetricsTopN: 10,
RulesDir: "/srv/waf-rules",
RulesEnabled: false,
})
if cfg.UpstreamURL.String() != "https://app.internal:8443/" {
@@ -354,13 +366,14 @@ func TestInvalidValueStopsTheStart(t *testing.T) {
{banResponse, "404"}, {banResponse, "drop"}, {banResponse, ""},
{limitBanDuration, off}, {limitBanDuration, "0s"}, {limitBanDuration, "1"},
{limitBanRepeatWindow, off}, {limitBanRepeatWindow, "-1h"},
{maxBanDuration, off}, {maxBanDuration, "1w"},
{maxBanDuration, off}, {maxBanDuration, "1w"}, {attackBanDuration, off},
{maxBans, off}, {maxBans, "0"}, {maxBans, "5K"},
{banScopeV4Prefix, "33"}, {banScopeV4Prefix, "-1"}, {banScopeV4Prefix, "/24"},
{stateDir, ""}, {stateDir, "state"}, {stateDir, "./var/lib/smallwebwaf"},
{stateWriteDelay, off}, {stateWriteDelay, "0s"},
{stateCounterInterval, off}, {stateCounterInterval, "15"},
{metricsTopN, off}, {metricsTopN, "0"}, {metricsTopN, "-1"},
{rulesEnabled, "yes"}, {rulesEnabled, "True"},
} {
t.Run(tc.name+"="+tc.value, func(t *testing.T) {
t.Parallel()
@@ -453,6 +466,7 @@ func TestLogsEachSettingWithItsValue(t *testing.T) {
limitBanDuration: "1h",
limitBanRepeatWindow: "24h",
maxBanDuration: "7d",
attackBanDuration: "7d",
maxBans: "5000",
banScopeV4Prefix: "32",
stateDir: "/var/lib/smallwebwaf",
@@ -460,6 +474,8 @@ func TestLogsEachSettingWithItsValue(t *testing.T) {
stateCounterInterval: "15m",
metricsToken: "",
metricsTopN: "50",
rulesDir: "/etc/smallwebwaf/rules.d",
rulesEnabled: "true",
}
if !maps.Equal(line.Settings, want) {
t.Errorf("logged settings\n%v\nwant\n%v", line.Settings, want)
@@ -489,8 +505,8 @@ func wantSettings(t *testing.T, got *config.Config, want config.Config) {
wantBanSettings(t, got, want)
}
// wantBanSettings checks the settings for bans, the state files and the
// metrics.
// wantBanSettings checks the settings for bans, the state files, the
// metrics and the rule files.
func wantBanSettings(t *testing.T, got *config.Config, want config.Config) {
t.Helper()
@@ -498,11 +514,17 @@ func wantBanSettings(t *testing.T, got *config.Config, want config.Config) {
got.LimitBanDuration != want.LimitBanDuration ||
got.LimitBanRepeatWindow != want.LimitBanRepeatWindow ||
got.MaxBanDuration != want.MaxBanDuration ||
got.AttackBanDuration != want.AttackBanDuration ||
got.MaxBans != want.MaxBans ||
got.BanScopeV4Prefix != want.BanScopeV4Prefix {
t.Errorf("ban settings\n%+v\nwant\n%+v", got, want)
}
if got.RulesDir != want.RulesDir || got.RulesEnabled != want.RulesEnabled {
t.Errorf("rule files in %q, on: %t, want %q, %t",
got.RulesDir, got.RulesEnabled, want.RulesDir, want.RulesEnabled)
}
if got.StateDir != want.StateDir ||
got.StateWriteDelay != want.StateWriteDelay ||
got.StateCounterInterval != want.StateCounterInterval {
+38 -8
View File
@@ -14,6 +14,7 @@ import (
"sneak.berlin/go/smallwebwaf/internal/bans"
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
"sneak.berlin/go/smallwebwaf/internal/requestlog"
"sneak.berlin/go/smallwebwaf/internal/rules"
)
// Metrics are smallwebwaf's metrics. They are safe for concurrent use.
@@ -30,7 +31,9 @@ type Metrics struct {
rateLimitHits *prometheus.CounterVec
sizeAndTimeLimitHits *prometheus.CounterVec
offences *prometheus.CounterVec
countries *countries
// ruleMatches are made by AddRules.
ruleMatches *prometheus.CounterVec
countries *countries
// GeoJSRequests are the requests to GeoJS, and GeoJSFailures those
// that failed. GeoJSUnanswered are the requests whose client counted
@@ -135,19 +138,22 @@ func New(topN int) *Metrics {
// AddBansAndClients adds the metrics read from the ledger and the table
// of clients as the metrics are asked for: the bans made since the start,
// the bans active and permanent at now, and the clients in the table.
// by cause, the bans active and permanent at now, and the clients in the
// table.
func (m *Metrics) AddBansAndClients(
ledger *bans.Ledger, limiter *ratelimit.Limiter, now func() time.Time,
) {
m.registry.MustRegister(
// Every ban smallwebwaf makes so far is for a broken limit.
prometheus.NewCounterFunc(prometheus.CounterOpts{
for _, cause := range []string{bans.CauseLimit, bans.CauseAttack} {
m.registry.MustRegister(prometheus.NewCounterFunc(prometheus.CounterOpts{
Name: "smallwebwaf_bans_made_total",
Help: "Bans made, by cause.",
ConstLabels: prometheus.Labels{"cause": "limit"},
ConstLabels: prometheus.Labels{"cause": cause},
}, func() float64 {
return float64(ledger.Made())
}),
return float64(ledger.Made(cause))
}))
}
m.registry.MustRegister(
prometheus.NewGaugeFunc(prometheus.GaugeOpts{
Name: "smallwebwaf_active_bans",
Help: "Bans active now, the permanent ones included.",
@@ -173,6 +179,24 @@ func (m *Metrics) AddBansAndClients(
)
}
// AddRules adds the metrics of the rule files: the requests that matched
// each rule, which RuleMatched counts, and the rules loaded from
// ruleFiles, read as the metrics are asked for. It is called once, before
// RuleMatched.
func (m *Metrics) AddRules(ruleFiles *rules.Files) {
m.ruleMatches = counterVec("smallwebwaf_rule_matches_total",
"Requests that matched a rule of the rule files, by its id and action.",
[]string{"rule_id", "action"})
m.registry.MustRegister(m.ruleMatches,
prometheus.NewGaugeFunc(prometheus.GaugeOpts{
Name: "smallwebwaf_rules_loaded",
Help: "Rules loaded from the rule files.",
}, func() float64 {
return float64(ruleFiles.Len())
}))
}
// ServeHTTP answers with the metrics in the Prometheus text format.
func (m *Metrics) ServeHTTP(w http.ResponseWriter, r *http.Request) {
m.handler.ServeHTTP(w, r)
@@ -219,6 +243,12 @@ func (m *Metrics) RequestEnded(
}
}
// RuleMatched counts a request that matched the rule id, whose action is
// action.
func (m *Metrics) RuleMatched(id, action string) {
m.ruleMatches.WithLabelValues(id, action).Inc()
}
// StateFileWritten counts a write of the state file name, of size bytes,
// that ended with err.
func (m *Metrics) StateFileWritten(name string, size int, err error) {
+41 -14
View File
@@ -6,6 +6,7 @@ import (
"sneak.berlin/go/smallwebwaf/internal/bans"
"sneak.berlin/go/smallwebwaf/internal/requestlog"
"sneak.berlin/go/smallwebwaf/internal/rules"
)
// banResponse is a refusal answered with SWWAF_BAN_RESPONSE, and logged
@@ -51,20 +52,12 @@ func (rq *request) limitBroken(now time.Time) bool {
netblock := rq.netblock()
ban := rq.h.ledger.BanForLimit(netblock, now, bans.Notes{
Country: rq.line.Country,
Limit: hit.Limit,
Window: hit.Window,
Count: hit.Requests,
Request: bans.Request{
Time: now,
Method: rq.in.Method,
Host: rq.in.Host,
Path: rq.in.URL.RequestURI(),
Status: rq.h.config.BanResponse,
UserAgent: rq.in.UserAgent(),
},
// The histories count this request only once it has ended.
Requests: rq.h.limiter.Requests(netblock) + 1,
Country: rq.line.Country,
Limit: hit.Limit,
Window: hit.Window,
Count: hit.Requests,
Request: rq.noted(now),
Requests: rq.netblockRequests(netblock),
})
rq.h.limiter.Reset(group)
rq.line.BanExpires = banExpires(ban)
@@ -72,6 +65,40 @@ func (rq *request) limitBroken(now time.Time) bool {
return true
}
// banForAttack bans the client's netblock at now for a clear sign of
// attack, the match of rule, a ban rule.
func (rq *request) banForAttack(now time.Time, rule rules.Rule) {
netblock := rq.netblock()
ban := rq.h.ledger.BanForAttack(netblock, now, bans.Notes{
Country: rq.line.Country,
RuleID: rule.ID,
Target: rule.Target,
Request: rq.noted(now),
Requests: rq.netblockRequests(netblock),
})
rq.line.BanExpires = banExpires(ban)
}
// noted is the request, refused at now with SWWAF_BAN_RESPONSE, as the
// notes of the ban it makes keep it.
func (rq *request) noted(now time.Time) bans.Request {
return bans.Request{
Time: now,
Method: rq.in.Method,
Host: rq.in.Host,
Path: rq.in.URL.RequestURI(),
Status: rq.h.config.BanResponse,
UserAgent: rq.in.UserAgent(),
}
}
// netblockRequests is how many requests netblock has sent since it was
// first seen, this one included: the histories count it only once it has
// ended.
func (rq *request) netblockRequests(netblock netip.Prefix) int64 {
return rq.h.limiter.Requests(netblock) + 1
}
// netblock is the netblock a ban on the client covers: its IPv4 address,
// widened to SWWAF_BAN_SCOPE_V4_PREFIX, or the IPv6 group clientGroup
// counts it in.
+4 -3
View File
@@ -278,6 +278,7 @@ func TestBanNotes(t *testing.T) {
Netblock: netblock,
Start: start,
Expires: start.Add(time.Hour),
Cause: bans.CauseLimit,
Notes: bans.Notes{
Country: "DE",
Limit: 1,
@@ -295,7 +296,7 @@ func TestBanNotes(t *testing.T) {
// refused under the ban.
Requests: 4,
Refused: 2,
EarlierBans: 0,
EarlierBans: bans.EarlierBans{},
},
}
@@ -312,8 +313,8 @@ func TestBanNotes(t *testing.T) {
s.get(fromDE, http.StatusForbidden, requestlog.ActionRateLimited)
got = ledger.Bans(netblock)
if len(got) != 2 || got[1].Notes.EarlierBans != 1 {
t.Errorf("bans %+v, want two, the second with one earlier ban", got)
if len(got) != 2 || got[1].Notes.EarlierBans != (bans.EarlierBans{Limit: 1}) {
t.Errorf("bans %+v, want two, the second with one earlier ban for a limit", got)
}
}
+2 -1
View File
@@ -6,6 +6,7 @@ import (
"errors"
"io"
"net/http"
"reflect"
"slices"
"strings"
"sync/atomic"
@@ -128,7 +129,7 @@ func wantRequestFields(t *testing.T, line logLine, host string, sent, received i
Action: requestlog.ActionForward, DurationTotal: line.DurationTotal,
DurationUpstreamTotal: line.DurationUpstreamTotal,
}
if line.Line != want {
if !reflect.DeepEqual(line.Line, want) {
t.Errorf("log line\n%+v\nwant\n%+v", line.Line, want)
}
+8
View File
@@ -17,6 +17,7 @@ import (
"sneak.berlin/go/smallwebwaf/internal/metrics"
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
"sneak.berlin/go/smallwebwaf/internal/requestlog"
"sneak.berlin/go/smallwebwaf/internal/rules"
)
// How smallwebwaf keeps connections to the app open between requests.
@@ -51,6 +52,9 @@ type Params struct {
// limits, bans are made and run out, and GeoJS's answers are kept,
// normally time.Now in UTC, the time the state files give.
Now func() time.Time
// Rules are the rule files' rules, which each request is checked
// against.
Rules *rules.Files
}
// Server is the server smallwebwaf runs, with the parts of the proxy
@@ -90,6 +94,7 @@ func New(params Params) *Server {
LimitBanDuration: params.Config.LimitBanDuration,
LimitBanRepeatWindow: params.Config.LimitBanRepeatWindow,
MaxBanDuration: params.Config.MaxBanDuration,
AttackBanDuration: params.Config.AttackBanDuration,
MaxBans: params.Config.MaxBans,
}),
geojs: lookup.New(lookup.Params{
@@ -98,8 +103,10 @@ func New(params Params) *Server {
ProcessLog: params.ProcessLog,
Metrics: m,
}),
rules: params.Rules,
}
m.AddBansAndClients(h.ledger, h.limiter, params.Now)
m.AddRules(params.Rules)
return &Server{
Server: &http.Server{
@@ -134,6 +141,7 @@ type handler struct {
limiter *ratelimit.Limiter
ledger *bans.Ledger
geojs *lookup.GeoJS
rules *rules.Files
}
// newTransport returns what carries requests to the app. It never goes
+17 -3
View File
@@ -17,6 +17,7 @@ import (
"sneak.berlin/go/smallwebwaf/internal/config"
"sneak.berlin/go/smallwebwaf/internal/proxy"
"sneak.berlin/go/smallwebwaf/internal/requestlog"
"sneak.berlin/go/smallwebwaf/internal/rules"
)
const (
@@ -67,6 +68,8 @@ const (
maxBanDuration = "SWWAF_MAX_BAN_DURATION"
maxBans = "SWWAF_MAX_BANS"
banScopeV4Prefix = "SWWAF_BAN_SCOPE_V4_PREFIX"
attackBanDuration = "SWWAF_ATTACK_BAN_DURATION"
rulesDir = "SWWAF_RULES_DIR"
)
// output collects what smallwebwaf writes on stdout.
@@ -197,14 +200,15 @@ func startProxyWithGeoJS(
}
// startProxyWithClock is startProxyWithGeoJS with requests counted and
// bans made by the time now tells, and returns the server as well.
// bans made by the time now tells, and returns the server as well. Unless
// env sets SWWAF_RULES_DIR, it is an empty directory, of no rules.
func startProxyWithClock(
t *testing.T, appURL, geojsURL string, now func() time.Time,
env map[string]string,
) (string, *output, *proxy.Server) {
t.Helper()
settings := map[string]string{"SWWAF_UPSTREAM_URL": appURL}
settings := map[string]string{"SWWAF_UPSTREAM_URL": appURL, rulesDir: t.TempDir()}
maps.Copy(settings, env)
cfg, err := config.FromEnvironment(func(name string) (string, bool) {
@@ -217,12 +221,22 @@ func startProxyWithClock(
}
out := &output{}
processLog := requestlog.NewProcessLogger(out)
ruleFiles, err := rules.Load(rules.Params{
Dir: cfg.RulesDir, Enabled: cfg.RulesEnabled, ProcessLog: processLog,
})
if err != nil {
t.Fatalf("rule files: %v", err)
}
server := proxy.New(proxy.Params{
Config: cfg,
RequestLog: out,
ProcessLog: requestlog.NewProcessLogger(out),
ProcessLog: processLog,
GeoJSURL: geojsURL,
Now: now,
Rules: ruleFiles,
})
listener, err := (&net.ListenConfig{}).Listen(t.Context(), "tcp", localhost+":0")
+15 -12
View File
@@ -110,20 +110,23 @@ func (h *handler) newRequest(w http.ResponseWriter, r *http.Request) *request {
// check is the one place where a request can be refused once its client
// is known, before its body is read or anything reaches the app. It
// returns nil to let the request through. The checks of checkClient come
// first, answered with SWWAF_BAN_RESPONSE, and then the size limit, so
// that a request the rate limits count is counted even when it is
// refused for its size. In observe mode a request checkClient refuses
// goes on to the size limit like any other. ctx is the request's own
// context.
// first, answered with SWWAF_BAN_RESPONSE, or 403 for a block rule, and
// then the size limit, so that a request the rate limits count is counted
// even when it is refused for its size. In observe mode a request
// checkClient refuses goes on to the size limit like any other. ctx is
// the request's own context.
func (rq *request) check(ctx context.Context) *refusal {
action := rq.checkClient(ctx)
if action != "" {
if !rq.h.config.Observe {
return rq.banResponse(action)
}
switch {
case action == "":
case rq.h.config.Observe:
// The log line names what enforce mode would have done.
rq.line.WouldAction = action
case action == requestlog.ActionRuleBlocked:
return &refusal{status: http.StatusForbidden, action: action}
default:
return rq.banResponse(action)
}
maxBytes := rq.h.config.RequestMaxBytes
@@ -145,8 +148,8 @@ func (rq *request) check(ctx context.Context) *refusal {
// client either refuses is not looked up, and then the country lists; a
// request any of them refuses is not counted for the rate limits. Then
// come the rate limits, unless the client is in
// SWWAF_RATE_LIMIT_EXEMPT_NETS, so that every other request is counted.
// ctx is the request's own context.
// SWWAF_RATE_LIMIT_EXEMPT_NETS, so that every other request is counted,
// and last the rule files. ctx is the request's own context.
func (rq *request) checkClient(ctx context.Context) string {
cfg := rq.h.config
if isInside(rq.client, cfg.AllowNets) {
@@ -171,7 +174,7 @@ func (rq *request) checkClient(ctx context.Context) string {
return requestlog.ActionRateLimited
}
return ""
return rq.checkRules(now)
}
// forward passes the request to the app and the app's answer back. ctx
+232
View File
@@ -0,0 +1,232 @@
package proxy_test
import (
"net/http"
"net/netip"
"os"
"path/filepath"
"slices"
"testing"
"time"
"sneak.berlin/go/smallwebwaf/internal/bans"
"sneak.berlin/go/smallwebwaf/internal/requestlog"
)
// testRules are the rules most tests here load: a block rule for
// /blocked and a ban rule for /.env.
const testRules = `
blocked path block ^/blocked$
probe path ban ^/\.env$
`
func TestEachRuleAction(t *testing.T) {
t.Parallel()
s, clk, server := startWithClock(t, "", map[string]string{
rulesDir: writeRules(t, "noted path log ^/\n"+testRules),
banResponse: "429",
})
start := clk.Now()
// A log rule notes its match, and lets the request through.
line := s.get(client, http.StatusOK, requestlog.ActionForward)
wantRuleIDs(t, line, "noted")
// A block rule refuses with 403, whatever SWWAF_BAN_RESPONSE is, and
// bans no one.
line = s.request(client, "/blocked", http.StatusForbidden,
requestlog.ActionRuleBlocked)
wantRuleIDs(t, line, "noted", "blocked")
s.get(client, http.StatusOK, requestlog.ActionForward)
// A ban rule refuses with SWWAF_BAN_RESPONSE, and bans the client for
// seven days, the default.
line = s.request(client, "/.env", http.StatusTooManyRequests, requestlog.ActionBanned)
wantRuleIDs(t, line, "noted", "probe")
if line.BanExpires != requestlog.FormatTime(start.Add(7*24*time.Hour)) {
t.Errorf("log line has ban_expires %q, want seven days on", line.BanExpires)
}
netblock := netip.MustParsePrefix(client + "/32")
want := bans.Ban{
Netblock: netblock,
Start: start,
Expires: start.Add(7 * 24 * time.Hour),
Cause: bans.CauseAttack,
Notes: bans.Notes{
RuleID: "probe",
Target: "path",
Request: bans.Request{
Time: start,
Method: http.MethodGet,
Host: appHost,
Path: "/.env",
Status: http.StatusTooManyRequests,
UserAgent: userAgent,
},
// The four requests up to and including the probe.
Requests: 4,
},
}
got := server.Ledger.Bans(netblock)
if len(got) != 1 || got[0] != want {
t.Fatalf("bans\n%+v\nwant\n%+v", got, want)
}
// The next request is refused under the ban, without being checked
// against the rules, and makes the ban permanent.
clk.advance(time.Hour)
line = s.get(client, http.StatusTooManyRequests, requestlog.ActionBanned)
wantRuleIDs(t, line)
if line.BanExpires != permanent {
t.Errorf("log line has ban_expires %q, want permanent", line.BanExpires)
}
clk.advance(365 * 24 * time.Hour)
s.get(client, http.StatusTooManyRequests, requestlog.ActionBanned)
}
func TestNextClearSignOfAttackAfterABanBansPermanently(t *testing.T) {
t.Parallel()
s, clk, _ := startWithClock(t, "", map[string]string{
rulesDir: writeRules(t, testRules),
attackBanDuration: "1h",
})
// The first probe bans for SWWAF_ATTACK_BAN_DURATION.
line := s.request(client, "/.env", http.StatusForbidden, requestlog.ActionBanned)
if line.BanExpires != requestlog.FormatTime(clk.Now().Add(time.Hour)) {
t.Errorf("log line has ban_expires %q, want an hour on", line.BanExpires)
}
// Once that ban has run out without a request, the client is served,
// and its next probe bans it for good.
clk.advance(time.Hour)
s.get(client, http.StatusOK, requestlog.ActionForward)
line = s.request(client, "/.env", http.StatusForbidden, requestlog.ActionBanned)
if line.BanExpires != permanent {
t.Errorf("log line has ban_expires %q, want permanent", line.BanExpires)
}
}
func TestRulesComeAfterTheOtherChecks(t *testing.T) {
t.Parallel()
const (
allowed = "192.0.2.60" // in SWWAF_ALLOW_NETS
exempt = "192.0.2.50" // in SWWAF_RATE_LIMIT_EXEMPT_NETS
)
s, _, server := startWithClock(t, "", map[string]string{
rulesDir: writeRules(t, testRules),
allowNets: allowed,
rateLimitExemptNets: exempt,
rateLimitPerMinute: "1",
})
// A client in SWWAF_ALLOW_NETS is not checked.
line := s.request(allowed, "/.env", http.StatusOK, requestlog.ActionForward)
wantRuleIDs(t, line)
// A probe over the rate limit breaks the limit before any rule sees
// it.
s.get(client, http.StatusOK, requestlog.ActionForward)
line = s.request(client, "/.env", http.StatusForbidden, requestlog.ActionRateLimited)
wantRuleIDs(t, line)
limitBan := server.Ledger.Bans(netip.MustParsePrefix(client + "/32"))
if len(limitBan) != 1 || limitBan[0].Cause != bans.CauseLimit {
t.Errorf("bans %+v, want one for a broken limit", limitBan)
}
// A client the rate limits do not apply to is still checked.
s.get(exempt, http.StatusOK, requestlog.ActionForward)
s.request(exempt, "/.env", http.StatusForbidden, requestlog.ActionBanned)
}
func TestObserveModeLogsWhatTheRulesWouldDo(t *testing.T) {
t.Parallel()
s, _, server := startWithClock(t, "", map[string]string{
rulesDir: writeRules(t, testRules),
mode: observe,
})
line := s.request(client, "/blocked", http.StatusOK, requestlog.ActionForward)
wantWouldAction(t, line, requestlog.ActionRuleBlocked)
wantRuleIDs(t, line, "blocked")
line = s.request(client, "/.env", http.StatusOK, requestlog.ActionForward)
wantWouldAction(t, line, requestlog.ActionBanned)
wantRuleIDs(t, line, "probe")
if line.BanExpires != "" {
t.Errorf("log line has ban_expires %q, want none", line.BanExpires)
}
// No ban was made.
line = s.get(client, http.StatusOK, requestlog.ActionForward)
wantWouldAction(t, line, "")
if got := server.Ledger.Snapshot(); len(got) != 0 {
t.Errorf("bans %+v, want none", got)
}
}
func TestMetricsCountRuleMatchesAndBansForAnAttack(t *testing.T) {
t.Parallel()
const scraper = "192.0.2.200"
s, _, _ := startWithClock(t, "", map[string]string{
rulesDir: writeRules(t, testRules),
metricsToken: token,
})
s.request(client, "/blocked", http.StatusForbidden, requestlog.ActionRuleBlocked)
s.request(client, "/.env", http.StatusForbidden, requestlog.ActionBanned)
s.get(client, http.StatusForbidden, requestlog.ActionBanned)
metrics := s.scrape(scraper)
wantMetric(t, metrics,
`smallwebwaf_rule_matches_total{action="block",rule_id="blocked"}`, 1)
wantMetric(t, metrics,
`smallwebwaf_rule_matches_total{action="ban",rule_id="probe"}`, 1)
wantMetric(t, metrics, "smallwebwaf_rules_loaded", 2)
wantMetric(t, metrics,
`smallwebwaf_requests_total{action="rule_blocked",status_class="4xx"}`, 1)
wantMetric(t, metrics, `smallwebwaf_bans_made_total{cause="attack"}`, 1)
wantMetric(t, metrics, `smallwebwaf_bans_made_total{cause="limit"}`, 0)
wantMetric(t, metrics, "smallwebwaf_permanent_bans", 1)
}
// writeRules writes content as a rule file into a new directory, and
// returns the directory.
func writeRules(t *testing.T, content string) string {
t.Helper()
dir := t.TempDir()
err := os.WriteFile(filepath.Join(dir, "test.rules"), []byte(content), 0o600)
if err != nil {
t.Fatalf("write the rule file: %v", err)
}
return dir
}
// wantRuleIDs checks the request log line's rule_ids.
func wantRuleIDs(t *testing.T, line logLine, want ...string) {
t.Helper()
if !slices.Equal(line.RuleIDs, want) {
t.Errorf("log line has rule_ids %v, want %v", line.RuleIDs, want)
}
}
+40
View File
@@ -0,0 +1,40 @@
package proxy
import (
"time"
"sneak.berlin/go/smallwebwaf/internal/requestlog"
"sneak.berlin/go/smallwebwaf/internal/rules"
)
// checkRules checks the request against the rules of the rule files at
// now, notes the ids of those it matches in the log line, and returns the
// action of the rule that refuses it, ActionRuleBlocked for a block rule
// and ActionBanned for a ban rule, or "" when none does. In enforce mode
// a ban rule bans the client's netblock for a clear sign of attack.
func (rq *request) checkRules(now time.Time) string {
matched := rq.h.rules.Match(rq.in)
for _, rule := range matched {
rq.line.RuleIDs = append(rq.line.RuleIDs, rule.ID)
rq.h.metrics.RuleMatched(rule.ID, rule.Action)
}
if len(matched) == 0 {
return ""
}
// Only the last rule matched can refuse the request.
switch last := matched[len(matched)-1]; last.Action {
case rules.ActionBlock:
return requestlog.ActionRuleBlocked
case rules.ActionBan:
if !rq.h.config.Observe {
rq.banForAttack(now, last)
}
return requestlog.ActionBanned
default:
return ""
}
}
+9 -2
View File
@@ -26,8 +26,12 @@ const (
// ActionRateLimited is a request refused because it took its client
// over a rate limit, which bans the client.
ActionRateLimited = "rate_limited"
// ActionBanned is a request refused because a ban covers its client.
// ActionBanned is a request refused because a ban covers its client,
// or because it matched a ban rule, which bans the client.
ActionBanned = "banned"
// ActionRuleBlocked is a request refused because it matched a block
// rule.
ActionRuleBlocked = "rule_blocked"
// ActionDenied is a request refused because its client is in
// SWWAF_DENY_NETS.
ActionDenied = "denied"
@@ -69,8 +73,11 @@ type Line struct {
Action string `json:"action"`
// WouldAction is, in observe mode, the action enforce mode would have
// taken with a request it would have refused: ActionDenied,
// ActionBanned, ActionCountryDenied or ActionRateLimited.
// ActionBanned, ActionCountryDenied, ActionRateLimited or
// ActionRuleBlocked.
WouldAction string `json:"would_action,omitempty"`
// RuleIDs are the ids of the rule file rules the request matched.
RuleIDs []string `json:"rule_ids,omitempty"`
// LimitHit is the window whose rate limit the request went over:
// minute, hour or day.
LimitHit string `json:"limit_hit,omitempty"`
+415
View File
@@ -0,0 +1,415 @@
// Package rules reads the rule files: the plain text files in
// SWWAF_RULES_DIR, one rule to a line, that each request is checked
// against, as the "Rule files" section of SPEC.md describes. They are read
// at start, and again whenever one is edited, added or removed.
package rules
import (
"context"
"encoding/hex"
"errors"
"fmt"
"log/slog"
"net/http"
"os"
"path/filepath"
"regexp"
"slices"
"strings"
"sync/atomic"
"github.com/fsnotify/fsnotify"
)
// The actions a rule takes when it matches.
const (
// ActionLog notes the match in the request log, and does nothing else.
ActionLog = "log"
// ActionBlock refuses the request with 403.
ActionBlock = "block"
// ActionBan refuses the request and bans the client's netblock: the
// request is a clear sign of attack.
ActionBan = "ban"
)
// extension ends the name of every rule file.
const extension = ".rules"
// headerTarget starts the target that is one request header,
// header:<Name>.
const headerTarget = "header:"
// escapeLength is the length of a percent escape, such as %2e.
const escapeLength = 3
var (
// ruleLine is a rule: four fields separated by spaces or tabs, of
// which the fourth, the regex, runs to the end of the line.
ruleLine = regexp.MustCompile(`^([^ \t]+)[ \t]+([^ \t]+)[ \t]+([^ \t]+)[ \t]+(.+)$`)
// idChars are the characters of a rule's id.
idChars = regexp.MustCompile(`^[A-Za-z0-9_-]+$`)
)
var (
errNotRule = errors.New(
"is not a rule: an id, a target, an action and a regex, " +
"separated by spaces or tabs")
errNotID = errors.New("is not an id of letters, digits, - and _")
errNotTarget = errors.New(
"is not path, query, uri, method, host, user_agent, referer or header:<Name>")
errNotAction = errors.New("is not log, block or ban")
errNotRegex = errors.New("does not compile")
errUsedTwice = errors.New("is already the id of the rule at")
)
// Rule is one rule of a rule file.
type Rule struct {
// ID names the rule in the request log, the metrics and ban notes.
ID string
// Target is what the regex is matched against, such as path or
// header:Accept.
Target string
// Action is ActionLog, ActionBlock or ActionBan.
Action string
regex *regexp.Regexp
}
// Params are what Load needs.
type Params struct {
// Dir is the directory of the rule files (SWWAF_RULES_DIR).
Dir string
// Enabled is SWWAF_RULES_ENABLED: while it is false, no file is read
// and no rule loaded.
Enabled bool
// ProcessLog receives how many rules were read, and the error in a
// rule file edited while smallwebwaf runs.
ProcessLog *slog.Logger
}
// Files are the rule files of a running smallwebwaf, and the rules read
// from them. They are safe for concurrent use.
type Files struct {
params Params
// rules are the rules loaded, in the order of their files' names, and
// then of their lines.
rules atomic.Pointer[[]Rule]
}
// Load reads the rules of every *.rules file in Dir, in the order of the
// files' names, unless Enabled is false. A Dir that cannot be read is an
// error, and so is a line that is not a rule, a regex that does not
// compile and an id used twice, each named with its file and line.
func Load(params Params) (*Files, error) {
f := &Files{params: params}
f.rules.Store(&[]Rule{})
if !params.Enabled {
return f, nil
}
rules, err := read(params.Dir)
if err != nil {
return nil, err
}
f.rules.Store(&rules)
f.logRead(len(rules))
return f, nil
}
// Match checks r against the rules, in order, and returns those it
// matches, up to the first whose action refuses it, block or ban, which
// is then the last one returned.
func (f *Files) Match(r *http.Request) []Rule {
var matched []Rule
for _, rule := range *f.rules.Load() {
if !rule.matches(r) {
continue
}
matched = append(matched, rule)
if rule.Action != ActionLog {
break
}
}
return matched
}
// Len returns how many rules are loaded.
func (f *Files) Len() int {
return len(*f.rules.Load())
}
// Watch watches Dir until ctx is done, and reads the rule files again
// whenever one is edited, added or removed. If they then hold an error,
// the rules stay as they were, the error is logged with its file and
// line, and the files are read again at the next change. If Dir cannot be
// watched, that is logged, and the rules stay as they were loaded. While
// Enabled is false, Watch returns at once.
func (f *Files) Watch(ctx context.Context) {
if !f.params.Enabled {
return
}
watcher, err := fsnotify.NewWatcher()
if err == nil {
defer func() {
_ = watcher.Close()
}()
err = watcher.Add(f.params.Dir)
}
if err != nil {
f.params.ProcessLog.Error("cannot watch the rule files for edits",
"error", err.Error())
return
}
f.params.ProcessLog.Info("watching the rule files for edits",
"directory", f.params.Dir)
for {
select {
case <-ctx.Done():
return
case event := <-watcher.Events:
if filepath.Ext(event.Name) == extension {
f.readAgain()
}
case err = <-watcher.Errors:
f.params.ProcessLog.Warn("watching the rule files failed",
"error", err.Error())
}
}
}
// readAgain reads the rule files again, in place of the rules loaded, or
// logs the error that keeps the rules as they were.
func (f *Files) readAgain() {
rules, err := read(f.params.Dir)
if err != nil {
f.params.ProcessLog.Error(
"a rule file has an error, and the rules stay as they were",
"error", err.Error())
return
}
f.rules.Store(&rules)
f.logRead(len(rules))
}
// logRead logs that the rule files were read, and how many rules they
// hold, which can be none.
func (f *Files) logRead(count int) {
f.params.ProcessLog.Info("read the rule files",
"directory", f.params.Dir, "rules", count)
}
// read returns the rules of every rule file in dir, in the order of the
// files' names, and then of their lines.
func read(dir string) ([]Rule, error) {
entries, err := os.ReadDir(dir)
if err != nil {
return nil, fmt.Errorf("SWWAF_RULES_DIR cannot be read: %w", err)
}
var rules []Rule
// places are where each id is, as "<file>, line <n>".
places := map[string]string{}
for _, entry := range entries {
if entry.IsDir() || filepath.Ext(entry.Name()) != extension {
continue
}
rules, err = readFile(filepath.Join(dir, entry.Name()), rules, places)
if err != nil {
return nil, err
}
}
return rules, nil
}
// readFile appends the rules of the rule file at path to rules. places
// are where each id read so far is, and gain those of the file.
func readFile(path string, rules []Rule, places map[string]string) ([]Rule, error) {
data, err := os.ReadFile(path) //nolint:gosec // a rule file, in SWWAF_RULES_DIR
if err != nil {
return nil, err
}
number := 0
for line := range strings.Lines(string(data)) {
number++
place := fmt.Sprintf("%s, line %d", path, number)
text := strings.TrimSuffix(strings.TrimSuffix(line, "\n"), "\r")
rule, isRule, err := parse(text)
if err != nil {
return nil, fmt.Errorf("%s: %w", place, err)
}
if !isRule {
continue
}
first, used := places[rule.ID]
if used {
return nil, fmt.Errorf("%s: the id %q %w %s", place, rule.ID, errUsedTwice, first)
}
places[rule.ID] = place
rules = append(rules, rule)
}
return rules, nil
}
// parse reads a line of a rule file. It returns false for a blank line
// and for a comment, a line that starts with #.
func parse(line string) (Rule, bool, error) {
line = strings.TrimLeft(line, " \t")
if line == "" || strings.HasPrefix(line, "#") {
return Rule{}, false, nil
}
fields := ruleLine.FindStringSubmatch(line)
if fields == nil {
return Rule{}, false, errNotRule
}
rule := Rule{ID: fields[1], Target: fields[2], Action: fields[3]}
switch {
case !idChars.MatchString(rule.ID):
return Rule{}, false, fmt.Errorf("the id %q %w", rule.ID, errNotID)
case !isTarget(rule.Target):
return Rule{}, false, fmt.Errorf("the target %q %w", rule.Target, errNotTarget)
case !slices.Contains([]string{ActionLog, ActionBlock, ActionBan}, rule.Action):
return Rule{}, false, fmt.Errorf("the action %q %w", rule.Action, errNotAction)
}
regex, err := regexp.Compile(fields[4])
if err != nil {
return Rule{}, false, fmt.Errorf("the regex %w: %w", errNotRegex, err)
}
rule.regex = regex
return rule, true, nil
}
// isTarget reports whether target is one a rule may have.
func isTarget(target string) bool {
switch target {
case "path", "query", "uri", "method", "host", "user_agent", "referer":
return true
}
name, isHeader := strings.CutPrefix(target, headerTarget)
return isHeader && name != ""
}
// matches reports whether the rule's regex matches its target in r. For
// uri it is matched against the path and query as received, and against
// them once percent-decoded, so that an encoded probe cannot slip past.
func (rule Rule) matches(r *http.Request) bool {
if rule.Target == "uri" {
uri := pathAndQuery(r)
return rule.regex.MatchString(uri) || rule.regex.MatchString(decodeOnce(uri))
}
return rule.regex.MatchString(value(rule.Target, r))
}
// value returns what a rule with target, other than uri, is matched
// against in r: the path and the query as the client sent them, before
// any decoding or re-encoding, split at the first ?, and a header's values
// joined by ", ", as HTTP joins those of a header sent more than once.
func value(target string, r *http.Request) string {
switch target {
case "path":
path, _, _ := strings.Cut(pathAndQuery(r), "?")
return path
case "query":
_, query, _ := strings.Cut(pathAndQuery(r), "?")
return query
case "method":
return r.Method
case "host":
return r.Host
case "user_agent":
return header(r, "User-Agent")
case "referer":
return header(r, "Referer")
default:
return header(r, strings.TrimPrefix(target, headerTarget))
}
}
// pathAndQuery returns the path and the query of r as the client sent
// them: the target of its request line, r.RequestURI, of which a target
// in absolute form, http://host/path as a client sends it to a proxy,
// gives what follows the host. r.URL is not used: when the path holds a
// character it escapes, such as \ or a non-ASCII byte, it decodes the
// whole path and escapes it again, so that \ becomes %5C and %2e a dot.
func pathAndQuery(r *http.Request) string {
if !r.URL.IsAbs() {
return r.RequestURI
}
_, afterScheme, _ := strings.Cut(r.RequestURI, "://")
start := strings.IndexAny(afterScheme, "/?")
if start < 0 {
return ""
}
return afterScheme[start:]
}
// header returns the values of r's header name joined by ", ", or "" if
// r has no such header.
func header(r *http.Request, name string) string {
return strings.Join(r.Header.Values(name), ", ")
}
// decodeOnce returns s with each percent escape, such as %2e, replaced by
// the byte it stands for. A % that is not followed by two hex digits is
// left as it is, so that a malformed escape cannot keep the rest of s
// from being decoded.
func decodeOnce(s string) string {
var decoded strings.Builder
for i := 0; i < len(s); i++ {
if s[i] == '%' && i+escapeLength <= len(s) {
b, err := hex.DecodeString(s[i+1 : i+escapeLength])
if err == nil {
decoded.Write(b)
i += escapeLength - 1
continue
}
}
decoded.WriteByte(s[i])
}
return decoded.String()
}
+595
View File
@@ -0,0 +1,595 @@
package rules_test
import (
"context"
"encoding/json"
"log/slog"
"maps"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"slices"
"strconv"
"testing"
"sneak.berlin/go/smallwebwaf/internal/rules"
)
const (
// What the process log says once Watch watches the directory, after
// each reading of the rule files, and for one that has an error.
watching = "watching the rule files for edits"
read = "read the rule files"
hasError = "a rule file has an error, and the rules stay as they were"
// maxLogLines is how many lines of the process log wait for a test to
// read them.
maxLogLines = 64
// browser is the user agent of an ordinary visitor.
browser = "Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0"
// testFile is the rule file of a test that needs only one, and
// firstFile the first of a test's rule files.
testFile = "test.rules"
firstFile = "00-a.rules"
// userAgent is the header that carries the user agent.
userAgent = "User-Agent"
)
func TestEachTargetMatchesWhatItNames(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
name string
rule string // its target, action and regex
uri string // the request's path and query
header http.Header
want bool
}{
{"path as received", `path log ^/%2eenv$`, "/%2eenv", nil, true},
{"path not decoded", `path log ^/\.env$`, "/%2eenv", nil, false},
{"path without the query", `path log ^/a$`, "/a?b=c", nil, true},
{"query as received", `query log ^b=%2e$`, "/a?b=%2e", nil, true},
{"uri as received", `uri log ^/a\?b=%2e$`, "/a?b=%2e", nil, true},
{"uri decoded", `uri log (\.\./){2}`, "/a?f=%2e%2e%2f%2e%2e%2f", nil, true},
{
"uri decoded past malformed escapes", `uri log (\.\./){2}&h=%$`,
"/a?g=%zz&f=%2e%2e%2f%2e%2e%2f&h=%", nil, true,
},
{"uri decoded only once", `uri log ^/a\.b$`, "/a%252eb", nil, false},
{"method", `method log ^PUT$`, "/", nil, true},
{"host", `host log ^app\.example$`, "/", nil, true},
{
"user_agent", `user_agent log ^sqlmap/`, "/",
http.Header{userAgent: {"sqlmap/1.8"}}, true,
},
{
"user_agent sent twice", `user_agent log ^curl/8, sqlmap/`, "/",
http.Header{userAgent: {"curl/8", "sqlmap/1.8"}}, true,
},
{"user_agent missing", `user_agent log ^$`, "/", nil, true},
{
"referer", `referer log ^https://spam\.example/`, "/",
http.Header{"Referer": {"https://spam.example/buy"}}, true,
},
{
"a header sent twice", `header:x-api-version log ^2, 3$`, "/",
http.Header{"X-Api-Version": {"2", "3"}}, true,
},
{"a header missing", `header:X-Api-Version log ^$`, "/", nil, true},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
files := load(t, ruleFiles{testFile: "a-rule " + tc.rule + "\n"})
// Every request is a PUT, which the method rule looks for.
r := httptest.NewRequestWithContext(t.Context(), http.MethodPut,
"http://app.example"+tc.uri, nil)
maps.Copy(r.Header, tc.header)
got := len(files.Match(r)) == 1
if got != tc.want {
t.Errorf("%s matches %s: %t, want %t", tc.rule, tc.uri, got, tc.want)
}
})
}
}
func TestPathMatchedAsTheClientSentIt(t *testing.T) {
t.Parallel()
// Each path holds a character Go's URL type would escape again, \ or
// a non-ASCII byte, and each rule is written for the path as sent.
for _, tc := range []struct {
rule string // its target, action and regex
sent string // the path and query the client sent
}{
{`path log ^/\.\.\\\.\.\\windows\\win\.ini$`, `/..\..\windows\win.ini`},
{`path log ^/%2e%2e\\%2e%2e\\windows\\win\.ini$`, `/%2e%2e\%2e%2e\windows\win.ini`},
{`path log ^/café$`, "/café?x=1"},
{`uri log ^/%2e%2e\\%2e%2e\\boot\.ini\?x=1$`, `/%2e%2e\%2e%2e\boot.ini?x=1`},
} {
files := load(t, ruleFiles{testFile: "as-sent " + tc.rule + "\n"})
// The target in origin form, as traefik sends it, and in absolute
// form, as a client sends it to a proxy.
for _, target := range []string{tc.sent, "http://app.example" + tc.sent} {
r := httptest.NewRequestWithContext(t.Context(), http.MethodGet, target, nil)
wantMatched(t, files, r, "as-sent")
}
}
}
func TestMatchingStopsAtTheFirstRuleThatRefuses(t *testing.T) {
t.Parallel()
files := load(t, ruleFiles{testFile: `
every-path path log ^/
no-path path log ^$
first-refusal path block ^/probe
later-ban path ban ^/probe
after path log ^/
`})
// Every log rule that matches is noted, and the block rule ends the
// matching.
wantMatched(t, files, get(t, "/probe"), "every-path", "first-refusal")
wantMatched(t, files, get(t, "/page"), "every-path", "after")
// A ban rule ends it too.
files = load(t, ruleFiles{testFile: "ban path ban ^/\nlater path block ^/\n"})
wantMatched(t, files, get(t, "/"), "ban")
}
func TestFilesReadInNameOrderThenLineOrder(t *testing.T) {
t.Parallel()
files := load(t, ruleFiles{
"50-b.rules": "b1 path log ^/\n\n# a comment\n # an indented one\nb2 path log ^/\n",
firstFile: "a1 path log ^/\r\n",
// None is a rule file.
"notes.txt": "notes, not rules\n",
"10-c.rules.bak": "an old copy\n",
"20-d.rules/keep": "a file in a directory\n",
})
wantMatched(t, files, get(t, "/"), "a1", "b1", "b2")
if files.Len() != 3 {
t.Errorf("%d rules loaded, want 3", files.Len())
}
}
func TestFaultStopsTheStartNamingTheFileAndLine(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
name string
content string
line int
want string
}{
{
"too few fields", "env-file path ban\n", 1,
"is not a rule: an id, a target, an action and a regex, " +
"separated by spaces or tabs",
},
{
"an id of other characters", "# ids\n\nenv.file path ban ^/\n", 3,
`the id "env.file" is not an id of letters, digits, - and _`,
},
{
"an unknown target", "env-file paths ban ^/\n", 1,
`the target "paths" is not path, query, uri, method, host, ` +
"user_agent, referer or header:<Name>",
},
{
"a header without a name", "env-file header: ban ^/\n", 1,
`the target "header:" is not path, query, uri, method, host, ` +
"user_agent, referer or header:<Name>",
},
{
"an unknown action", "env-file path deny ^/\n", 1,
`the action "deny" is not log, block or ban`,
},
{
"a regex that does not compile", "env-file path ban ^/(\n", 1,
"the regex does not compile: error parsing regexp: " +
"missing closing ): `^/(`",
},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
dir := writeFiles(t, ruleFiles{"00-default.rules": tc.content})
path := filepath.Join(dir, "00-default.rules")
wantRefused(t, dir, path+", line "+strconv.Itoa(tc.line)+": "+tc.want)
})
}
}
func TestIDUsedTwiceStopsTheStartNamingBothPlaces(t *testing.T) {
t.Parallel()
dir := writeFiles(t, ruleFiles{
"00-a.rules": "probe path log ^/a\n",
"50-b.rules": "other path log ^/b\nprobe path ban ^/c\n",
})
wantRefused(t, dir, filepath.Join(dir, "50-b.rules")+`, line 2: the id "probe" `+
"is already the id of the rule at "+filepath.Join(dir, "00-a.rules")+", line 1")
}
func TestDirectoryThatDoesNotExistStopsTheStart(t *testing.T) {
t.Parallel()
dir := filepath.Join(t.TempDir(), "rules.d")
wantRefused(t, dir, "SWWAF_RULES_DIR cannot be read: open "+dir+
": no such file or directory")
}
func TestEmptyDirectoryLoadsNoRulesAndSaysSo(t *testing.T) {
t.Parallel()
params, lines := newParams(writeFiles(t, ruleFiles{"00-default.rules": "# none\n"}))
files, err := rules.Load(params)
if err != nil {
t.Fatalf("load: %v", err)
}
line := lines.waitFor(t, read)
if files.Len() != 0 || line["rules"] != 0.0 {
t.Errorf("%d rules loaded, and the log says %v, want none", files.Len(), line)
}
}
func TestRuleFilesOffReadNothing(t *testing.T) {
t.Parallel()
// SWWAF_RULES_DIR does not exist, which would stop the start.
params, _ := newParams(filepath.Join(t.TempDir(), "rules.d"))
params.Enabled = false
files, err := rules.Load(params)
if err != nil {
t.Fatalf("load: %v", err)
}
if files.Len() != 0 || files.Match(get(t, "/")) != nil {
t.Errorf("%d rules loaded with the rule files off", files.Len())
}
// It would watch until the test ends.
files.Watch(t.Context())
}
func TestEditsTakenInWhileRunning(t *testing.T) {
t.Parallel()
dir := writeFiles(t, ruleFiles{firstFile: "first path block ^/first\n"})
files, lines := watch(t, dir)
// matches reports whether path matches a rule.
matches := func(path string) bool { return len(files.Match(get(t, path))) == 1 }
// A file added.
save(t, dir, "50-b.rules", "second path block ^/second\n")
lines.waitUntil(t, func() bool { return matches("/second") })
wantMatched(t, files, get(t, "/first"), "first")
// A file edited.
save(t, dir, firstFile, "first path block ^/edited\n")
lines.waitUntil(t, func() bool { return !matches("/first") })
wantMatched(t, files, get(t, "/edited"), "first")
// A file removed.
err := os.Remove(filepath.Join(dir, "50-b.rules"))
if err != nil {
t.Fatalf("remove: %v", err)
}
lines.waitUntil(t, func() bool { return !matches("/second") })
wantMatched(t, files, get(t, "/edited"), "first")
}
func TestBrokenEditKeepsTheRulesAsTheyWere(t *testing.T) {
t.Parallel()
dir := writeFiles(t, ruleFiles{firstFile: "first path block ^/first\n"})
files, lines := watch(t, dir)
// The edit's second line has an unknown action, so the rules stay as
// they were, the first line's earlier version included.
save(t, dir, firstFile, "first path block ^/edited\nsecond path bann ^/second\n")
line := lines.waitFor(t, hasError)
want := filepath.Join(dir, firstFile) +
`, line 2: the action "bann" is not log, block or ban`
if line["error"] != want || line["level"] != "ERROR" {
t.Errorf("logged %v, want an error %q", line, want)
}
wantMatched(t, files, get(t, "/first"), "first")
wantMatched(t, files, get(t, "/second"))
// Once mended, the file is read again.
save(t, dir, firstFile, "first path block ^/edited\nsecond path ban ^/second\n")
lines.waitUntil(t, func() bool { return len(files.Match(get(t, "/second"))) == 1 })
wantMatched(t, files, get(t, "/edited"), "first")
}
func TestDefaultFileBansProbesAtTheSiteRootAlone(t *testing.T) {
t.Parallel()
params, _ := newParams(filepath.Join("..", "..", "share", "rules.d"))
files, err := rules.Load(params)
if err != nil {
t.Fatalf("load the default file: %v", err)
}
// Probes sent by a browser, by the rule that refuses them.
for rule, targets := range map[string][]string{
"env-file": {"/.env", "/.env.production", "/.ENV"},
"vcs-dir": {"/.git/config", "/.git", "/.svn/entries"},
"secrets-dir": {"/.aws/credentials", "/.ssh/id_rsa"},
"secret-file": {"/.htpasswd", "/.DS_Store", "/.git-credentials"},
"editor-dir": {"/.vscode/sftp.json"},
"backup-file": {
"/wp-config.php.bak", "/index.php~", "/dump.sql", "/backup.sql.gz",
},
"log-file": {"/debug.log"},
"compose-file": {"/docker-compose.yml", "/compose.yaml"},
"php-shell": {"/shell.php"},
"path-traversal": {
"/static/../../etc/passwd", "/f?f=%2e%2e%2f%2e%2e%2fetc%2fpasswd",
},
} {
for _, target := range targets {
wantRefusedBy(t, files, target, browser, rule)
}
}
// Scanners, by their user agents.
for _, scanner := range []string{
"sqlmap/1.8.4#stable (https://sqlmap.org)",
"Mozilla/5.0 (compatible; Nuclei - Open-source project)",
} {
wantRefusedBy(t, files, "/", scanner, "scanner-agent")
}
// Ordinary requests to a code forge for files of those names deeper
// in its paths, and for other files at its root.
for _, target := range []string{
"/owner/repo/src/branch/main/.env.example",
"/owner/repo/src/branch/main/.env",
"/owner/repo/src/branch/main/.github/workflows/ci.yml",
"/owner/repo/src/branch/main/.vscode/settings.json",
"/owner/repo/src/branch/main/.htaccess",
"/owner/repo/src/branch/main/docker-compose.yml",
"/owner/repo/src/branch/main/db/schema.sql",
"/owner/repo/raw/branch/main/debug.log",
"/owner/repo.git/info/refs?service=git-upload-pack",
"/owner/repo/src/branch/main/docs/../README.md",
"/user/login?redirect_to=%2fowner%2frepo",
"/index.php",
"/.well-known/security.txt",
} {
r := get(t, target)
r.Header.Set(userAgent, browser)
matched := files.Match(r)
if len(matched) != 0 {
t.Errorf("%s matched %v, want no rule", target, ids(matched))
}
}
// A request without a user agent is only noted.
wantMatched(t, files, get(t, "/"), "empty-agent")
}
// ruleFiles are files to write into a directory of rule files, by name.
type ruleFiles map[string]string
// writeFiles writes files into a new directory, and returns it.
func writeFiles(t *testing.T, files ruleFiles) string {
t.Helper()
dir := t.TempDir()
for name, content := range files {
path := filepath.Join(dir, name)
err := os.MkdirAll(filepath.Dir(path), 0o700)
if err != nil {
t.Fatalf("mkdir: %v", err)
}
err = os.WriteFile(path, []byte(content), 0o600)
if err != nil {
t.Fatalf("write %s: %v", name, err)
}
}
return dir
}
// save writes content to the rule file name in dir as an editor that
// saves by renaming does, so that the file is never seen half written.
func save(t *testing.T, dir, name, content string) {
t.Helper()
path := filepath.Join(dir, name)
err := os.WriteFile(path+".tmp", []byte(content), 0o600)
if err != nil {
t.Fatalf("write %s: %v", name, err)
}
err = os.Rename(path+".tmp", path)
if err != nil {
t.Fatalf("rename: %v", err)
}
}
// newParams returns Params for the rule files in dir, switched on, with
// the process log in the processLog returned.
func newParams(dir string) (rules.Params, processLog) {
lines := make(processLog, maxLogLines)
return rules.Params{
Dir: dir,
Enabled: true,
ProcessLog: slog.New(slog.NewJSONHandler(lines, nil)),
}, lines
}
// load writes files into a new directory and loads the rules in it.
func load(t *testing.T, files ruleFiles) *rules.Files {
t.Helper()
params, _ := newParams(writeFiles(t, files))
params.ProcessLog = slog.New(slog.DiscardHandler)
loaded, err := rules.Load(params)
if err != nil {
t.Fatalf("load: %v", err)
}
return loaded
}
// watch loads the rules in dir, runs their Watch until the test ends, and
// waits until it watches the directory.
func watch(t *testing.T, dir string) (*rules.Files, processLog) {
t.Helper()
params, lines := newParams(dir)
files, err := rules.Load(params)
if err != nil {
t.Fatalf("load: %v", err)
}
ctx, stop := context.WithCancel(t.Context())
stopped := make(chan struct{})
go func() {
files.Watch(ctx)
close(stopped)
}()
t.Cleanup(func() {
stop()
<-stopped
})
lines.waitFor(t, watching)
return files, lines
}
// wantRefused checks that loading the rule files in dir fails with the
// error want.
func wantRefused(t *testing.T, dir, want string) {
t.Helper()
params, _ := newParams(dir)
_, err := rules.Load(params)
if err == nil || err.Error() != want {
t.Errorf("error %v, want %s", err, want)
}
}
// get returns a GET request for target, a path and an optional query, as
// smallwebwaf's server reads it, without a user agent.
func get(t *testing.T, target string) *http.Request {
t.Helper()
return httptest.NewRequestWithContext(t.Context(), http.MethodGet,
"http://app.example"+target, nil)
}
// wantRefusedBy checks that a GET request for target with the user agent
// sent matches rule alone, and that rule refuses it.
func wantRefusedBy(t *testing.T, files *rules.Files, target, sent, rule string) {
t.Helper()
r := get(t, target)
r.Header.Set(userAgent, sent)
matched := files.Match(r)
if len(matched) != 1 || matched[0].ID != rule || matched[0].Action == rules.ActionLog {
t.Errorf("%s from %q matched %v, want %s alone, refusing it", target,
sent, ids(matched), rule)
}
}
// wantMatched checks the ids of the rules r matches, in order.
func wantMatched(t *testing.T, files *rules.Files, r *http.Request, want ...string) {
t.Helper()
got := ids(files.Match(r))
if !slices.Equal(got, want) {
t.Errorf("%s matched %v, want %v", r.URL, got, want)
}
}
// ids returns the ids of matched.
func ids(matched []rules.Rule) []string {
got := make([]string, 0, len(matched))
for _, rule := range matched {
got = append(got, rule.ID)
}
return got
}
// processLog receives the lines of a process log, each a JSON object, for
// a test to wait for.
type processLog chan string
// Write receives a line of the process log.
func (l processLog) Write(line []byte) (int, error) {
l <- string(line)
return len(line), nil
}
// waitFor returns the next line of the process log whose message is msg,
// passing over the lines before it. It waits as long as that takes, so
// that a slow test process cannot fail the test.
func (l processLog) waitFor(t *testing.T, msg string) map[string]any {
t.Helper()
for line := range l {
var fields map[string]any
err := json.Unmarshal([]byte(line), &fields)
if err != nil {
t.Fatalf("process log line %q is not JSON: %v", line, err)
}
if fields["msg"] == msg {
return fields
}
}
return nil
}
// waitUntil waits for the rule files to be read until done reports true,
// as it does once they have been read after the test's last change. One
// change can be seen more than once, and so read more than once.
func (l processLog) waitUntil(t *testing.T, done func() bool) {
t.Helper()
for !done() {
l.waitFor(t, read)
}
}
+1
View File
@@ -28,6 +28,7 @@ func TestHealthCheck(t *testing.T) {
listenAddr: localhost + ":0",
upstreamURL: app.URL,
stateDir: t.TempDir(),
rulesDir: t.TempDir(),
}
go func() {
+31 -11
View File
@@ -1,6 +1,6 @@
// Package smallwebwaf runs the smallwebwaf process: it reads the settings
// and the state files, serves requests until it is told to stop, and then
// stops in an orderly way, writing the state files.
// Package smallwebwaf runs the smallwebwaf process: it reads the settings,
// the rule files and the state files, serves requests until it is told to
// stop, and then stops in an orderly way, writing the state files.
package smallwebwaf
import (
@@ -19,6 +19,7 @@ import (
"sneak.berlin/go/smallwebwaf/internal/lookup"
"sneak.berlin/go/smallwebwaf/internal/proxy"
"sneak.berlin/go/smallwebwaf/internal/requestlog"
"sneak.berlin/go/smallwebwaf/internal/rules"
"sneak.berlin/go/smallwebwaf/internal/state"
)
@@ -56,9 +57,9 @@ func Main(version string) int {
})
}
// Run reads the settings and the state files, then serves requests until
// ctx is done. It returns the process's exit status, 1 when smallwebwaf
// cannot start.
// Run reads the settings, the rule files and the state files, then serves
// requests until ctx is done. It returns the process's exit status, 1
// when smallwebwaf cannot start.
func Run(ctx context.Context, params Params) int {
processLog := requestlog.NewProcessLogger(params.Stdout)
@@ -69,6 +70,17 @@ func Run(ctx context.Context, params Params) int {
return 1
}
ruleFiles, err := rules.Load(rules.Params{
Dir: cfg.RulesDir,
Enabled: cfg.RulesEnabled,
ProcessLog: processLog,
})
if err != nil {
processLog.Error("cannot use the rule files", "error", err.Error())
return 1
}
// The state files give times in UTC.
now := func() time.Time { return time.Now().UTC() }
@@ -78,6 +90,7 @@ func Run(ctx context.Context, params Params) int {
ProcessLog: processLog,
GeoJSURL: lookup.URL,
Now: now,
Rules: ruleFiles,
})
files, err := state.Load(state.Params{
@@ -110,16 +123,16 @@ func Run(ctx context.Context, params Params) int {
"address", listener.Addr().String(),
"settings", cfg)
return serve(ctx, server.Server, listener, files, processLog)
return serve(ctx, server.Server, listener, files, ruleFiles, processLog)
}
// serve serves requests on listener, writes the state files as they are
// due, and takes in an admin's edits of them, until ctx is done. Then it
// gives the requests in progress shutdownTimeout to finish, and writes
// every state file.
// due, takes in an admin's edits of them, and reads the rule files again
// as they change, until ctx is done. Then it gives the requests in
// progress shutdownTimeout to finish, and writes every state file.
func serve(
ctx context.Context, server *http.Server, listener net.Listener,
files *state.Files, processLog *slog.Logger,
files *state.Files, ruleFiles *rules.Files, processLog *slog.Logger,
) int {
served := make(chan error, 1)
@@ -132,6 +145,7 @@ func serve(
written := make(chan struct{})
watched := make(chan struct{})
rulesWatched := make(chan struct{})
go func() {
files.Run(writing)
@@ -143,6 +157,11 @@ func serve(
close(watched)
}()
go func() {
ruleFiles.Watch(writing)
close(rulesWatched)
}()
select {
case err := <-served:
processLog.Error("serving failed", "error", err.Error())
@@ -181,6 +200,7 @@ func serve(
// missing from clients.json.
<-written
<-watched
<-rulesWatched
err = files.WriteAll()
if err != nil {
+85 -1
View File
@@ -34,6 +34,7 @@ const (
stateWriteDelay = "SWWAF_STATE_WRITE_DELAY"
stateCounterInterval = "SWWAF_STATE_COUNTER_INTERVAL"
rateLimitPerDay = "SWWAF_RATE_LIMIT_PER_DAY"
rulesDir = "SWWAF_RULES_DIR"
// greeting is what the tests' app answers.
greeting = "hello from the app"
)
@@ -163,6 +164,7 @@ func TestAddressInUseStopsTheStart(t *testing.T) {
status := run(t.Context(), map[string]string{
listenAddr: taken.Addr().String(),
stateDir: t.TempDir(),
rulesDir: t.TempDir(),
}, out)
if status != 1 {
t.Errorf("exit status %d, want 1", status)
@@ -186,9 +188,15 @@ func TestServesUntilToldToStop(t *testing.T) {
listenAddr: localhost + ":0",
upstreamURL: appURL,
stateDir: dir,
rulesDir: filepath.Join("..", "..", "share", "rules.d"),
}, out)
}()
// The default rule file is read.
if rules := out.line(t, "msg", "read the rule files")["rules"]; rules != 12.0 {
t.Errorf("read %v rules from the default rule file, want 12", rules)
}
starting := out.line(t, "msg", "starting")
wantStartingLine(t, starting, appURL, dir)
@@ -217,6 +225,7 @@ func TestStateKeptAcrossRestarts(t *testing.T) {
listenAddr: localhost + ":0",
upstreamURL: startApp(t),
stateDir: t.TempDir(),
rulesDir: t.TempDir(),
rateLimitPerDay: "2",
// Neither comes due in the test: the files are written as
// smallwebwaf stops.
@@ -253,6 +262,7 @@ func TestBanRefusesItsNetblockAfterARestartWithAnotherScope(t *testing.T) {
listenAddr: localhost + ":0",
upstreamURL: startApp(t),
stateDir: t.TempDir(),
rulesDir: t.TempDir(),
trustedProxies: localhost + "/32",
rateLimitPerDay: "1",
scope: "24",
@@ -300,6 +310,7 @@ func TestBanAddedAndLiftedByEditingBansJSON(t *testing.T) {
listenAddr: localhost + ":0",
upstreamURL: startApp(t),
stateDir: dir,
rulesDir: t.TempDir(),
trustedProxies: localhost + "/32",
// No write comes due in the test, so only the watch on the
// directory can take the edits in.
@@ -316,6 +327,51 @@ func TestBanAddedAndLiftedByEditingBansJSON(t *testing.T) {
})
}
func TestRuleFileAddedWhileRunningTakesEffect(t *testing.T) {
t.Parallel()
dir := t.TempDir()
env := map[string]string{
listenAddr: localhost + ":0",
upstreamURL: startApp(t),
stateDir: t.TempDir(),
rulesDir: dir,
// The requests sent until the rule takes effect must not break a
// rate limit, whose ban would refuse them too.
"SWWAF_RATE_LIMIT_PER_MINUTE": "off",
}
out := runUntilStopped(t, env, func(url string) {
wantGreeting(t, url)
saveUntilAnswered(t, filepath.Join(dir, "50-app.rules"),
"everything path block ^/\n", url, "203.0.113.9", http.StatusForbidden)
})
out.line(t, "action", "rule_blocked")
}
func TestRuleFileThatDoesNotParseStopsTheStart(t *testing.T) {
t.Parallel()
dir := t.TempDir()
path := filepath.Join(dir, "00-default.rules")
err := os.WriteFile(path, []byte("# probes\nenv-file path bann ^/\\.env$\n"), 0o600)
if err != nil {
t.Fatalf("write the rule file: %v", err)
}
wantRulesRefused(t, dir, path+`, line 2: the action "bann" is not log, block or ban`)
}
func TestRulesDirThatDoesNotExistStopsTheStart(t *testing.T) {
t.Parallel()
dir := filepath.Join(t.TempDir(), "rules.d")
wantRulesRefused(t, dir, "SWWAF_RULES_DIR cannot be read: open "+dir+
": no such file or directory")
}
func TestStateFileThatDoesNotParseStopsTheStart(t *testing.T) {
t.Parallel()
@@ -349,7 +405,9 @@ func wantStartRefused(t *testing.T, dir, want string) {
out := &output{}
status := run(ctx, map[string]string{listenAddr: localhost + ":0", stateDir: dir}, out)
status := run(ctx, map[string]string{
listenAddr: localhost + ":0", stateDir: dir, rulesDir: t.TempDir(),
}, out)
if status != 1 {
t.Fatalf("exit status %d, want 1", status)
}
@@ -362,6 +420,30 @@ func wantStartRefused(t *testing.T, dir, want string) {
}
}
// wantRulesRefused runs smallwebwaf with its rule files in dir, and
// checks that it stops at start, with the error want. If it starts
// instead, it is stopped after waitLimit.
func wantRulesRefused(t *testing.T, dir, want string) {
t.Helper()
ctx, stop := context.WithTimeout(t.Context(), waitLimit)
defer stop()
out := &output{}
status := run(ctx, map[string]string{
listenAddr: localhost + ":0", stateDir: t.TempDir(), rulesDir: dir,
}, out)
if status != 1 {
t.Fatalf("exit status %d, want 1", status)
}
line := out.line(t, "msg", "cannot use the rule files")
if line["error"] != want || line["level"] != "ERROR" {
t.Errorf("start refused with %v, want the error %q", line, want)
}
}
// startApp starts an app that answers every request with greeting, and
// returns its URL.
func startApp(t *testing.T) string {
@@ -442,8 +524,10 @@ func wantStartingLine(t *testing.T, line map[string]any, appURL, dir string) {
"SWWAF_LIMIT_BAN_DURATION": "1h",
"SWWAF_LIMIT_BAN_REPEAT_WINDOW": "24h",
"SWWAF_MAX_BAN_DURATION": "7d",
"SWWAF_ATTACK_BAN_DURATION": "7d",
"SWWAF_MAX_BANS": "5000",
"SWWAF_BAN_SCOPE_V4_PREFIX": "32",
"SWWAF_RULES_ENABLED": "true",
}
for name, value := range want {
+12 -4
View File
@@ -48,6 +48,7 @@ var (
errVersion = errors.New("unknown version")
// errMissing is for an entry without a field it needs.
errMissing = errors.New("has no")
errCause = errors.New("is not limit or attack")
)
// Params are what Load needs.
@@ -95,11 +96,12 @@ type bansFile struct {
}
// banEntry is a ban as bans.json holds it: a permanent ban's expires is
// null.
// null, and a ban an admin added may have no cause.
type banEntry struct {
Netblock netip.Prefix `json:"netblock"`
Start time.Time `json:"start"`
Expires *time.Time `json:"expires"`
Cause string `json:"cause,omitempty"`
Notes bans.Notes `json:"notes"`
}
@@ -444,7 +446,9 @@ func (f *Files) encode(name string) ([]byte, error) {
// newBanEntry returns ban as bans.json holds it.
func newBanEntry(ban bans.Ban) banEntry {
entry := banEntry{Netblock: ban.Netblock, Start: ban.Start, Notes: ban.Notes}
entry := banEntry{
Netblock: ban.Netblock, Start: ban.Start, Cause: ban.Cause, Notes: ban.Notes,
}
if !ban.Permanent() {
entry.Expires = &ban.Expires
}
@@ -454,7 +458,7 @@ func newBanEntry(ban bans.Ban) banEntry {
// ban returns the ban an entry of bans.json holds.
func (e banEntry) ban() bans.Ban {
ban := bans.Ban{Netblock: e.Netblock, Start: e.Start, Notes: e.Notes}
ban := bans.Ban{Netblock: e.Netblock, Start: e.Start, Cause: e.Cause, Notes: e.Notes}
if e.Expires != nil {
ban.Expires = *e.Expires
}
@@ -466,7 +470,8 @@ func (e banEntry) ban() bans.Ban {
// client, a start, from which the length of the netblock's next ban is
// worked out, or an expires, which would make it permanent. A permanent
// ban's expires is null, which Bans cannot tell from a missing one, so
// each expires is read again as written.
// each expires is read again as written. A cause other than limit or
// attack, most likely misspelt, is refused too.
func (f *bansFile) check(data []byte) error {
var written struct {
Bans []struct {
@@ -487,6 +492,9 @@ func (f *bansFile) check(data []byte) error {
return missing(i, "start")
case written.Bans[i].Expires == nil:
return missing(i, "expires")
case entry.Cause != "" && entry.Cause != bans.CauseLimit &&
entry.Cause != bans.CauseAttack:
return fmt.Errorf("entry %d's cause %q %w", i+1, entry.Cause, errCause)
}
}
+23 -4
View File
@@ -63,7 +63,11 @@ const permanentBansJSON = `{
},
"requests": 1500,
"refused": 3,
"earlier_bans": 5
"earlier_bans": {
"limit": 3,
"attack": 1,
"without_cause": 1
}
}
}
]
@@ -263,6 +267,17 @@ func TestEntryWithoutAFieldItNeedsStopsTheStart(t *testing.T) {
}
}
func TestBanWithACauseSmallwebwafDoesNotGiveStopsTheStart(t *testing.T) {
t.Parallel()
wantRefused(t, bansJSON, `{"version": 1, "bans": [`+
`{"netblock": "203.0.113.9/32", "start": "2026-10-06T00:00:00Z", `+
`"expires": null, "cause": "attack"}, `+
`{"netblock": "203.0.113.10/32", "start": "2026-10-06T00:00:00Z", `+
`"expires": null, "cause": "atack"}]}`,
`: entry 2's cause "atack" is not limit or attack`)
}
func TestUnknownVersionStopsTheStart(t *testing.T) {
t.Parallel()
@@ -881,6 +896,7 @@ func newParams(dir string) state.Params {
LimitBanDuration: time.Hour,
LimitBanRepeatWindow: 24 * time.Hour,
MaxBanDuration: 7 * 24 * time.Hour,
AttackBanDuration: 7 * 24 * time.Hour,
MaxBans: 5000,
}),
Limiter: ratelimit.New(ratelimit.Limits{}),
@@ -893,14 +909,17 @@ func newParams(dir string) state.Params {
}
}
// fill puts a ban that ends and one that does not, clients with counts
// and histories, and GeoJS answers into the parts of params.
// fill puts a permanent ban without a cause, as an admin adds one, a ban
// for a broken limit and one for a clear sign of attack, clients with
// counts and histories, and GeoJS answers into the parts of params.
func fill(params state.Params) {
now := midnight()
client := netip.MustParsePrefix("203.0.113.9/32")
params.Ledger.Load([]bans.Ban{permanentBan()})
params.Ledger.BanForLimit(client, now, bans.Notes{Country: "DE", Limit: 1})
params.Ledger.BanForAttack(netip.MustParsePrefix("192.0.2.1/32"), now,
bans.Notes{RuleID: "env-file", Target: "path"})
for _, c := range []string{"2001:db8::/64", "203.0.113.9/32", "192.0.2.1/32"} {
params.Limiter.Count(netip.MustParsePrefix(c), now)
@@ -939,7 +958,7 @@ func permanentBan() bans.Ban {
},
Requests: 1500,
Refused: 3,
EarlierBans: 5,
EarlierBans: bans.EarlierBans{Limit: 3, Attack: 1, WithoutCause: 1},
},
}
}