Compare commits
2
Commits
174b6edca7
...
aea5392afd
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aea5392afd | ||
|
|
9bcbe5a3c8 |
No files matched your search
@@ -37,3 +37,6 @@ These are the rules for agents working on this repository.
|
|||||||
- `192.168.0.0/16`.
|
- `192.168.0.0/16`.
|
||||||
- `SPEC.md` states that default.
|
- `SPEC.md` states that default.
|
||||||
- Forwarded headers count only when the peer is a trusted proxy.
|
- Forwarded headers count only when the peer is a trusted proxy.
|
||||||
|
- Every time is in UTC, whatever `TZ` or the host's time zone says.
|
||||||
|
- Every day is a date written `YYYY-MM-DD`, never a time.
|
||||||
|
- `SPEC.md` states this near its start.
|
||||||
@@ -509,7 +509,8 @@ request reaches the app.
|
|||||||
|
|
||||||
`waf` takes the checks:
|
`waf` takes the checks:
|
||||||
|
|
||||||
3. The tag lists.
|
3. The header limits, then the tag lists. A client in `SWW_ALLOW_NETS` skips the
|
||||||
|
header limits.
|
||||||
4. The static lists, `SWW_ALLOW_NETS` and `SWW_DENY_NETS`.
|
4. The static lists, `SWW_ALLOW_NETS` and `SWW_DENY_NETS`.
|
||||||
5. The bans.
|
5. The bans.
|
||||||
6. The lookup of the client's AS number and country.
|
6. The lookup of the client's AS number and country.
|
||||||
@@ -521,7 +522,7 @@ request reaches the app.
|
|||||||
11. The rate limits.
|
11. The rate limits.
|
||||||
12. The trap paths.
|
12. The trap paths.
|
||||||
13. The rule files.
|
13. The rule files.
|
||||||
14. The header limits, and then the Core Rule Set.
|
14. The Core Rule Set.
|
||||||
15. The tag refusals again, so that `exploitsignature` can refuse too.
|
15. The tag refusals again, so that `exploitsignature` can refuse too.
|
||||||
16. A path under `/_sww/` is answered `404`, so that the app never sees that
|
16. A path under `/_sww/` is answered `404`, so that the app never sees that
|
||||||
prefix.
|
prefix.
|
||||||
@@ -537,7 +538,7 @@ request reaches the app.
|
|||||||
A refused visitor is answered with `SWW_BAN_RESPONSE`. A `block` rule or a Core
|
A refused visitor is answered with `SWW_BAN_RESPONSE`. A `block` rule or a Core
|
||||||
Rule Set match answers `403` and bans nobody, since the Core Rule Set has false
|
Rule Set match answers `403` and bans nobody, since the Core Rule Set has false
|
||||||
positives; such refusals count toward `SWW_ERROR_BURST_THRESHOLD` instead. A
|
positives; such refusals count toward `SWW_ERROR_BURST_THRESHOLD` instead. A
|
||||||
request over the header limits of step 14 is answered `431`, and bans nobody
|
request over the header limits of step 3 is answered `431`, and bans nobody
|
||||||
either. `rproxy` itself answers `405` to a method it does not pass on, and the
|
either. `rproxy` itself answers `405` to a method it does not pass on, and the
|
||||||
status for a size or time limit, such as `413` for a body too large.
|
status for a size or time limit, such as `413` for a body too large.
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ it is built, except what the last section lists. `README.md` says how to run it.
|
|||||||
under `docs/` give the settings, the formats and the lists. This document links
|
under `docs/` give the settings, the formats and the lists. This document links
|
||||||
to them.
|
to them.
|
||||||
|
|
||||||
|
The whole application stores, logs and handles every time in UTC, whatever `TZ`
|
||||||
|
or the host's time zone says. It keeps every day as a date written `YYYY-MM-DD`,
|
||||||
|
never as a time.
|
||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
|
|
||||||
- It protects self-hosted apps on the open internet from scrapers, scanners and
|
- It protects self-hosted apps on the open internet from scrapers, scanners and
|
||||||
@@ -145,10 +149,12 @@ field gives that name. `refusal_limit` and `byte_limits` give theirs in
|
|||||||
- An IPv6 client is its group: the netblock of `SWW_IPV6_GROUP_PREFIX`
|
- An IPv6 client is its group: the netblock of `SWW_IPV6_GROUP_PREFIX`
|
||||||
around its address, a /64 by default, since one abuser usually controls a
|
around its address, a /64 by default, since one abuser usually controls a
|
||||||
whole /64. A client is therefore one IPv4 address or one IPv6 group.
|
whole /64. A client is therefore one IPv4 address or one IPv6 group.
|
||||||
3. The tag lists (`tag_lists`; see "Request tags"). Each list that holds the
|
3. The header limits (`header_limits`; see "Answering in time"). They come first
|
||||||
client adds its tag. Next, `routewatch_tags` adds the client's `asn=` and
|
after `SWW_ALLOW_NETS`, since they only count header fields and cookies.
|
||||||
|
Next, the tag lists (`tag_lists`; see "Request tags"). Each list that holds
|
||||||
|
the client adds its tag. Next, `routewatch_tags` adds the client's `asn=` and
|
||||||
`prefix=` tags while `SWW_ROUTEWATCH_URL` is set. A client in
|
`prefix=` tags while `SWW_ROUTEWATCH_URL` is set. A client in
|
||||||
`SWW_ALLOW_NETS` gets no tag.
|
`SWW_ALLOW_NETS` skips the header limits and gets no tag.
|
||||||
4. The static lists. A client in `SWW_ALLOW_NETS` skips steps 5 to 15, the byte
|
4. The static lists. A client in `SWW_ALLOW_NETS` skips steps 5 to 15, the byte
|
||||||
limits and the error burst. A client in `SWW_DENY_NETS` is refused
|
limits and the error burst. A client in `SWW_DENY_NETS` is refused
|
||||||
(`deny_nets`); a client in both lists is let through.
|
(`deny_nets`); a client in both lists is let through.
|
||||||
@@ -181,10 +187,9 @@ field gives that name. `refusal_limit` and `byte_limits` give theirs in
|
|||||||
refused and bans the client.
|
refused and bans the client.
|
||||||
13. The rule files (`rule_files`; see "Rule files"). Matching stops at the first
|
13. The rule files (`rule_files`; see "Rule files"). Matching stops at the first
|
||||||
rule that refuses.
|
rule that refuses.
|
||||||
14. The header limits (`header_limits`; see "Answering in time"), and then the
|
14. The Core Rule Set (`core_rule_set`; see "Attack detection"). A match is
|
||||||
Core Rule Set (`core_rule_set`; see "Attack detection"). A Core Rule Set
|
refused with `403` while `SWW_WAF_MODE` is `block`, and only logged and
|
||||||
match is refused with `403` while `SWW_WAF_MODE` is `block`, and only logged
|
alerted while it is `detect`.
|
||||||
and alerted while it is `detect`.
|
|
||||||
15. The tag refusals again (`tag_deny`), so that `exploitsignature` can refuse
|
15. The tag refusals again (`tag_deny`), so that `exploitsignature` can refuse
|
||||||
too.
|
too.
|
||||||
16. A path under `/_sww/` is answered `404`, so that the app never sees that
|
16. A path under `/_sww/` is answered `404`, so that the app never sees that
|
||||||
@@ -237,11 +242,16 @@ apply.
|
|||||||
## Bans
|
## Bans
|
||||||
|
|
||||||
Until a ban ends, every request from its netblock is refused with
|
Until a ban ends, every request from its netblock is refused with
|
||||||
`SWW_BAN_RESPONSE`. The netblock is the client: its IPv4 address, widened to
|
`SWW_BAN_RESPONSE`, unless it is over the header limits. The netblock is the
|
||||||
`SWW_BAN_SCOPE_V4_PREFIX`, or its IPv6 group. At the defaults a ban covers one
|
client: its IPv4 address, widened to `SWW_BAN_SCOPE_V4_PREFIX`, or its IPv6
|
||||||
address or one /64, so a permanent ban does not reach neighbours who did
|
group. At the defaults a ban covers one address or one /64, so a permanent ban
|
||||||
nothing. Requests refused under a ban are not counted against limits. They are
|
does not reach neighbours who did nothing. Requests refused under a ban are not
|
||||||
counted in the client's history and in the ban's notes.
|
counted against limits. They are counted in the client's history and in the
|
||||||
|
ban's notes.
|
||||||
|
|
||||||
|
A request over the header limits is refused with `431` before the bans are
|
||||||
|
checked (see "Answering in time"). It makes no ban, makes no ban permanent and
|
||||||
|
is not counted in a ban's notes.
|
||||||
|
|
||||||
An offence is a request `waf` holds against the client. It is one of:
|
An offence is a request `waf` holds against the client. It is one of:
|
||||||
|
|
||||||
@@ -1775,6 +1785,7 @@ in. `docs/state-files.md` lists the files, what each holds, and their sizes.
|
|||||||
- a top-level `version` number;
|
- a top-level `version` number;
|
||||||
- entries sorted by client address;
|
- entries sorted by client address;
|
||||||
- times in RFC 3339 UTC;
|
- times in RFC 3339 UTC;
|
||||||
|
- days as `YYYY-MM-DD`;
|
||||||
- durations and sizes as plain numbers, with the unit in the field name.
|
- durations and sizes as plain numbers, with the unit in the field name.
|
||||||
- `bans.json`, `reputation.json` and `alerts.json` are indented, so that a
|
- `bans.json`, `reputation.json` and `alerts.json` are indented, so that a
|
||||||
person can open `bans.json` in an editor, find an address, and remove or add
|
person can open `bans.json` in an editor, find an address, and remove or add
|
||||||
@@ -2020,7 +2031,6 @@ waits on these, which run in the background:
|
|||||||
|
|
||||||
In `enforce` mode, a question does not wait for GeoJS when it is a request:
|
In `enforce` mode, a question does not wait for GeoJS when it is a request:
|
||||||
|
|
||||||
- over the header limits;
|
|
||||||
- from a client on a fetched blocklist, while `SWW_BLOCKLIST_ACTION` is `deny`;
|
- from a client on a fetched blocklist, while `SWW_BLOCKLIST_ACTION` is `deny`;
|
||||||
- from a client on the CrowdSec decision list;
|
- from a client on the CrowdSec decision list;
|
||||||
- that would take its client over a rate limit at 100% of it, since a percentage
|
- that would take its client over a rate limit at 100% of it, since a percentage
|
||||||
@@ -2032,8 +2042,8 @@ number and country stay unknown until GeoJS has answered. So while
|
|||||||
`SWW_UNKNOWN_LIMIT_PERCENT` does not apply to it until then, so not waiting
|
`SWW_UNKNOWN_LIMIT_PERCENT` does not apply to it until then, so not waiting
|
||||||
never lowers its limit.
|
never lowers its limit.
|
||||||
|
|
||||||
The static lists and bans come before the lookup, so they never wait. These can
|
The header limits, the static lists and bans come before the lookup, so they
|
||||||
still wait for GeoJS:
|
never wait. These can still wait for GeoJS:
|
||||||
|
|
||||||
- a country list, which needs the answer;
|
- a country list, which needs the answer;
|
||||||
- a rate limit a percentage has lowered, which needs the answer;
|
- a rate limit a percentage has lowered, which needs the answer;
|
||||||
@@ -2052,13 +2062,17 @@ either one:
|
|||||||
|
|
||||||
Real clients never reach these limits. The answer is `deny` with `431`, and the
|
Real clients never reach these limits. The answer is `deny` with `431`, and the
|
||||||
log line's action is `request_header_too_large`. The refusal makes no ban and is
|
log line's action is `request_header_too_large`. The refusal makes no ban and is
|
||||||
no offence. The rate limits count the request as usual, so a client that keeps
|
no offence.
|
||||||
sending such requests breaks a limit.
|
|
||||||
|
The header limits are the first check after `SWW_ALLOW_NETS`. A request over
|
||||||
|
them is refused before any later check runs. So it causes no lookup, no query to
|
||||||
|
an outside source and no rule match, and the rate limits do not count it. A
|
||||||
|
client under a ban or in `SWW_DENY_NETS` also gets `431` for such a request.
|
||||||
|
|
||||||
The limits apply on every path and in every `SWW_WAF_MODE`. A client in
|
The limits apply on every path and in every `SWW_WAF_MODE`. A client in
|
||||||
`SWW_ALLOW_NETS` skips them, as it skips the Core Rule Set. In `observe` mode
|
`SWW_ALLOW_NETS` skips them, as it skips the Core Rule Set. In `observe` mode
|
||||||
such a request is answered `allow` with `would_action`, and the Core Rule Set
|
such a request is answered `allow` with `would_action`, and no other check runs
|
||||||
does not read it.
|
on it.
|
||||||
|
|
||||||
The Core Rule Set runs each of its rules on each cookie, so thousands of small
|
The Core Rule Set runs each of its rules on each cookie, so thousands of small
|
||||||
cookies would keep it busy for over a second. Within the limits, one long cookie
|
cookies would keep it busy for over a second. Within the limits, one long cookie
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ module sneak.berlin/go/smallwebwaf
|
|||||||
go 1.26.0
|
go 1.26.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
cloud.google.com/go v0.123.0
|
||||||
github.com/corazawaf/coraza-coreruleset/v4 v4.25.0
|
github.com/corazawaf/coraza-coreruleset/v4 v4.25.0
|
||||||
github.com/corazawaf/coraza/v3 v3.8.2-0.20261022102553-d028be399c68
|
github.com/corazawaf/coraza/v3 v3.8.2-0.20261022102553-d028be399c68
|
||||||
github.com/fsnotify/fsnotify v1.10.1
|
github.com/fsnotify/fsnotify v1.10.1
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE=
|
||||||
|
cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU=
|
||||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ type FileParams struct {
|
|||||||
// Path is the lookup database, the IPinfo Lite file in its .mmdb form
|
// Path is the lookup database, the IPinfo Lite file in its .mmdb form
|
||||||
// (SWW_LOOKUP_DB_PATH).
|
// (SWW_LOOKUP_DB_PATH).
|
||||||
Path string
|
Path string
|
||||||
// Now tells the time, normally time.Now.
|
// Now tells the time, normally time.Now in UTC.
|
||||||
Now func() time.Time
|
Now func() time.Time
|
||||||
// ProcessLog receives each reading of the file, and why a replacement
|
// ProcessLog receives each reading of the file, and why a replacement
|
||||||
// of it cannot be read.
|
// of it cannot be read.
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ type Params struct {
|
|||||||
// Answered, unless nil, is given each answer GeoJS gives, once it is
|
// Answered, unless nil, is given each answer GeoJS gives, once it is
|
||||||
// kept.
|
// kept.
|
||||||
Answered func(Answer)
|
Answered func(Answer)
|
||||||
// Now tells the time, normally time.Now.
|
// Now tells the time, normally time.Now in UTC.
|
||||||
Now func() time.Time
|
Now func() time.Time
|
||||||
// ProcessLog receives GeoJS's failures.
|
// ProcessLog receives GeoJS's failures.
|
||||||
ProcessLog *slog.Logger
|
ProcessLog *slog.Logger
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"cloud.google.com/go/civil"
|
||||||
"github.com/hashicorp/golang-lru/v2/simplelru"
|
"github.com/hashicorp/golang-lru/v2/simplelru"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
||||||
)
|
)
|
||||||
@@ -27,8 +28,6 @@ const (
|
|||||||
AbuseIPDBSource = "abuseipdb"
|
AbuseIPDBSource = "abuseipdb"
|
||||||
// maxAnswerBytes is the most of an answer of AbuseIPDB that is read.
|
// maxAnswerBytes is the most of an answer of AbuseIPDB that is read.
|
||||||
maxAnswerBytes = 64 << 10
|
maxAnswerBytes = 64 << 10
|
||||||
// day is the length of the day the checks are counted in, in UTC.
|
|
||||||
day = 24 * time.Hour
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -50,9 +49,9 @@ type Score struct {
|
|||||||
// AbuseIPDB: the day, in UTC, of the checks Spent counts, zero before the
|
// AbuseIPDB: the day, in UTC, of the checks Spent counts, zero before the
|
||||||
// first, and the scores still in use.
|
// first, and the scores still in use.
|
||||||
type Checks struct {
|
type Checks struct {
|
||||||
Day time.Time `json:"day,omitzero"`
|
Day civil.Date `json:"day,omitzero"`
|
||||||
Spent int `json:"spent"`
|
Spent int `json:"spent"`
|
||||||
Scores []Score `json:"scores"`
|
Scores []Score `json:"scores"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// AbuseIPDBParams are what NewAbuseIPDB needs.
|
// AbuseIPDBParams are what NewAbuseIPDB needs.
|
||||||
@@ -93,7 +92,7 @@ type AbuseIPDB struct {
|
|||||||
// checking are the clients whose check is under way.
|
// checking are the clients whose check is under way.
|
||||||
checking map[netip.Prefix]bool
|
checking map[netip.Prefix]bool
|
||||||
// day is the day, in UTC, of the checks spent counts.
|
// day is the day, in UTC, of the checks spent counts.
|
||||||
day time.Time
|
day civil.Date
|
||||||
spent int
|
spent int
|
||||||
// checks and failures count the checks made and those that failed,
|
// checks and failures count the checks made and those that failed,
|
||||||
// and retryAt is when a client may be checked again after the last
|
// and retryAt is when a client may be checked again after the last
|
||||||
@@ -143,7 +142,7 @@ func (a *AbuseIPDB) Hit(
|
|||||||
return kept.Score, kept.Score >= a.params.MinScore
|
return kept.Score, kept.Score >= a.params.MinScore
|
||||||
}
|
}
|
||||||
|
|
||||||
if today := now.Truncate(day); !a.day.Equal(today) {
|
if today := civil.DateOf(now.UTC()); a.day != today {
|
||||||
a.day, a.spent = today, 0
|
a.day, a.spent = today, 0
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,7 +189,7 @@ func (a *AbuseIPDB) BudgetLeft() int {
|
|||||||
a.mu.Lock()
|
a.mu.Lock()
|
||||||
defer a.mu.Unlock()
|
defer a.mu.Unlock()
|
||||||
|
|
||||||
if !a.day.Equal(a.params.Now().Truncate(day)) {
|
if a.day != civil.DateOf(a.params.Now().UTC()) {
|
||||||
return a.params.DailyBudget
|
return a.params.DailyBudget
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -202,6 +202,43 @@ func TestDailyBudgetKeptAcrossARestartAndWholeAgainAsTheDayEnds(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestChecksAreCountedInUTCDaysWhateverTheClocksZone(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
synctest.Test(t, func(t *testing.T) {
|
||||||
|
// A clock 14 hours ahead of UTC, whose day starts at 10:00 UTC.
|
||||||
|
ahead := time.FixedZone("UTC+14", 14*60*60)
|
||||||
|
p := abuseIPDBParams()
|
||||||
|
p.DailyBudget = 1
|
||||||
|
p.Now = func() time.Time { return time.Now().In(ahead) }
|
||||||
|
abuseIPDB := &abuseIPDBStandIn{scores: map[string]int64{suspect: 100}}
|
||||||
|
checker := newAbuseIPDB(abuseIPDB, p)
|
||||||
|
|
||||||
|
// At 09:00 UTC the one check is spent.
|
||||||
|
time.Sleep(9 * time.Hour)
|
||||||
|
hitFrom(t, checker, suspect, true)
|
||||||
|
synctest.Wait()
|
||||||
|
wantBudgetLeft(t, checker, 0)
|
||||||
|
|
||||||
|
// At 11:00 UTC the clock's day has changed, but the UTC day has not.
|
||||||
|
time.Sleep(2 * time.Hour)
|
||||||
|
wantBudgetLeft(t, checker, 0)
|
||||||
|
|
||||||
|
encoded, err := json.Marshal(checker.Snapshot())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("encode: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.Contains(string(encoded), `"day":"2000-01-01"`) {
|
||||||
|
t.Errorf("reputation.json would hold %s, want the day 2000-01-01", encoded)
|
||||||
|
}
|
||||||
|
|
||||||
|
// At 00:00 UTC the budget is whole again.
|
||||||
|
time.Sleep(13 * time.Hour)
|
||||||
|
wantBudgetLeft(t, checker, 1)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func TestFailedCheckGivesNoScoreAndNoClientIsCheckedForAMinute(t *testing.T) {
|
func TestFailedCheckGivesNoScoreAndNoClientIsCheckedForAMinute(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
+81
-2
@@ -171,9 +171,11 @@ type alertsFile struct {
|
|||||||
// check refuses the first entry without a field it needs, which would
|
// check refuses the first entry without a field it needs, which would
|
||||||
// otherwise be read as something the entry does not say. data is the
|
// otherwise be read as something the entry does not say. data is the
|
||||||
// file, for a field that may be null or "" but not left out, which the
|
// file, for a field that may be null or "" but not left out, which the
|
||||||
// struct cannot tell apart.
|
// struct cannot tell apart. Its toUTC then converts every time in it to
|
||||||
|
// UTC, since an admin may write a time with an offset.
|
||||||
type stateFile interface {
|
type stateFile interface {
|
||||||
check(data []byte) error
|
check(data []byte) error
|
||||||
|
toUTC()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load checks that files can be written in Dir, and reads the state files
|
// Load checks that files can be written in Dir, and reads the state files
|
||||||
@@ -918,6 +920,80 @@ func missing(i int, field string) error {
|
|||||||
return fmt.Errorf("entry %d %w %q", i+1, errMissing, field)
|
return fmt.Errorf("entry %d %w %q", i+1, errMissing, field)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// toUTC converts every time of each ban to UTC.
|
||||||
|
func (f *bansFile) toUTC() {
|
||||||
|
for i := range f.Bans {
|
||||||
|
ban := &f.Bans[i]
|
||||||
|
convertToUTC(&ban.Start, ban.Expires, ban.Lifted, &ban.Notes.Request.Time)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// toUTC converts every time of each client to UTC.
|
||||||
|
func (f *clientsFile) toUTC() {
|
||||||
|
for i := range f.Clients {
|
||||||
|
client := &f.Clients[i]
|
||||||
|
convertToUTC(&client.Minute.Start, &client.Hour.Start, &client.Day.Start,
|
||||||
|
&client.MinuteBytes.Start, &client.HourBytes.Start,
|
||||||
|
&client.DayBytes.Start, &client.MinuteRefusals.Start,
|
||||||
|
&client.History.FirstSeen, &client.History.LastSeen,
|
||||||
|
&client.History.LookedUp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// toUTC converts both times of each answer to UTC.
|
||||||
|
func (f *lookupsFile) toUTC() {
|
||||||
|
for i := range f.Lookups {
|
||||||
|
convertToUTC(&f.Lookups[i].Answered, &f.Lookups[i].Used)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// toUTC converts every time of each list, verdict and AbuseIPDB score to
|
||||||
|
// UTC.
|
||||||
|
func (f *reputationFile) toUTC() {
|
||||||
|
for i := range f.Lists {
|
||||||
|
convertToUTC(&f.Lists[i].Tried, &f.Lists[i].Fetched)
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range f.Verdicts {
|
||||||
|
convertToUTC(&f.Verdicts[i].Fetched)
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range f.AbuseIPDB.Scores {
|
||||||
|
convertToUTC(&f.AbuseIPDB.Scores[i].Fetched)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// toUTC converts every time of each cooldown, of the hour, of each alert
|
||||||
|
// waiting and of each anomaly counter to UTC.
|
||||||
|
func (f *alertsFile) toUTC() {
|
||||||
|
for i := range f.Cooldowns {
|
||||||
|
convertToUTC(&f.Cooldowns[i].Sent)
|
||||||
|
}
|
||||||
|
|
||||||
|
convertToUTC(&f.Hour.Start)
|
||||||
|
|
||||||
|
for _, waiting := range f.Waiting {
|
||||||
|
for i := range waiting {
|
||||||
|
convertToUTC(&waiting[i].Time)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range f.AnomalyCounters {
|
||||||
|
counter := &f.AnomalyCounters[i]
|
||||||
|
convertToUTC(&counter.Minute.Start, &counter.Hour.Start,
|
||||||
|
&counter.MinuteBytes.Start, &counter.HourBytes.Start)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// convertToUTC converts each of times that is not nil to UTC.
|
||||||
|
func convertToUTC(times ...*time.Time) {
|
||||||
|
for _, at := range times {
|
||||||
|
if at != nil {
|
||||||
|
*at = at.UTC()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// encodeOnePerLine encodes a state file whose entries, under key, are one
|
// encodeOnePerLine encodes a state file whose entries, under key, are one
|
||||||
// to a line, so that grep shows everything about one client.
|
// to a line, so that grep shows everything about one client.
|
||||||
func encodeOnePerLine[E any](key string, entries []E) ([]byte, error) {
|
func encodeOnePerLine[E any](key string, entries []E) ([]byte, error) {
|
||||||
@@ -955,7 +1031,8 @@ func checkWritable(dir string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// parse reads data, what the state file at path holds, into file, a
|
// parse reads data, what the state file at path holds, into file, a
|
||||||
// pointer to that file's struct, and checks its entries.
|
// pointer to that file's struct, checks its entries, and converts every
|
||||||
|
// time in it to UTC.
|
||||||
func parse(path string, data []byte, file stateFile) error {
|
func parse(path string, data []byte, file stateFile) error {
|
||||||
// The version is read first, so that a file of another version is
|
// The version is read first, so that a file of another version is
|
||||||
// refused for that, and not for an entry this version cannot read.
|
// refused for that, and not for an entry this version cannot read.
|
||||||
@@ -985,6 +1062,8 @@ func parse(path string, data []byte, file stateFile) error {
|
|||||||
return fmt.Errorf("%s%s: %w", path, position(data, err), err)
|
return fmt.Errorf("%s%s: %w", path, position(data, err), err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
file.toUTC()
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"reflect"
|
"reflect"
|
||||||
|
"regexp"
|
||||||
"slices"
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -21,6 +22,7 @@ import (
|
|||||||
"testing/synctest"
|
"testing/synctest"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"cloud.google.com/go/civil"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/anomaly"
|
"sneak.berlin/go/smallwebwaf/internal/anomaly"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/bans"
|
"sneak.berlin/go/smallwebwaf/internal/bans"
|
||||||
@@ -258,7 +260,7 @@ const filledReputationJSON = `{
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"abuseipdb": {
|
"abuseipdb": {
|
||||||
"day": "2026-10-06T00:00:00Z",
|
"day": "2026-10-06",
|
||||||
"spent": 3,
|
"spent": 3,
|
||||||
"scores": [
|
"scores": [
|
||||||
{
|
{
|
||||||
@@ -1306,7 +1308,7 @@ func TestEditOfEachFileTakenIn(t *testing.T) {
|
|||||||
`"tried": "2026-10-06T00:00:00Z", "fetched": "2026-10-06T00:00:00Z", `+
|
`"tried": "2026-10-06T00:00:00Z", "fetched": "2026-10-06T00:00:00Z", `+
|
||||||
`"lines": ["198.51.100.7"]}], "verdicts": [{"zone": "`+dnsblZone+`", `+
|
`"lines": ["198.51.100.7"]}], "verdicts": [{"zone": "`+dnsblZone+`", `+
|
||||||
`"client": "198.51.100.7", "listed": true, "fetched": "2026-10-06T00:00:00Z"}], `+
|
`"client": "198.51.100.7", "listed": true, "fetched": "2026-10-06T00:00:00Z"}], `+
|
||||||
`"abuseipdb": {"day": "2026-10-06T00:00:00Z", "spent": 9, "scores": [`+
|
`"abuseipdb": {"day": "2026-10-06", "spent": 9, "scores": [`+
|
||||||
`{"client": "198.51.100.7/32", "score": 80, "fetched": "2026-10-06T00:00:00Z"}]}}`)
|
`{"client": "198.51.100.7/32", "score": 80, "fetched": "2026-10-06T00:00:00Z"}]}}`)
|
||||||
wantTakenIn(t, lines, dir, reputationJSON)
|
wantTakenIn(t, lines, dir, reputationJSON)
|
||||||
|
|
||||||
@@ -1321,7 +1323,7 @@ func TestEditOfEachFileTakenIn(t *testing.T) {
|
|||||||
}})
|
}})
|
||||||
|
|
||||||
checks := reputation.Checks{
|
checks := reputation.Checks{
|
||||||
Day: midnight(), Spent: 9,
|
Day: civil.DateOf(midnight()), Spent: 9,
|
||||||
Scores: []reputation.Score{{Client: client, Score: 80, Fetched: midnight()}},
|
Scores: []reputation.Score{{Client: client, Score: 80, Fetched: midnight()}},
|
||||||
}
|
}
|
||||||
if got := params.AbuseIPDB.Snapshot(); !reflect.DeepEqual(got, checks) {
|
if got := params.AbuseIPDB.Snapshot(); !reflect.DeepEqual(got, checks) {
|
||||||
@@ -1350,6 +1352,62 @@ func TestEditOfEachFileTakenIn(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestTimesEditedWithAnOffsetWrittenBackInUTC(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
params := newParams(dir)
|
||||||
|
fill(params)
|
||||||
|
// A lifted ban, so that bans.json holds every time a ban has.
|
||||||
|
params.Ledger.Lift(netip.MustParseAddr("192.0.2.1"), midnight())
|
||||||
|
|
||||||
|
files := load(t, params)
|
||||||
|
|
||||||
|
err := files.WriteAll()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("write: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// An admin saves each file with every time in it two hours ahead of
|
||||||
|
// UTC, midnight as "2026-10-06T02:00:00+02:00".
|
||||||
|
stamp := regexp.MustCompile(`"\d{4}-\d\d-\d\dT[^"]*Z"`)
|
||||||
|
ahead := time.FixedZone("UTC+2", 2*60*60)
|
||||||
|
written := map[string]string{}
|
||||||
|
|
||||||
|
for _, name := range []string{
|
||||||
|
bansJSON, clientsJSON, lookupsJSON, reputationJSON, alertsJSON,
|
||||||
|
} {
|
||||||
|
written[name] = readFile(t, filepath.Join(dir, name))
|
||||||
|
edited := stamp.ReplaceAllStringFunc(written[name], func(quoted string) string {
|
||||||
|
at, err := time.Parse(time.RFC3339Nano, strings.Trim(quoted, `"`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%s holds %s: %v", name, quoted, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return `"` + at.In(ahead).Format(time.RFC3339Nano) + `"`
|
||||||
|
})
|
||||||
|
|
||||||
|
if !strings.Contains(edited, "+02:00") {
|
||||||
|
t.Fatalf("%s holds no time:\n%s", name, edited)
|
||||||
|
}
|
||||||
|
|
||||||
|
edit(t, dir, name, edited)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Each write takes its edit in first, and writes the same times back in
|
||||||
|
// UTC.
|
||||||
|
err = files.WriteAll()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("write: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for name, want := range written {
|
||||||
|
if got := readFile(t, filepath.Join(dir, name)); got != want {
|
||||||
|
t.Errorf("%s written back as\n%s\nwant\n%s", name, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestOwnWritesAreNotTakenIn(t *testing.T) {
|
func TestOwnWritesAreNotTakenIn(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -1837,10 +1895,12 @@ func fill(params state.Params) {
|
|||||||
},
|
},
|
||||||
{Zone: dnsblZone, Client: client.Addr(), Listed: true, Fetched: now.Add(-time.Hour)},
|
{Zone: dnsblZone, Client: client.Addr(), Listed: true, Fetched: now.Add(-time.Hour)},
|
||||||
})
|
})
|
||||||
params.AbuseIPDB.Load(reputation.Checks{Day: now, Spent: 3, Scores: []reputation.Score{
|
params.AbuseIPDB.Load(reputation.Checks{
|
||||||
{Client: netip.MustParsePrefix("2001:db8::/64"), Fetched: now.Add(-2 * time.Hour)},
|
Day: civil.DateOf(now), Spent: 3, Scores: []reputation.Score{
|
||||||
{Client: client, Score: 100, Fetched: now.Add(-time.Hour)},
|
{Client: netip.MustParsePrefix("2001:db8::/64"), Fetched: now.Add(-2 * time.Hour)},
|
||||||
}})
|
{Client: client, Score: 100, Fetched: now.Add(-time.Hour)},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
// An alert waiting, a repeat of it the cooldown holds back, another
|
// An alert waiting, a repeat of it the cooldown holds back, another
|
||||||
// alert waiting, and one past the two an hour, for the hour's summary.
|
// alert waiting, and one past the two an hour, for the hour's summary.
|
||||||
|
|||||||
@@ -9,10 +9,14 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"syscall"
|
"syscall"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
// The time zone database, so that the process TestMainRunsWAF starts
|
||||||
|
// finds the zone TZ names on any host.
|
||||||
|
_ "time/tzdata"
|
||||||
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/sww"
|
"sneak.berlin/go/smallwebwaf/internal/sww"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/waf"
|
"sneak.berlin/go/smallwebwaf/internal/waf"
|
||||||
@@ -37,6 +41,14 @@ func TestMain(m *testing.M) {
|
|||||||
func TestMainRunsWAF(t *testing.T) {
|
func TestMainRunsWAF(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
// Kiritimati is 14 hours ahead of UTC; every time sww gives stays in UTC.
|
||||||
|
const zone = "Pacific/Kiritimati"
|
||||||
|
|
||||||
|
_, err := time.LoadLocation(zone)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load %s: %v", zone, err)
|
||||||
|
}
|
||||||
|
|
||||||
binary, err := os.Executable()
|
binary, err := os.Executable()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("find the test binary: %v", err)
|
t.Fatalf("find the test binary: %v", err)
|
||||||
@@ -45,14 +57,16 @@ func TestMainRunsWAF(t *testing.T) {
|
|||||||
ctx, cancel := context.WithTimeout(t.Context(), 3*waitLimit)
|
ctx, cancel := context.WithTimeout(t.Context(), 3*waitLimit)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
out := &output{}
|
out := &output{}
|
||||||
cmd := exec.CommandContext(ctx, binary) //nolint:gosec // this test binary
|
cmd := exec.CommandContext(ctx, binary) //nolint:gosec // this test binary
|
||||||
cmd.Stdout = out
|
cmd.Stdout = out
|
||||||
|
|
||||||
cmd.Env = append(os.Environ(),
|
cmd.Env = append(os.Environ(),
|
||||||
|
"TZ="+zone,
|
||||||
commandForMain+"="+wafCommand,
|
commandForMain+"="+wafCommand,
|
||||||
listenAddr+"="+localhost+":0",
|
listenAddr+"="+localhost+":0",
|
||||||
stateDir+"="+t.TempDir(),
|
stateDir+"="+dir,
|
||||||
rulesDir+"="+t.TempDir(),
|
rulesDir+"="+t.TempDir(),
|
||||||
lookupSource+"=off")
|
lookupSource+"=off")
|
||||||
|
|
||||||
@@ -66,9 +80,11 @@ func TestMainRunsWAF(t *testing.T) {
|
|||||||
_ = cmd.Wait()
|
_ = cmd.Wait()
|
||||||
})
|
})
|
||||||
|
|
||||||
addr, _ := out.line(t, "msg", "starting")["address"].(string)
|
starting := out.line(t, "msg", "starting")
|
||||||
|
addr, _ := starting["address"].(string)
|
||||||
ask(t, addr, "/", http.StatusOK, waf.DispositionAllow)
|
ask(t, addr, "/", http.StatusOK, waf.DispositionAllow)
|
||||||
|
|
||||||
|
// The files are written as sww stops.
|
||||||
err = cmd.Process.Signal(syscall.SIGTERM)
|
err = cmd.Process.Signal(syscall.SIGTERM)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("signal: %v", err)
|
t.Fatalf("signal: %v", err)
|
||||||
@@ -76,7 +92,39 @@ func TestMainRunsWAF(t *testing.T) {
|
|||||||
|
|
||||||
err = cmd.Wait()
|
err = cmd.Wait()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Errorf("exit: %v; output:\n%s", err, out.text())
|
t.Fatalf("exit: %v; output:\n%s", err, out.text())
|
||||||
|
}
|
||||||
|
|
||||||
|
wantUTC(t, "the process line", starting["time"])
|
||||||
|
wantUTC(t, "the request line", out.line(t, "type", "request")["time"])
|
||||||
|
|
||||||
|
path := filepath.Join(dir, "clients.json")
|
||||||
|
|
||||||
|
clients, err := os.ReadFile(path) //nolint:gosec // a file in the test's directory
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read clients.json: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
stamp := regexp.MustCompile(`"\d{4}-\d\d-\d\dT[^"]*"`)
|
||||||
|
|
||||||
|
times := stamp.FindAllString(string(clients), -1)
|
||||||
|
if len(times) == 0 {
|
||||||
|
t.Errorf("clients.json holds no time:\n%s", clients)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, stored := range times {
|
||||||
|
wantUTC(t, "clients.json", strings.Trim(stored, `"`))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// wantUTC checks that value, a time read from the log line or file that
|
||||||
|
// where names, is in UTC.
|
||||||
|
func wantUTC(t *testing.T, where string, value any) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
text, _ := value.(string)
|
||||||
|
if !strings.HasSuffix(text, "Z") {
|
||||||
|
t.Errorf("%s gives the time %v, want one in UTC", where, value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+145
-38
@@ -7,6 +7,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
|
"path/filepath"
|
||||||
"slices"
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -16,6 +17,7 @@ import (
|
|||||||
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/bans"
|
"sneak.berlin/go/smallwebwaf/internal/bans"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/lookup"
|
"sneak.berlin/go/smallwebwaf/internal/lookup"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/lookup/lookuptest"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/reputation"
|
"sneak.berlin/go/smallwebwaf/internal/reputation"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
@@ -216,93 +218,199 @@ func TestObserveModeAllowsARequestOverTheHeaderLimitsUnread(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRequestsOverTheHeaderLimitsCountTowardTheRateLimits(t *testing.T) {
|
func TestTheRateLimitsDoNotCountRequestsOverTheHeaderLimits(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
s := startWAF(t, "", map[string]string{rateLimitPerMinute: "1"})
|
s := startWAF(t, "", map[string]string{rateLimitPerMinute: "1"})
|
||||||
ask := func() answer {
|
|
||||||
|
for range 2 {
|
||||||
req := s.question(siteA, client, "/")
|
req := s.question(siteA, client, "/")
|
||||||
req.Header.Set(cookieHeader, cookies(cookieLimit+1))
|
req.Header.Set(cookieHeader, cookies(cookieLimit+1))
|
||||||
|
wantDisposition(t, do(t, req), tooLarge, waf.DispositionDeny)
|
||||||
return do(t, req)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
wantDisposition(t, ask(), tooLarge, waf.DispositionDeny)
|
wantDisposition(t, s.ask(client, "/"), http.StatusOK, waf.DispositionAllow)
|
||||||
wantDisposition(t, ask(), http.StatusForbidden, waf.DispositionDeny)
|
|
||||||
|
|
||||||
lines := s.out.requestLines(t, 2)
|
|
||||||
wantLine(t, lines[1], http.StatusForbidden, requestlog.ActionClientIsRateLimited)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTheHeaderLimitsComeAfterTheTrapPathsAndTheRuleFiles(t *testing.T) {
|
func TestTheHeaderLimitsComeBeforeEveryCheckThatRefuses(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
// The lookup database places client in Germany.
|
||||||
|
lookupDB := filepath.Join(t.TempDir(), "ipinfo_lite.mmdb")
|
||||||
|
lookuptest.Write(t, lookupDB, map[string]lookuptest.Network{
|
||||||
|
client + "/32": {ASN: asnDE, ASName: asNameDE, Country: "DE"},
|
||||||
|
})
|
||||||
|
|
||||||
for _, tc := range []struct {
|
for _, tc := range []struct {
|
||||||
name, path, action string
|
name string
|
||||||
|
env map[string]string
|
||||||
|
path string
|
||||||
|
// prepare gets the server ready, at now, before the question.
|
||||||
|
prepare func(t *testing.T, server *waf.Server, now time.Time)
|
||||||
}{
|
}{
|
||||||
{"a trap path", "/wp-login.php", requestlog.ActionTemporarilyBanned},
|
{"SWW_DENY_NETS", map[string]string{denyNets: client}, "/", leaveAsItIs},
|
||||||
{"a block rule", "/blocked", requestlog.ActionBlockedByBlockRule},
|
{"a ban", nil, "/", banClient},
|
||||||
|
{
|
||||||
|
"the country list", map[string]string{
|
||||||
|
lookupSource: fileSource, lookupDBPath: lookupDB, allowedCountries: "FR",
|
||||||
|
},
|
||||||
|
"/", leaveAsItIs,
|
||||||
|
},
|
||||||
|
{"a blocklist", map[string]string{blocklistURLs: dropURL}, "/", listClient},
|
||||||
|
{
|
||||||
|
"the CrowdSec decision list",
|
||||||
|
map[string]string{crowdSecURL: lapi, crowdSecKey: bouncerKey}, "/", decideOnClient,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"a DNS blocklist", map[string]string{
|
||||||
|
dnsblZones: dnsblZone, dnsblResolver: noResolver, reputationAction: actionDeny,
|
||||||
|
},
|
||||||
|
"/", func(_ *testing.T, server *waf.Server, _ time.Time) {
|
||||||
|
loadVerdicts(server, map[string][]string{client: {dnsblZone}})
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"AbuseIPDB",
|
||||||
|
map[string]string{abuseIPDBKey: accountKey, reputationAction: actionDeny},
|
||||||
|
"/", func(_ *testing.T, server *waf.Server, _ time.Time) {
|
||||||
|
// Over SWW_ABUSEIPDB_MIN_SCORE, 75 by default.
|
||||||
|
loadScores(server, map[string]int64{client: 80})
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"SWW_TAG_DENY", map[string]string{tagLists: "vpn=" + vpnList, tagDeny: "vpn"},
|
||||||
|
"/", func(t *testing.T, server *waf.Server, _ time.Time) {
|
||||||
|
t.Helper()
|
||||||
|
loadLists(t, server, map[string][]string{vpnList: {client}})
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{"the rate limits", map[string]string{rateLimitPerMinute: "1"}, "/", countClient},
|
||||||
|
{
|
||||||
|
"a trap path", map[string]string{trapPaths: trapPathList}, "/wp-login.php",
|
||||||
|
leaveAsItIs,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"a block rule", map[string]string{rulesDir: writeRules(t, testRules)}, "/blocked",
|
||||||
|
leaveAsItIs,
|
||||||
|
},
|
||||||
} {
|
} {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
s := startWAF(t, "", map[string]string{
|
s := startWAF(t, "", tc.env)
|
||||||
trapPaths: trapPathList, rulesDir: writeRules(t, testRules),
|
tc.prepare(t, s.server, s.clock.Now())
|
||||||
})
|
|
||||||
req := s.question(siteA, client, tc.path)
|
req := s.question(siteA, client, tc.path)
|
||||||
req.Header.Set(cookieHeader, cookies(cookieLimit+1))
|
req.Header.Set(cookieHeader, cookies(cookieLimit+1))
|
||||||
|
|
||||||
wantDisposition(t, do(t, req), http.StatusForbidden, waf.DispositionDeny)
|
wantDisposition(t, do(t, req), tooLarge, waf.DispositionDeny)
|
||||||
wantLine(t, s.out.requestLine(t), http.StatusForbidden, tc.action)
|
wantLine(t, s.out.requestLine(t), tooLarge, requestlog.ActionRequestHeaderTooLarge)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestARequestOverTheHeaderLimitsRunsNoOtherCheck(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// Each check after the header limits leaves a mark on the log line of a
|
||||||
|
// request it reads: a tag, routewatch_tags_missing, a rule id, the
|
||||||
|
// counts, or the Core Rule Set's time.
|
||||||
|
path := filepath.Join(t.TempDir(), "ipinfo_lite.mmdb")
|
||||||
|
lookuptest.Write(t, path, map[string]lookuptest.Network{
|
||||||
|
client + "/32": {ASN: asnDE, ASName: asNameDE, Country: "DE"},
|
||||||
|
})
|
||||||
|
s := startWAF(t, "", map[string]string{
|
||||||
|
lookupSource: fileSource, lookupDBPath: path,
|
||||||
|
tagLists: "vpn=" + vpnList,
|
||||||
|
routeWatchURL: startRouteWatch(t, map[string]standInRoute{client: {prefixDE, 64496}}),
|
||||||
|
dnsblZones: dnsblZone, reputationAction: actionLog,
|
||||||
|
rulesDir: writeRules(t, "noted path log ^/\n"),
|
||||||
|
wafMode: block,
|
||||||
|
})
|
||||||
|
loadLists(t, s.server, map[string][]string{vpnList: {client}})
|
||||||
|
loadVerdicts(s.server, map[string][]string{client: {dnsblZone}})
|
||||||
|
|
||||||
|
over := s.question(siteA, client, "/")
|
||||||
|
over.Header.Set(cookieHeader, cookies(cookieLimit+1))
|
||||||
|
wantDisposition(t, do(t, over), tooLarge, waf.DispositionDeny)
|
||||||
|
|
||||||
|
within := s.question(siteA, client, "/")
|
||||||
|
within.Header.Set(cookieHeader, cookies(cookieLimit))
|
||||||
|
wantDisposition(t, do(t, within), http.StatusOK, waf.DispositionAllow)
|
||||||
|
|
||||||
|
lines := s.out.requestLines(t, 2)
|
||||||
|
|
||||||
|
wantTags(t, lines[0])
|
||||||
|
wantTagsMissing(t, lines[0], "")
|
||||||
|
|
||||||
|
if lines[0].RuleIDs != nil || lines[0].Counts != (ratelimit.Counts{}) ||
|
||||||
|
lines[0].DurationCoreRuleSetMS != nil {
|
||||||
|
t.Errorf("rule ids %v, counts %+v and Core Rule Set time %v, want none",
|
||||||
|
lines[0].RuleIDs, lines[0].Counts, lines[0].DurationCoreRuleSetMS)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The request within the limits shows that each check leaves its mark.
|
||||||
|
wantTags(t, lines[1],
|
||||||
|
"asn=64496", "country=DE", "dnsbl="+dnsblZone, "exploitsignature", "vpn")
|
||||||
|
wantTagsMissing(t, lines[1], "no_answer_yet")
|
||||||
|
|
||||||
|
if lines[1].RuleIDs == nil || lines[1].Counts.Minute != 1 ||
|
||||||
|
lines[1].DurationCoreRuleSetMS == nil {
|
||||||
|
t.Errorf("rule ids %v, counts %+v and Core Rule Set time %v, want each",
|
||||||
|
lines[1].RuleIDs, lines[1].Counts, lines[1].DurationCoreRuleSetMS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestNotWaitingForGeoJSNeverLowersAClientsLimit(t *testing.T) {
|
func TestNotWaitingForGeoJSNeverLowersAClientsLimit(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
// The client's next request takes it over the whole limit, so it does
|
||||||
|
// not wait.
|
||||||
geojsURL, _ := startGeoJS(t)
|
geojsURL, _ := startGeoJS(t)
|
||||||
s := startWAF(t, geojsURL, map[string]string{
|
s := startWAF(t, geojsURL, map[string]string{
|
||||||
unknownLimitPercent: "0", lookupTimeout: "1h",
|
unknownLimitPercent: "0", lookupTimeout: "1h", rateLimitPerMinute: "1",
|
||||||
})
|
})
|
||||||
req := s.question(siteA, fromDE, "/")
|
countClient(t, s.server, s.clock.Now())
|
||||||
req.Header.Set(cookieHeader, cookies(cookieLimit+1))
|
|
||||||
|
|
||||||
wantDisposition(t, do(t, req), tooLarge, waf.DispositionDeny)
|
wantDisposition(t, s.ask(fromDE, "/"), http.StatusForbidden, waf.DispositionDeny)
|
||||||
|
|
||||||
line := s.out.requestLine(t)
|
line := s.out.requestLine(t)
|
||||||
wantLine(t, line, tooLarge, requestlog.ActionRequestHeaderTooLarge)
|
wantLine(t, line, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
|
||||||
|
|
||||||
if line.RateLimitPercent != nil {
|
if line.RateLimitPercent != nil {
|
||||||
t.Errorf("limit percentage %d, want none", *line.RateLimitPercent)
|
t.Errorf("limit percentage %d, want none", *line.RateLimitPercent)
|
||||||
}
|
}
|
||||||
|
|
||||||
// The client is under no ban, and GeoJS places it in a country.
|
|
||||||
wantDisposition(t, s.ask(fromDE, "/"), http.StatusOK, waf.DispositionAllow)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAClientGeoJSPlacedInNoCountryCountsAsUnknownWithoutWaiting(t *testing.T) {
|
func TestAClientGeoJSPlacedInNoCountryCountsAsUnknownWithoutWaiting(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
// The client's next request takes it over the whole limit, so it does
|
||||||
|
// not wait.
|
||||||
geojsURL, _ := startGeoJS(t)
|
geojsURL, _ := startGeoJS(t)
|
||||||
s := startWAF(t, geojsURL, map[string]string{unknownLimitPercent: "0"})
|
s := startWAF(t, geojsURL, map[string]string{
|
||||||
|
unknownLimitPercent: "0", rateLimitPerMinute: "1",
|
||||||
|
})
|
||||||
s.server.GeoJS.Load([]lookup.Answer{{
|
s.server.GeoJS.Load([]lookup.Answer{{
|
||||||
Client: netip.MustParsePrefix(client + "/32"), ASN: "AS64512",
|
Client: netip.MustParsePrefix(client + "/32"), ASN: "AS64512",
|
||||||
Answered: s.clock.Now(), Used: s.clock.Now(),
|
Answered: s.clock.Now(), Used: s.clock.Now(),
|
||||||
}})
|
}})
|
||||||
|
countClient(t, s.server, s.clock.Now())
|
||||||
|
|
||||||
req := s.question(siteA, client, "/")
|
wantDisposition(t, s.ask(client, "/"), http.StatusForbidden, waf.DispositionDeny)
|
||||||
req.Header.Set(cookieHeader, cookies(cookieLimit+1))
|
|
||||||
|
|
||||||
wantDisposition(t, do(t, req), http.StatusForbidden, waf.DispositionDeny)
|
line := s.out.requestLine(t)
|
||||||
wantLine(t, s.out.requestLine(t), http.StatusForbidden,
|
wantLine(t, line, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
|
||||||
requestlog.ActionClientIsRateLimited)
|
|
||||||
|
if line.RateLimitPercent == nil || *line.RateLimitPercent != 0 {
|
||||||
|
t.Errorf("limit percentage %v, want 0", line.RateLimitPercent)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestObserveModeWaitsForGeoJSForARequestOverTheHeaderLimits(t *testing.T) {
|
func TestObserveModeAsksGeoJSNothingForARequestOverTheHeaderLimits(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
geojsURL, _ := startGeoJS(t)
|
geojsURL, asked := startGeoJS(t)
|
||||||
s := startWAF(t, geojsURL, map[string]string{
|
s := startWAF(t, geojsURL, map[string]string{
|
||||||
mode: observe, addLookupHeaders: on, lookupTimeout: "1h",
|
mode: observe, addLookupHeaders: on, lookupTimeout: "1h",
|
||||||
})
|
})
|
||||||
@@ -312,10 +420,9 @@ func TestObserveModeWaitsForGeoJSForARequestOverTheHeaderLimits(t *testing.T) {
|
|||||||
got := do(t, req)
|
got := do(t, req)
|
||||||
wantDisposition(t, got, http.StatusOK, waf.DispositionAllow)
|
wantDisposition(t, got, http.StatusOK, waf.DispositionAllow)
|
||||||
|
|
||||||
asn, country := got.header.Get("X-Client-Asn"), got.header.Get("X-Client-Country")
|
if len(asked()) != 0 || got.header.Get("X-Client-Asn") != "" {
|
||||||
if asn != asnDE || country != "DE" {
|
t.Errorf("GeoJS was asked about %v, and the answer gave the AS number %q, "+
|
||||||
t.Errorf("answered with the AS number %q and the country %q, want %q and DE",
|
"want neither", asked(), got.header.Get("X-Client-Asn"))
|
||||||
asn, country, asnDE)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,22 +10,23 @@ import (
|
|||||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
)
|
)
|
||||||
|
|
||||||
// The header limits: the most cookies and header fields a request may have
|
// The header limits: the most cookies and header fields a request may have.
|
||||||
// when the checks reach the Core Rule Set. Real clients never reach them.
|
// Real clients never reach them.
|
||||||
const (
|
const (
|
||||||
maxCookies = 50
|
maxCookies = 50
|
||||||
maxHeaderFields = 100
|
maxHeaderFields = 100
|
||||||
)
|
)
|
||||||
|
|
||||||
// headerLimits refuses a request over the header limits, with 431. Its figures
|
// headerLimits refuses a request with more than maxHeaderFields header fields
|
||||||
// are the request's header fields and cookies, and the limit of each.
|
// or more than maxCookies cookies, with 431. Its figures are the request's
|
||||||
|
// header fields and cookies, and the limit of each.
|
||||||
type headerLimits struct{}
|
type headerLimits struct{}
|
||||||
|
|
||||||
func (headerLimits) name() string { return "header_limits" }
|
func (headerLimits) name() string { return "header_limits" }
|
||||||
|
|
||||||
func (headerLimits) check(_ context.Context, rq *request, _ time.Time) *verdict {
|
func (headerLimits) check(_ context.Context, rq *request, _ time.Time) *verdict {
|
||||||
fields, cookies := rq.headerCounts()
|
fields, cookies := rq.headerCounts()
|
||||||
if !overHeaderLimits(fields, cookies) {
|
if fields <= maxHeaderFields && cookies <= maxCookies {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,13 +39,6 @@ func (headerLimits) check(_ context.Context, rq *request, _ time.Time) *verdict
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// overHeaderLimits reports whether a request with these counts of header fields
|
|
||||||
// and cookies, as headerCounts counts them, is over the header limits: it has
|
|
||||||
// more than maxHeaderFields fields or more than maxCookies cookies.
|
|
||||||
func overHeaderLimits(fields, cookies int) bool {
|
|
||||||
return fields > maxHeaderFields || cookies > maxCookies
|
|
||||||
}
|
|
||||||
|
|
||||||
// headerCounts returns the request's header fields, each value of a header sent
|
// headerCounts returns the request's header fields, each value of a header sent
|
||||||
// twice being one, and its cookies, each part of a Cookie header between
|
// twice being one, and its cookies, each part of a Cookie header between
|
||||||
// semicolons being one unless it is empty or only spaces and tabs. The Core
|
// semicolons being one unless it is empty or only spaces and tabs. The Core
|
||||||
|
|||||||
@@ -41,8 +41,11 @@ const (
|
|||||||
|
|
||||||
// beforeLookup are the heuristics that run before the client's AS number and
|
// beforeLookup are the heuristics that run before the client's AS number and
|
||||||
// country are looked up, so that a client they refuse is not looked up.
|
// country are looked up, so that a client they refuse is not looked up.
|
||||||
|
// header_limits comes first: it only counts, and a request it refuses causes no
|
||||||
|
// other work.
|
||||||
func beforeLookup() []heuristic {
|
func beforeLookup() []heuristic {
|
||||||
return []heuristic{
|
return []heuristic{
|
||||||
|
headerLimits{},
|
||||||
tagLists{},
|
tagLists{},
|
||||||
routeWatchTags{},
|
routeWatchTags{},
|
||||||
denyNets{},
|
denyNets{},
|
||||||
@@ -64,7 +67,6 @@ func afterLookup() []heuristic {
|
|||||||
rateLimits{},
|
rateLimits{},
|
||||||
trapPaths{},
|
trapPaths{},
|
||||||
ruleFiles{},
|
ruleFiles{},
|
||||||
headerLimits{},
|
|
||||||
coreRuleSet{},
|
coreRuleSet{},
|
||||||
tagDeny{},
|
tagDeny{},
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-6
@@ -143,10 +143,9 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
// refusedWhateverTheAnswer reports whether the request is one that does not wait
|
// refusedWhateverTheAnswer reports whether the request is one that does not wait
|
||||||
// for GeoJS, since the checks will refuse it at now whatever GeoJS answers about
|
// for GeoJS, since the checks will refuse it at now whatever GeoJS answers about
|
||||||
// its client: the heuristic crowdsec, header_limits, blocklists or rate_limits
|
// its client: the heuristic crowdsec, blocklists or rate_limits will refuse it,
|
||||||
// will refuse it, as a function in that heuristic's file says. In observe mode,
|
// as a function in that heuristic's file says. In observe mode, which refuses
|
||||||
// which refuses nothing, none is. rproxy lets a request through when
|
// nothing, none is. rproxy lets a request through when waf answers too late.
|
||||||
// waf answers too late.
|
|
||||||
func (rq *request) refusedWhateverTheAnswer(now time.Time) bool {
|
func (rq *request) refusedWhateverTheAnswer(now time.Time) bool {
|
||||||
if rq.h.config.Observe {
|
if rq.h.config.Observe {
|
||||||
return false
|
return false
|
||||||
@@ -154,8 +153,7 @@ func (rq *request) refusedWhateverTheAnswer(now time.Time) bool {
|
|||||||
|
|
||||||
_, decided := rq.crowdSecDecisionInForce(now)
|
_, decided := rq.crowdSecDecisionInForce(now)
|
||||||
|
|
||||||
return decided || overHeaderLimits(rq.headerCounts()) ||
|
return decided || rq.refusedByBlocklists(rq.h.lists.ListedBy(rq.client)) ||
|
||||||
rq.refusedByBlocklists(rq.h.lists.ListedBy(rq.client)) ||
|
|
||||||
rq.overAWholeRateLimit(now)
|
rq.overAWholeRateLimit(now)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user