Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b0476bd29a |
@@ -2000,12 +2000,13 @@ 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`, such as
|
it in use too. A fetch fails when the engine answers other than `200`, a
|
||||||
`403` for a key it does not know, when it does not finish within a minute, when
|
redirect included, such as `403` for a key it does not know, when it does not
|
||||||
the answer is longer than 16 MiB or is not a JSON list of decisions, or when a
|
finish within a minute, when the answer is longer than 16 MiB or is not a JSON
|
||||||
decision to ban gives a value that is not an address or a netblock, or a
|
list of decisions, or when a decision to ban gives a value that is not an
|
||||||
`duration` that does not read. A failure is counted, logged and raised as a
|
address or a netblock, or a `duration` that does not read. A failure is counted,
|
||||||
`source_failure` alert, held back as a repeat within `SWWAF_ALERT_COOLDOWN`.
|
logged and raised as a `source_failure` alert, held back as a repeat within
|
||||||
|
`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,8 +31,10 @@ 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, as CrowdSec names it.
|
// ban is the type of a decision to ban, and rangeScope the scope of a
|
||||||
ban = "ban"
|
// decision on a netblock, as CrowdSec names them.
|
||||||
|
ban = "ban"
|
||||||
|
rangeScope = "Range"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestCrowdSecDecisionBansItsNetblockUntilItEndsEvenWithTheEngineDown(t *testing.T) {
|
func TestCrowdSecDecisionBansItsNetblockUntilItEndsEvenWithTheEngineDown(t *testing.T) {
|
||||||
@@ -46,7 +48,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)},
|
||||||
{"Range", "198.51.100.0/24", ban, probing, began.Add(time.Hour)},
|
{rangeScope, "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)},
|
||||||
@@ -111,6 +113,64 @@ 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,
|
||||||
) {
|
) {
|
||||||
@@ -167,7 +227,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, log.String(), lists, queue)
|
wantKeyNotShown(t, e, log.String(), lists, queue)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -197,6 +257,11 @@ 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>" },
|
||||||
@@ -222,14 +287,24 @@ func crowdSecFailures() []crowdSecFailure {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// wantKeyNotShown checks that the engine's key is in none of what the
|
// wantKeyNotShown checks that no fetch carried the engine's key to a URL
|
||||||
// fetches leave behind: log, the process log, the alerts waiting in queue,
|
// other than its decision list, such as the one a redirect names, and that
|
||||||
// and the copies of lists, which reputation.json keeps.
|
// the key is in none of what the fetches leave behind: log, the process
|
||||||
|
// log, the alerts waiting in queue, and the copies of lists, which
|
||||||
|
// reputation.json keeps.
|
||||||
func wantKeyNotShown(
|
func wantKeyNotShown(
|
||||||
t *testing.T, log string, lists *reputation.Lists, queue *alerts.Queue,
|
t *testing.T, e *engine, 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)
|
||||||
@@ -246,7 +321,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{
|
||||||
{"Range", "198.51.100.0/24", ban, probing, began.Add(time.Hour)},
|
{rangeScope, "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()
|
||||||
@@ -313,14 +388,18 @@ 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,
|
||||||
// and 503 while failing. It counts the fetches.
|
// with a redirect to redirect while that is not "", and 503 while failing.
|
||||||
|
// 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.
|
||||||
@@ -336,11 +415,17 @@ func (e *engine) RoundTrip(req *http.Request) (*http.Response, error) {
|
|||||||
|
|
||||||
e.fetches++
|
e.fetches++
|
||||||
|
|
||||||
status, body := http.StatusOK, e.answer
|
if req.URL.String() != decisionsURL && req.Header.Get("X-Api-Key") != "" {
|
||||||
|
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 == "":
|
||||||
@@ -350,7 +435,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: http.Header{},
|
Header: header,
|
||||||
Body: io.NopCloser(strings.NewReader(body)),
|
Body: io.NopCloser(strings.NewReader(body)),
|
||||||
Request: req,
|
Request: req,
|
||||||
}, nil
|
}, nil
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ 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,6 +93,10 @@ 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.
|
||||||
@@ -129,7 +133,16 @@ 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{params: params, httpClient: &http.Client{}, lists: map[string]*list{}}
|
l := &Lists{
|
||||||
|
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}}
|
||||||
@@ -416,8 +429,9 @@ 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. An answer other than 200, or a list longer
|
// the engine looks for it, by crowdSecClient, which follows no redirect.
|
||||||
// than maxListBytes, is a failure.
|
// An answer other than 200, or a list longer than maxListBytes, is a
|
||||||
|
// 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()
|
||||||
@@ -427,11 +441,14 @@ 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 := l.httpClient.Do(req)
|
res, err := client.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