Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7f472c40e2 |
@@ -103,11 +103,11 @@ in `bin/state` unless `SWWAF_STATE_DIR` is set, and the default rule file of
|
|||||||
window and the requests counted in it, the request that broke it, the client's
|
window and the requests counted in it, the request that broke it, the client's
|
||||||
country when it was looked up, the netblock's requests since it was first
|
country when it was looked up, the netblock's requests since it was first
|
||||||
seen, how many of them the ban has refused, and how many bans the netblock had
|
seen, how many of them the ban has refused, and how many bans the netblock had
|
||||||
before. At most `SWWAF_MAX_BANS` bans are kept, past, active and permanent;
|
before, for a broken limit, for a clear sign of attack and without a cause. At
|
||||||
past that, the earliest ban of the netblock that has gone longest without a
|
most `SWWAF_MAX_BANS` bans are kept, past, active and permanent; past that,
|
||||||
request is dropped first. `bans.json` shows the bans and their notes, a
|
the earliest ban of the netblock that has gone longest without a request is
|
||||||
restart lifts none, and you add or lift a ban by editing it (see "State files"
|
dropped first. `bans.json` shows the bans and their notes, a restart lifts
|
||||||
below).
|
none, and you add or lift a ban by editing it (see "State files" below).
|
||||||
- Checks each request against the rules of the rule files (see "Rule files"
|
- Checks each request against the rules of the rule files (see "Rule files"
|
||||||
below) after the rate limits, and before its body is read. A `log` rule that
|
below) after the rate limits, and before its body is read. A `log` rule that
|
||||||
matches is noted in the log line; a `block` rule refuses the request with
|
matches is noted in the log line; a `block` rule refuses the request with
|
||||||
|
|||||||
+35
-6
@@ -101,8 +101,19 @@ type Notes struct {
|
|||||||
// far. Both go up with each request the ban refuses.
|
// far. Both go up with each request the ban refuses.
|
||||||
Requests int64 `json:"requests"`
|
Requests int64 `json:"requests"`
|
||||||
Refused int64 `json:"refused"`
|
Refused int64 `json:"refused"`
|
||||||
// EarlierBans is how many bans the netblock had before this one.
|
// EarlierBans is how many bans the netblock had before this one, by
|
||||||
EarlierBans int `json:"earlier_bans"`
|
// 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.
|
// Request is a request in a ban's notes. Each text is cut to 256 bytes.
|
||||||
@@ -348,10 +359,7 @@ func (l *Ledger) ban(
|
|||||||
}
|
}
|
||||||
|
|
||||||
held = *bans
|
held = *bans
|
||||||
|
notes.EarlierBans = earlierBans(held)
|
||||||
// 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()
|
notes.Request = notes.Request.cut()
|
||||||
@@ -370,6 +378,27 @@ func (l *Ledger) ban(
|
|||||||
return ban
|
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.
|
// markChanged has Changed receive a value, unless one is waiting already.
|
||||||
func (l *Ledger) markChanged() {
|
func (l *Ledger) markChanged() {
|
||||||
select {
|
select {
|
||||||
|
|||||||
@@ -24,8 +24,9 @@ func TestRepeatsTripleUntilPermanent(t *testing.T) {
|
|||||||
ban := ledger.BanForLimit(netblock, now, bans.Notes{})
|
ban := ledger.BanForLimit(netblock, now, bans.Notes{})
|
||||||
|
|
||||||
length := time.Duration(hours) * time.Hour
|
length := time.Duration(hours) * time.Hour
|
||||||
if !ban.Expires.Equal(now.Add(length)) || ban.Notes.EarlierBans != i {
|
if !ban.Expires.Equal(now.Add(length)) ||
|
||||||
t.Fatalf("ban %d lasts %s with %d earlier bans, want %d hours and %d",
|
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)
|
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{})
|
first := ledger.BanForLimit(netblock, midnight(), bans.Notes{})
|
||||||
second := ledger.BanForLimit(netblock, first.Expires.Add(tc.gap), 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 {
|
if second.Expires.Sub(second.Start) != tc.want ||
|
||||||
t.Errorf("second ban lasts %s with %d earlier bans, want %s and 1",
|
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)
|
second.Expires.Sub(second.Start), second.Notes.EarlierBans, tc.want)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -235,9 +237,10 @@ func TestFullLedgerDropsTheEarlierBanOfTheNetblockBannedAgain(t *testing.T) {
|
|||||||
second := ledger.BanForLimit(netblock, first.Expires, bans.Notes{})
|
second := ledger.BanForLimit(netblock, first.Expires, bans.Notes{})
|
||||||
|
|
||||||
held := ledger.Bans(netblock)
|
held := ledger.Bans(netblock)
|
||||||
if len(held) != 1 || held[0] != second || held[0].Notes.EarlierBans != 1 {
|
if len(held) != 1 || held[0] != second ||
|
||||||
t.Errorf("the ledger holds %+v, want only the second ban, with 1 earlier ban",
|
held[0].Notes.EarlierBans != (bans.EarlierBans{Limit: 1}) {
|
||||||
held)
|
t.Errorf("the ledger holds %+v, want only the second ban, "+
|
||||||
|
"with 1 earlier ban for a limit", held)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -301,10 +304,13 @@ func TestAttackAfterAnAttackBanHasEndedBansPermanently(t *testing.T) {
|
|||||||
t.Fatal("the ban did not end")
|
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{})
|
third := ledger.BanForAttack(netblock, second.Expires.Add(30*day), bans.Notes{})
|
||||||
if !third.Permanent() || third.Notes.EarlierBans != 2 {
|
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, "+
|
t.Errorf("the next ban for an attack is %+v, want a permanent one, "+
|
||||||
"with 2 earlier bans", third)
|
"with 1 earlier ban for a limit and 1 for an attack", third)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -82,8 +82,9 @@ func TestLoadedBansCarryOn(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
again := after.BanForLimit(netblock, ban.Expires, bans.Notes{})
|
again := after.BanForLimit(netblock, ban.Expires, bans.Notes{})
|
||||||
if again.Expires.Sub(again.Start) != 3*time.Hour || again.Notes.EarlierBans != 1 {
|
if again.Expires.Sub(again.Start) != 3*time.Hour ||
|
||||||
t.Errorf("the next ban lasts %s with %d earlier bans, want 3h and 1",
|
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)
|
again.Expires.Sub(again.Start), again.Notes.EarlierBans)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -178,7 +179,8 @@ func TestNextBanWorkedOutFromTheBanThatEndedLast(t *testing.T) {
|
|||||||
Netblock: netblock,
|
Netblock: netblock,
|
||||||
Start: midnight(),
|
Start: midnight(),
|
||||||
Expires: midnight().Add(9 * time.Hour),
|
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{
|
admins := bans.Ban{
|
||||||
Netblock: netblock,
|
Netblock: netblock,
|
||||||
@@ -191,10 +193,13 @@ func TestNextBanWorkedOutFromTheBanThatEndedLast(t *testing.T) {
|
|||||||
|
|
||||||
// Once both have ended, a limit broken within the repeat window bans
|
// 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
|
// 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{})
|
ban := ledger.BanForLimit(netblock, nineHours.Expires.Add(time.Hour), bans.Notes{})
|
||||||
if ban.Expires.Sub(ban.Start) != 27*time.Hour || ban.Notes.EarlierBans != 4 {
|
if ban.Expires.Sub(ban.Start) != 27*time.Hour ||
|
||||||
t.Errorf("the next ban lasts %s with %d earlier bans, want 27h and 4",
|
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)
|
ban.Expires.Sub(ban.Start), ban.Notes.EarlierBans)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -296,7 +296,7 @@ func TestBanNotes(t *testing.T) {
|
|||||||
// refused under the ban.
|
// refused under the ban.
|
||||||
Requests: 4,
|
Requests: 4,
|
||||||
Refused: 2,
|
Refused: 2,
|
||||||
EarlierBans: 0,
|
EarlierBans: bans.EarlierBans{},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -313,8 +313,8 @@ func TestBanNotes(t *testing.T) {
|
|||||||
s.get(fromDE, http.StatusForbidden, requestlog.ActionRateLimited)
|
s.get(fromDE, http.StatusForbidden, requestlog.ActionRateLimited)
|
||||||
|
|
||||||
got = ledger.Bans(netblock)
|
got = ledger.Bans(netblock)
|
||||||
if len(got) != 2 || got[1].Notes.EarlierBans != 1 {
|
if len(got) != 2 || got[1].Notes.EarlierBans != (bans.EarlierBans{Limit: 1}) {
|
||||||
t.Errorf("bans %+v, want two, the second with one earlier ban", got)
|
t.Errorf("bans %+v, want two, the second with one earlier ban for a limit", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+30
-5
@@ -327,7 +327,7 @@ func isTarget(target string) bool {
|
|||||||
// them once percent-decoded, so that an encoded probe cannot slip past.
|
// them once percent-decoded, so that an encoded probe cannot slip past.
|
||||||
func (rule Rule) matches(r *http.Request) bool {
|
func (rule Rule) matches(r *http.Request) bool {
|
||||||
if rule.Target == "uri" {
|
if rule.Target == "uri" {
|
||||||
uri := r.URL.RequestURI()
|
uri := pathAndQuery(r)
|
||||||
|
|
||||||
return rule.regex.MatchString(uri) || rule.regex.MatchString(decodeOnce(uri))
|
return rule.regex.MatchString(uri) || rule.regex.MatchString(decodeOnce(uri))
|
||||||
}
|
}
|
||||||
@@ -337,14 +337,18 @@ func (rule Rule) matches(r *http.Request) bool {
|
|||||||
|
|
||||||
// value returns what a rule with target, other than uri, is matched
|
// 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
|
// against in r: the path and the query as the client sent them, before
|
||||||
// any decoding, and a header's values joined by ", ", as HTTP joins those
|
// any decoding or re-encoding, split at the first ?, and a header's values
|
||||||
// of a header sent more than once.
|
// joined by ", ", as HTTP joins those of a header sent more than once.
|
||||||
func value(target string, r *http.Request) string {
|
func value(target string, r *http.Request) string {
|
||||||
switch target {
|
switch target {
|
||||||
case "path":
|
case "path":
|
||||||
return r.URL.EscapedPath()
|
path, _, _ := strings.Cut(pathAndQuery(r), "?")
|
||||||
|
|
||||||
|
return path
|
||||||
case "query":
|
case "query":
|
||||||
return r.URL.RawQuery
|
_, query, _ := strings.Cut(pathAndQuery(r), "?")
|
||||||
|
|
||||||
|
return query
|
||||||
case "method":
|
case "method":
|
||||||
return r.Method
|
return r.Method
|
||||||
case "host":
|
case "host":
|
||||||
@@ -358,6 +362,27 @@ func value(target string, r *http.Request) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
// header returns the values of r's header name joined by ", ", or "" if
|
||||||
// r has no such header.
|
// r has no such header.
|
||||||
func header(r *http.Request, name string) string {
|
func header(r *http.Request, name string) string {
|
||||||
|
|||||||
@@ -95,6 +95,31 @@ func TestEachTargetMatchesWhatItNames(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
func TestMatchingStopsAtTheFirstRuleThatRefuses(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -63,7 +63,11 @@ const permanentBansJSON = `{
|
|||||||
},
|
},
|
||||||
"requests": 1500,
|
"requests": 1500,
|
||||||
"refused": 3,
|
"refused": 3,
|
||||||
"earlier_bans": 5
|
"earlier_bans": {
|
||||||
|
"limit": 3,
|
||||||
|
"attack": 1,
|
||||||
|
"without_cause": 1
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -954,7 +958,7 @@ func permanentBan() bans.Ban {
|
|||||||
},
|
},
|
||||||
Requests: 1500,
|
Requests: 1500,
|
||||||
Refused: 3,
|
Refused: 3,
|
||||||
EarlierBans: 5,
|
EarlierBans: bans.EarlierBans{Limit: 3, Attack: 1, WithoutCause: 1},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user