Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
91f69346ea |
@@ -2000,13 +2000,12 @@ The list is fetched again a minute after it was last fetched or tried, the fetch
|
|||||||
failed or not, and is kept as a blocklist is (see "Blocklists" above): its last
|
failed or not, and is kept as a blocklist is (see "Blocklists" above): its last
|
||||||
good copy, the engine's answer as it came, stays in use while a fetch fails, and
|
good copy, the engine's answer as it came, stays in use while a fetch fails, and
|
||||||
`reputation.json` keeps it with the time it was fetched, so that a restart keeps
|
`reputation.json` keeps it with the time it was fetched, so that a restart keeps
|
||||||
it in use too. A fetch fails when the engine answers other than `200`, a
|
it in use too. A fetch fails when the engine answers other than `200`, such as
|
||||||
redirect included, such as `403` for a key it does not know, when it does not
|
`403` for a key it does not know, when it does not finish within a minute, when
|
||||||
finish within a minute, when the answer is longer than 16 MiB or is not a JSON
|
the answer is longer than 16 MiB or is not a JSON list of decisions, or when a
|
||||||
list of decisions, or when a decision to ban gives a value that is not an
|
decision to ban gives a value that is not an address or a netblock, or a
|
||||||
address or a netblock, or a `duration` that does not read. A failure is counted,
|
`duration` that does not read. A failure is counted, logged and raised as a
|
||||||
logged and raised as a `source_failure` alert, held back as a repeat within
|
`source_failure` alert, held back as a repeat within `SWWAF_ALERT_COOLDOWN`.
|
||||||
`SWWAF_ALERT_COOLDOWN`.
|
|
||||||
|
|
||||||
The decisions of the type `ban` on an address or a netblock, the scopes `Ip` and
|
The decisions of the type `ban` on an address or a netblock, the scopes `Ip` and
|
||||||
`Range`, are used; any other, such as one to show a captcha or one on a country,
|
`Range`, are used; any other, such as one to show a captcha or one on a country,
|
||||||
|
|||||||
@@ -31,10 +31,8 @@ const (
|
|||||||
// sshBF and probing are scenarios of the engine's decisions.
|
// sshBF and probing are scenarios of the engine's decisions.
|
||||||
sshBF = "crowdsecurity/ssh-bf"
|
sshBF = "crowdsecurity/ssh-bf"
|
||||||
probing = "crowdsecurity/http-probing"
|
probing = "crowdsecurity/http-probing"
|
||||||
// ban is the type of a decision to ban, and rangeScope the scope of a
|
// ban is the type of a decision to ban, as CrowdSec names it.
|
||||||
// decision on a netblock, as CrowdSec names them.
|
ban = "ban"
|
||||||
ban = "ban"
|
|
||||||
rangeScope = "Range"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestCrowdSecDecisionBansItsNetblockUntilItEndsEvenWithTheEngineDown(t *testing.T) {
|
func TestCrowdSecDecisionBansItsNetblockUntilItEndsEvenWithTheEngineDown(t *testing.T) {
|
||||||
@@ -48,7 +46,7 @@ func TestCrowdSecDecisionBansItsNetblockUntilItEndsEvenWithTheEngineDown(t *test
|
|||||||
// A shorter decision on the same address, which is not the one
|
// A shorter decision on the same address, which is not the one
|
||||||
// used.
|
// used.
|
||||||
{"Ip", suspect, ban, sshBF, began.Add(4 * time.Hour)},
|
{"Ip", suspect, ban, sshBF, began.Add(4 * time.Hour)},
|
||||||
{rangeScope, "198.51.100.0/24", ban, probing, began.Add(time.Hour)},
|
{"Range", "198.51.100.0/24", ban, probing, began.Add(time.Hour)},
|
||||||
{"Ip", "2001:db8::1", ban, sshBF, began.Add(2 * time.Hour)},
|
{"Ip", "2001:db8::1", ban, sshBF, began.Add(2 * time.Hour)},
|
||||||
// Left out: a decision to show a captcha, and one on a country.
|
// Left out: a decision to show a captcha, and one on a country.
|
||||||
{"Ip", "192.0.2.50", "captcha", probing, began.Add(time.Hour)},
|
{"Ip", "192.0.2.50", "captcha", probing, began.Add(time.Hour)},
|
||||||
@@ -113,64 +111,6 @@ func TestCrowdSecDecisionListFetchedAgainEveryMinute(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCrowdSecDecisionOnAClientIsTheOneThatEndsLast(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
synctest.Test(t, func(t *testing.T) {
|
|
||||||
began := time.Now()
|
|
||||||
e := &engine{key: engineKey, decisions: []decision{
|
|
||||||
// Two decisions on one address, the shorter listed first.
|
|
||||||
{"Ip", suspect, ban, sshBF, began.Add(2 * time.Hour)},
|
|
||||||
{"Ip", suspect, ban, probing, began.Add(4 * time.Hour)},
|
|
||||||
// 198.51.100.130 is held by a decision on its address that ends
|
|
||||||
// after the one on its netblock, and 192.0.2.20 by one that ends
|
|
||||||
// before.
|
|
||||||
{rangeScope, "198.51.100.128/25", ban, sshBF, began.Add(time.Hour)},
|
|
||||||
{"Ip", "198.51.100.130", ban, probing, began.Add(3 * time.Hour)},
|
|
||||||
{rangeScope, "192.0.2.0/24", ban, probing, began.Add(5 * time.Hour)},
|
|
||||||
{"Ip", "192.0.2.20", ban, sshBF, began.Add(2 * time.Hour)},
|
|
||||||
}}
|
|
||||||
lists := start(t, e, crowdSecParams())
|
|
||||||
|
|
||||||
wantDecision(t, lists, suspect,
|
|
||||||
reputation.Decision{Expires: began.Add(4 * time.Hour), Scenario: probing})
|
|
||||||
wantDecision(t, lists, "198.51.100.130",
|
|
||||||
reputation.Decision{Expires: began.Add(3 * time.Hour), Scenario: probing})
|
|
||||||
wantDecision(t, lists, "192.0.2.20",
|
|
||||||
reputation.Decision{Expires: began.Add(5 * time.Hour), Scenario: probing})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCrowdSecAnswerOfNoDecisionIsAGoodCopyThatListsNoClient(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
synctest.Test(t, func(t *testing.T) {
|
|
||||||
began := time.Now()
|
|
||||||
e := &engine{key: engineKey, decisions: []decision{
|
|
||||||
{"Ip", suspect, ban, sshBF, began.Add(4 * time.Hour)},
|
|
||||||
}}
|
|
||||||
lists := start(t, e, crowdSecParams())
|
|
||||||
|
|
||||||
// With its decision deleted, the engine answers null.
|
|
||||||
e.set(func(e *engine) { e.decisions = nil })
|
|
||||||
time.Sleep(time.Minute)
|
|
||||||
wantEngineFetches(t, e, 2)
|
|
||||||
|
|
||||||
want := []reputation.List{{
|
|
||||||
URL: decisionsURL, Tried: time.Now(), Fetched: time.Now(), Lines: []string{"null"},
|
|
||||||
}}
|
|
||||||
if got := lists.Snapshot(); !reflect.DeepEqual(got, want) {
|
|
||||||
t.Errorf("lists %+v, want %+v", got, want)
|
|
||||||
}
|
|
||||||
|
|
||||||
if lists.Failures(decisionsURL) != 0 {
|
|
||||||
t.Errorf("%d failures, want 0", lists.Failures(decisionsURL))
|
|
||||||
}
|
|
||||||
|
|
||||||
wantDecision(t, lists, suspect, reputation.Decision{})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCrowdSecFailureKeepsTheLastGoodCopyAlertsOncePerCooldownAndHidesTheKey(
|
func TestCrowdSecFailureKeepsTheLastGoodCopyAlertsOncePerCooldownAndHidesTheKey(
|
||||||
t *testing.T,
|
t *testing.T,
|
||||||
) {
|
) {
|
||||||
@@ -227,7 +167,7 @@ func TestCrowdSecFailureKeepsTheLastGoodCopyAlertsOncePerCooldownAndHidesTheKey(
|
|||||||
t.Errorf("logged\n%s\nwant the failures", log.String())
|
t.Errorf("logged\n%s\nwant the failures", log.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
wantKeyNotShown(t, e, log.String(), lists, queue)
|
wantKeyNotShown(t, log.String(), lists, queue)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -257,11 +197,6 @@ func crowdSecFailures() []crowdSecFailure {
|
|||||||
func(e *engine) { e.key = "another-key-0123456789abcdef" },
|
func(e *engine) { e.key = "another-key-0123456789abcdef" },
|
||||||
"the server answered 403 Forbidden",
|
"the server answered 403 Forbidden",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"a redirect",
|
|
||||||
func(e *engine) { e.redirect = "http://elsewhere.example/v1/decisions" },
|
|
||||||
"the server answered 302 Found",
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"an answer that does not read",
|
"an answer that does not read",
|
||||||
func(e *engine) { e.answer = "<html>" },
|
func(e *engine) { e.answer = "<html>" },
|
||||||
@@ -287,24 +222,14 @@ func crowdSecFailures() []crowdSecFailure {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// wantKeyNotShown checks that no fetch carried the engine's key to a URL
|
// wantKeyNotShown checks that the engine's key is in none of what the
|
||||||
// other than its decision list, such as the one a redirect names, and that
|
// fetches leave behind: log, the process log, the alerts waiting in queue,
|
||||||
// the key is in none of what the fetches leave behind: log, the process
|
// and the copies of lists, which reputation.json keeps.
|
||||||
// log, the alerts waiting in queue, and the copies of lists, which
|
|
||||||
// reputation.json keeps.
|
|
||||||
func wantKeyNotShown(
|
func wantKeyNotShown(
|
||||||
t *testing.T, e *engine, log string, lists *reputation.Lists, queue *alerts.Queue,
|
t *testing.T, log string, lists *reputation.Lists, queue *alerts.Queue,
|
||||||
) {
|
) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
e.mu.Lock()
|
|
||||||
keySentTo := e.keySentTo
|
|
||||||
e.mu.Unlock()
|
|
||||||
|
|
||||||
if len(keySentTo) != 0 {
|
|
||||||
t.Errorf("the key was sent to %v", keySentTo)
|
|
||||||
}
|
|
||||||
|
|
||||||
shown, err := json.Marshal([]any{lists.Snapshot(), waiting(queue)})
|
shown, err := json.Marshal([]any{lists.Snapshot(), waiting(queue)})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("encode: %v", err)
|
t.Fatalf("encode: %v", err)
|
||||||
@@ -321,7 +246,7 @@ func TestCrowdSecDecisionListKeptAcrossARestartEndsWhenItsDecisionsDo(t *testing
|
|||||||
synctest.Test(t, func(t *testing.T) {
|
synctest.Test(t, func(t *testing.T) {
|
||||||
began := time.Now()
|
began := time.Now()
|
||||||
e := &engine{key: engineKey, decisions: []decision{
|
e := &engine{key: engineKey, decisions: []decision{
|
||||||
{rangeScope, "198.51.100.0/24", ban, probing, began.Add(time.Hour)},
|
{"Range", "198.51.100.0/24", ban, probing, began.Add(time.Hour)},
|
||||||
}}
|
}}
|
||||||
lists := start(t, e, crowdSecParams())
|
lists := start(t, e, crowdSecParams())
|
||||||
kept := lists.Snapshot()
|
kept := lists.Snapshot()
|
||||||
@@ -388,18 +313,14 @@ func TestLoadTakesACrowdSecListNeverFetchedAndRefusesACopyThatDoesNotRead(
|
|||||||
// decisions still in force, each with the time it has left as it answers,
|
// decisions still in force, each with the time it has left as it answers,
|
||||||
// by the bubble's clock, as an engine does, or with answer while that is
|
// by the bubble's clock, as an engine does, or with answer while that is
|
||||||
// not "". It answers 403 to a fetch without its key, as an engine does,
|
// not "". It answers 403 to a fetch without its key, as an engine does,
|
||||||
// with a redirect to redirect while that is not "", and 503 while failing.
|
// and 503 while failing. It counts the fetches.
|
||||||
// It counts the fetches, and notes in keySentTo the URL of each fetch of
|
|
||||||
// another URL that carries a key, as one following a redirect would.
|
|
||||||
type engine struct {
|
type engine struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
key string
|
key string
|
||||||
decisions []decision
|
decisions []decision
|
||||||
answer string
|
answer string
|
||||||
redirect string
|
|
||||||
failing bool
|
failing bool
|
||||||
fetches int
|
fetches int
|
||||||
keySentTo []string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// decision is a decision of the engine, which ends at expires.
|
// decision is a decision of the engine, which ends at expires.
|
||||||
@@ -415,17 +336,11 @@ func (e *engine) RoundTrip(req *http.Request) (*http.Response, error) {
|
|||||||
|
|
||||||
e.fetches++
|
e.fetches++
|
||||||
|
|
||||||
if req.URL.String() != decisionsURL && req.Header.Get("X-Api-Key") != "" {
|
status, body := http.StatusOK, e.answer
|
||||||
e.keySentTo = append(e.keySentTo, req.URL.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
status, header, body := http.StatusOK, http.Header{}, e.answer
|
|
||||||
|
|
||||||
switch {
|
switch {
|
||||||
case req.URL.String() != decisionsURL || req.Header.Get("X-Api-Key") != e.key:
|
case req.URL.String() != decisionsURL || req.Header.Get("X-Api-Key") != e.key:
|
||||||
status, body = http.StatusForbidden, `{"message":"access forbidden"}`
|
status, body = http.StatusForbidden, `{"message":"access forbidden"}`
|
||||||
case e.redirect != "":
|
|
||||||
status, header = http.StatusFound, http.Header{"Location": {e.redirect}}
|
|
||||||
case e.failing:
|
case e.failing:
|
||||||
status, body = http.StatusServiceUnavailable, ""
|
status, body = http.StatusServiceUnavailable, ""
|
||||||
case body == "":
|
case body == "":
|
||||||
@@ -435,7 +350,7 @@ func (e *engine) RoundTrip(req *http.Request) (*http.Response, error) {
|
|||||||
return &http.Response{
|
return &http.Response{
|
||||||
StatusCode: status,
|
StatusCode: status,
|
||||||
Status: fmt.Sprintf("%d %s", status, http.StatusText(status)),
|
Status: fmt.Sprintf("%d %s", status, http.StatusText(status)),
|
||||||
Header: header,
|
Header: http.Header{},
|
||||||
Body: io.NopCloser(strings.NewReader(body)),
|
Body: io.NopCloser(strings.NewReader(body)),
|
||||||
Request: req,
|
Request: req,
|
||||||
}, nil
|
}, nil
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import (
|
|||||||
// network.
|
// network.
|
||||||
func (l *Lists) SetTransport(transport http.RoundTripper) {
|
func (l *Lists) SetTransport(transport http.RoundTripper) {
|
||||||
l.httpClient.Transport = transport
|
l.httpClient.Transport = transport
|
||||||
l.crowdSecClient.Transport = transport
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetTransport has a's checks go through transport instead of the
|
// SetTransport has a's checks go through transport instead of the
|
||||||
|
|||||||
@@ -93,10 +93,6 @@ type Params struct {
|
|||||||
type Lists struct {
|
type Lists struct {
|
||||||
params Params
|
params Params
|
||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
// crowdSecClient fetches the CrowdSec decision list. It follows no
|
|
||||||
// redirect, so that the key goes to the engine alone: a redirect is a
|
|
||||||
// failure.
|
|
||||||
crowdSecClient *http.Client
|
|
||||||
|
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
// lists are by URL, one for each URL Params names.
|
// lists are by URL, one for each URL Params names.
|
||||||
@@ -133,16 +129,7 @@ type Decision struct {
|
|||||||
|
|
||||||
// New returns the lists, without a copy of any yet.
|
// New returns the lists, without a copy of any yet.
|
||||||
func New(params Params) *Lists {
|
func New(params Params) *Lists {
|
||||||
l := &Lists{
|
l := &Lists{params: params, httpClient: &http.Client{}, lists: map[string]*list{}}
|
||||||
params: params,
|
|
||||||
httpClient: &http.Client{},
|
|
||||||
crowdSecClient: &http.Client{
|
|
||||||
CheckRedirect: func(*http.Request, []*http.Request) error {
|
|
||||||
return http.ErrUseLastResponse
|
|
||||||
},
|
|
||||||
},
|
|
||||||
lists: map[string]*list{},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, listURL := range l.URLs() {
|
for _, listURL := range l.URLs() {
|
||||||
l.lists[listURL] = &list{kept: List{URL: listURL}}
|
l.lists[listURL] = &list{kept: List{URL: listURL}}
|
||||||
@@ -429,9 +416,8 @@ func raiseFailure(queue *alerts.Queue, reason, source string, err error) {
|
|||||||
|
|
||||||
// get fetches the list at listURL, and returns its lines. The CrowdSec
|
// get fetches the list at listURL, and returns its lines. The CrowdSec
|
||||||
// decision list is fetched with CrowdSecKey in the header X-Api-Key, where
|
// decision list is fetched with CrowdSecKey in the header X-Api-Key, where
|
||||||
// the engine looks for it, by crowdSecClient, which follows no redirect.
|
// the engine looks for it. An answer other than 200, or a list longer
|
||||||
// An answer other than 200, or a list longer than maxListBytes, is a
|
// than maxListBytes, is a failure.
|
||||||
// failure.
|
|
||||||
func (l *Lists) get(ctx context.Context, listURL string) ([]string, error) {
|
func (l *Lists) get(ctx context.Context, listURL string) ([]string, error) {
|
||||||
ctx, cancel := context.WithTimeout(ctx, fetchTimeout)
|
ctx, cancel := context.WithTimeout(ctx, fetchTimeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
@@ -441,14 +427,11 @@ func (l *Lists) get(ctx context.Context, listURL string) ([]string, error) {
|
|||||||
return nil, fmt.Errorf("make the request: %w", err)
|
return nil, fmt.Errorf("make the request: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
client := l.httpClient
|
|
||||||
|
|
||||||
if listURL == l.params.CrowdSecDecisionsURL {
|
if listURL == l.params.CrowdSecDecisionsURL {
|
||||||
req.Header.Set("X-Api-Key", l.params.CrowdSecKey)
|
req.Header.Set("X-Api-Key", l.params.CrowdSecKey)
|
||||||
client = l.crowdSecClient
|
|
||||||
}
|
}
|
||||||
|
|
||||||
res, err := client.Do(req)
|
res, err := l.httpClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Do's error names the URL, which the log line and the alert name
|
// Do's error names the URL, which the log line and the alert name
|
||||||
// already: only what went wrong is kept.
|
// already: only what went wrong is kept.
|
||||||
|
|||||||
Reference in New Issue
Block a user