check / check (push) Successful in 3m24s
A request over 50 cookies or 100 header fields is now refused before any other check. It causes no lookup, no query to RouteWatch, a DNS blocklist or AbuseIPDB, no rule file match and no Core Rule Set. The rate limits no longer count it, and a banned or denied client gets 431 for it. In observe mode no lookup is made for it. Requests within the limits go through the same checks in the same order. Model: opus-5-5
653 lines
21 KiB
Go
653 lines
21 KiB
Go
package discriminator_test
|
||
|
||
import (
|
||
"fmt"
|
||
"maps"
|
||
"net"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"net/netip"
|
||
"path/filepath"
|
||
"slices"
|
||
"strconv"
|
||
"strings"
|
||
"testing"
|
||
"testing/synctest"
|
||
"time"
|
||
|
||
"sneak.berlin/go/smallwebwaf/internal/bans"
|
||
"sneak.berlin/go/smallwebwaf/internal/discriminator"
|
||
"sneak.berlin/go/smallwebwaf/internal/lookup"
|
||
"sneak.berlin/go/smallwebwaf/internal/lookup/lookuptest"
|
||
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
||
"sneak.berlin/go/smallwebwaf/internal/reputation"
|
||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||
)
|
||
|
||
const (
|
||
// answerTimeLimit is the default of rproxy's SWRP_TIMEOUT_MS, the time
|
||
// discriminator has to answer.
|
||
answerTimeLimit = 2 * time.Second
|
||
// defaultLookupWait is the default of SWW_LOOKUP_TIMEOUT.
|
||
defaultLookupWait = 800 * time.Millisecond
|
||
// The header limits SPEC.md gives.
|
||
cookieLimit = 50
|
||
headerFieldLimit = 100
|
||
// cookieHeader is the header the cookie limit counts.
|
||
cookieHeader = "Cookie"
|
||
// tooLarge is how discriminator refuses a request over the header limits.
|
||
tooLarge = http.StatusRequestHeaderFieldsTooLarge
|
||
// silentAbuseIPDBURL is where AbuseIPDB never answers: at the stand-in
|
||
// that silentGeoJSURL names too.
|
||
silentAbuseIPDBURL = "silent://abuseipdb/api/v2/check"
|
||
// wholeLimit is the percentage of a rate limit that lowers it not at all.
|
||
wholeLimit = 100
|
||
// clientsDecision is the CrowdSec decision list holding client alone.
|
||
clientsDecision = `[{"duration": "4h0m0s", "scenario": "crowdsecurity/ssh-bf", ` +
|
||
`"scope": "Ip", "type": "ban", "value": "` + client + `"}]`
|
||
)
|
||
|
||
// cookies returns a Cookie header's value with count cookies.
|
||
func cookies(count int) string {
|
||
parts := make([]string, 0, count)
|
||
for i := range count {
|
||
parts = append(parts, "c"+strconv.Itoa(i)+"=1")
|
||
}
|
||
|
||
return strings.Join(parts, "; ")
|
||
}
|
||
|
||
// fillHeaders adds headers to a question until the checks see total header
|
||
// fields: all but ClientHeader and PeerHeader, which discriminator removes. Go's
|
||
// client sends nothing else but Host, which the limit does not count, once
|
||
// User-Agent is set.
|
||
func fillHeaders(req *http.Request, total int) {
|
||
req.Header.Set("User-Agent", "test")
|
||
|
||
for i := len(req.Header) - 2; i < total; i++ {
|
||
req.Header.Set("X-Filler-"+strconv.Itoa(i), "1")
|
||
}
|
||
}
|
||
|
||
func TestSixteenThousandCookiesAreRefusedBeforeTheCoreRuleSetReadsThem(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
s := startDiscriminator(t, "", map[string]string{wafMode: block})
|
||
req := s.question(siteA, client, "/")
|
||
req.Header.Set(cookieHeader, strings.Repeat("a;", 16000))
|
||
|
||
got := do(t, req)
|
||
|
||
wantDisposition(t, got, tooLarge, discriminator.DispositionDeny)
|
||
|
||
line := s.out.requestLine(t)
|
||
wantLine(t, line, tooLarge, requestlog.ActionRequestHeaderTooLarge)
|
||
|
||
if line.DurationCoreRuleSetMS != nil {
|
||
t.Errorf("the Core Rule Set read the request, for %f ms", *line.DurationCoreRuleSetMS)
|
||
}
|
||
}
|
||
|
||
func TestHeaderLimitRefusalNamesItsHeuristicCountsAndLimits(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
s := startDiscriminator(t, "", nil)
|
||
req := s.question(siteA, client, "/")
|
||
fillHeaders(req, headerFieldLimit+1)
|
||
do(t, req)
|
||
|
||
wantHeuristic(t, s.out.requestLine(t), "header_limits",
|
||
`{"fields": 101, "fields_limit": 100, "cookies": 0, "cookies_limit": 50}`)
|
||
}
|
||
|
||
func TestEachHeaderLimitRefusesARequestOverIt(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
repeated := make([]string, headerFieldLimit)
|
||
for i := range repeated {
|
||
repeated[i] = "1"
|
||
}
|
||
|
||
for _, tc := range []struct {
|
||
name string
|
||
make func(*http.Request)
|
||
// refused is true for a request over a limit, and false for one at it.
|
||
refused bool
|
||
}{
|
||
{"cookies at the limit", func(req *http.Request) {
|
||
req.Header.Set(cookieHeader, cookies(cookieLimit))
|
||
}, false},
|
||
{"cookies over the limit", func(req *http.Request) {
|
||
req.Header.Set(cookieHeader, cookies(cookieLimit+1))
|
||
}, true},
|
||
{"parts empty or only spaces and tabs are no cookies", func(req *http.Request) {
|
||
req.Header.Set(cookieHeader, cookies(cookieLimit)+"; \t;;")
|
||
}, false},
|
||
{"parts of no-break spaces count", func(req *http.Request) {
|
||
req.Header.Set(cookieHeader, strings.Repeat(" ;", cookieLimit+1))
|
||
}, true},
|
||
{"cookies over the limit across two headers", func(req *http.Request) {
|
||
req.Header[cookieHeader] = []string{cookies(cookieLimit), "last=1"}
|
||
}, true},
|
||
{"cookies without a value count", func(req *http.Request) {
|
||
req.Header.Set(cookieHeader, strings.Repeat("a;", cookieLimit+1))
|
||
}, true},
|
||
{"header fields at the limit", func(req *http.Request) {
|
||
fillHeaders(req, headerFieldLimit)
|
||
}, false},
|
||
{"header fields over the limit", func(req *http.Request) {
|
||
fillHeaders(req, headerFieldLimit+1)
|
||
}, true},
|
||
{"a header sent many times counts each time", func(req *http.Request) {
|
||
req.Header["X-Filler"] = repeated
|
||
}, true},
|
||
} {
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
s := startDiscriminator(t, "", map[string]string{wafMode: block})
|
||
req := s.question(siteA, client, "/")
|
||
tc.make(req)
|
||
|
||
got := do(t, req)
|
||
line := s.out.requestLine(t)
|
||
|
||
if tc.refused {
|
||
wantDisposition(t, got, tooLarge, discriminator.DispositionDeny)
|
||
wantLine(t, line, tooLarge, requestlog.ActionRequestHeaderTooLarge)
|
||
|
||
return
|
||
}
|
||
|
||
wantDisposition(t, got, http.StatusOK, discriminator.DispositionAllow)
|
||
wantLine(t, line, http.StatusOK, requestlog.ActionForward)
|
||
|
||
if line.DurationCoreRuleSetMS == nil {
|
||
t.Error("the Core Rule Set did not read a request within the limits")
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
func TestHeaderLimitsHoldWhateverTheCoreRuleSetSettingsSay(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
for _, env := range []map[string]string{
|
||
{wafMode: off},
|
||
{wafMode: "detect"},
|
||
{wafMode: block, "SWW_WAF_EXEMPT_PATHS": "/"},
|
||
} {
|
||
t.Run(fmt.Sprint(env), func(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
s := startDiscriminator(t, "", env)
|
||
req := s.question(siteA, client, "/")
|
||
req.Header.Set(cookieHeader, cookies(cookieLimit+1))
|
||
|
||
wantDisposition(t, do(t, req), tooLarge, discriminator.DispositionDeny)
|
||
})
|
||
}
|
||
}
|
||
|
||
func TestClientInTheAllowNetsSkipsTheHeaderLimits(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
s := startDiscriminator(t, "", map[string]string{wafMode: block, allowNets: client})
|
||
req := s.question(siteA, client, "/")
|
||
req.Header.Set(cookieHeader, cookies(cookieLimit+1))
|
||
|
||
wantDisposition(t, do(t, req), http.StatusOK, discriminator.DispositionAllow)
|
||
}
|
||
|
||
func TestObserveModeAllowsARequestOverTheHeaderLimitsUnread(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
s := startDiscriminator(t, "", map[string]string{mode: observe, wafMode: block})
|
||
req := s.question(siteA, client, "/")
|
||
req.Header.Set(cookieHeader, strings.Repeat("a;", 16000))
|
||
|
||
got := do(t, req)
|
||
|
||
wantDisposition(t, got, http.StatusOK, discriminator.DispositionAllow)
|
||
|
||
line := s.out.requestLine(t)
|
||
if line.WouldAction != requestlog.ActionRequestHeaderTooLarge ||
|
||
line.DurationCoreRuleSetMS != nil {
|
||
t.Errorf("would_action %q and duration_core_rule_set_ms %v, want %q and none",
|
||
line.WouldAction, line.DurationCoreRuleSetMS, requestlog.ActionRequestHeaderTooLarge)
|
||
}
|
||
}
|
||
|
||
func TestTheRateLimitsDoNotCountRequestsOverTheHeaderLimits(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
s := startDiscriminator(t, "", map[string]string{rateLimitPerMinute: "1"})
|
||
|
||
for range 2 {
|
||
req := s.question(siteA, client, "/")
|
||
req.Header.Set(cookieHeader, cookies(cookieLimit+1))
|
||
wantDisposition(t, do(t, req), tooLarge, discriminator.DispositionDeny)
|
||
}
|
||
|
||
wantDisposition(t, s.ask(client, "/"), http.StatusOK, discriminator.DispositionAllow)
|
||
}
|
||
|
||
func TestTheHeaderLimitsComeBeforeEveryCheckThatRefuses(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
for _, tc := range []struct {
|
||
name string
|
||
env map[string]string
|
||
path string
|
||
// prepare gets the server ready, at now, before the question.
|
||
prepare func(t *testing.T, server *discriminator.Server, now time.Time)
|
||
}{
|
||
{"SWW_DENY_NETS", map[string]string{denyNets: client}, "/", leaveAsItIs},
|
||
{"a ban", nil, "/", banClient},
|
||
{"a blocklist", map[string]string{blocklistURLs: dropURL}, "/", listClient},
|
||
{
|
||
"the CrowdSec decision list",
|
||
map[string]string{crowdSecURL: lapi, crowdSecKey: bouncerKey}, "/", decideOnClient,
|
||
},
|
||
{"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.Parallel()
|
||
|
||
s := startDiscriminator(t, "", tc.env)
|
||
tc.prepare(t, s.server, s.clock.Now())
|
||
|
||
req := s.question(siteA, client, tc.path)
|
||
req.Header.Set(cookieHeader, cookies(cookieLimit+1))
|
||
|
||
wantDisposition(t, do(t, req), tooLarge, discriminator.DispositionDeny)
|
||
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 := startDiscriminator(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, discriminator.DispositionDeny)
|
||
|
||
within := s.question(siteA, client, "/")
|
||
within.Header.Set(cookieHeader, cookies(cookieLimit))
|
||
wantDisposition(t, do(t, within), http.StatusOK, discriminator.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) {
|
||
t.Parallel()
|
||
|
||
// The client's next request takes it over the whole limit, so it does
|
||
// not wait.
|
||
geojsURL, _ := startGeoJS(t)
|
||
s := startDiscriminator(t, geojsURL, map[string]string{
|
||
unknownLimitPercent: "0", lookupTimeout: "1h", rateLimitPerMinute: "1",
|
||
})
|
||
countClient(t, s.server, s.clock.Now())
|
||
|
||
wantDisposition(t, s.ask(fromDE, "/"), http.StatusForbidden,
|
||
discriminator.DispositionDeny)
|
||
|
||
line := s.out.requestLine(t)
|
||
wantLine(t, line, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
|
||
|
||
if line.RateLimitPercent != nil {
|
||
t.Errorf("limit percentage %d, want none", *line.RateLimitPercent)
|
||
}
|
||
}
|
||
|
||
func TestAClientGeoJSPlacedInNoCountryCountsAsUnknownWithoutWaiting(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
// The client's next request takes it over the whole limit, so it does
|
||
// not wait.
|
||
geojsURL, _ := startGeoJS(t)
|
||
s := startDiscriminator(t, geojsURL, map[string]string{
|
||
unknownLimitPercent: "0", rateLimitPerMinute: "1",
|
||
})
|
||
s.server.GeoJS.Load([]lookup.Answer{{
|
||
Client: netip.MustParsePrefix(client + "/32"), ASN: "AS64512",
|
||
Answered: s.clock.Now(), Used: s.clock.Now(),
|
||
}})
|
||
countClient(t, s.server, s.clock.Now())
|
||
|
||
wantDisposition(t, s.ask(client, "/"), http.StatusForbidden,
|
||
discriminator.DispositionDeny)
|
||
|
||
line := s.out.requestLine(t)
|
||
wantLine(t, line, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
|
||
|
||
if line.RateLimitPercent == nil || *line.RateLimitPercent != 0 {
|
||
t.Errorf("limit percentage %v, want 0", line.RateLimitPercent)
|
||
}
|
||
}
|
||
|
||
func TestObserveModeAsksGeoJSNothingForARequestOverTheHeaderLimits(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
geojsURL, asked := startGeoJS(t)
|
||
s := startDiscriminator(t, geojsURL, map[string]string{
|
||
mode: observe, addLookupHeaders: on, lookupTimeout: "1h",
|
||
})
|
||
req := s.question(siteA, fromDE, "/")
|
||
req.Header.Set(cookieHeader, cookies(cookieLimit+1))
|
||
|
||
got := do(t, req)
|
||
wantDisposition(t, got, http.StatusOK, discriminator.DispositionAllow)
|
||
|
||
if len(asked()) != 0 || got.header.Get("X-Client-Asn") != "" {
|
||
t.Errorf("GeoJS was asked about %v, and the answer gave the AS number %q, "+
|
||
"want neither", asked(), got.header.Get("X-Client-Asn"))
|
||
}
|
||
}
|
||
|
||
// newDiscriminatorInBubble makes a discriminator for a synctest bubble: GeoJS never
|
||
// answers, a setting needs its answer, and everything is at its default but
|
||
// for env. No request to it may use the network.
|
||
func newDiscriminatorInBubble(
|
||
t *testing.T, env map[string]string,
|
||
) (*discriminator.Server, *output) {
|
||
t.Helper()
|
||
|
||
settings := map[string]string{tagDeny: "country=KP"}
|
||
maps.Copy(settings, env)
|
||
|
||
server, out, _ := newDiscriminator(t, silentGeoJSURL, time.Now, settings)
|
||
|
||
return server, out
|
||
}
|
||
|
||
// askDirectly hands a question about a request from client straight to server,
|
||
// as change leaves it, and returns the answer's status and how long it took.
|
||
func askDirectly(
|
||
t *testing.T, server *discriminator.Server, change func(*http.Request),
|
||
) (int, time.Duration) {
|
||
t.Helper()
|
||
|
||
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", http.NoBody)
|
||
req.RemoteAddr = net.JoinHostPort(rproxyPeer, "1234")
|
||
req.Header.Set(discriminator.ClientHeader, client)
|
||
req.Header.Set(requestIDHeader, "the-id")
|
||
change(req)
|
||
|
||
recorder := httptest.NewRecorder()
|
||
began := time.Now()
|
||
|
||
server.Handler.ServeHTTP(recorder, req)
|
||
|
||
return recorder.Code, time.Since(began)
|
||
}
|
||
|
||
// banClient bans client at now.
|
||
func banClient(_ *testing.T, server *discriminator.Server, now time.Time) {
|
||
server.Ledger.BanForLimit(netip.MustParsePrefix(client+"/32"), now, bans.Notes{})
|
||
}
|
||
|
||
// countClient counts one request from client at now for the rate limits.
|
||
func countClient(_ *testing.T, server *discriminator.Server, now time.Time) {
|
||
server.Limiter.Count(netip.MustParsePrefix(client+"/32"), now, wholeLimit)
|
||
}
|
||
|
||
// listClient puts client on the blocklist at dropURL.
|
||
func listClient(t *testing.T, server *discriminator.Server, now time.Time) {
|
||
t.Helper()
|
||
|
||
err := server.Lists.Load([]reputation.List{{
|
||
URL: dropURL, Tried: now, Fetched: now, Lines: []string{client},
|
||
}})
|
||
if err != nil {
|
||
t.Fatalf("load the blocklist: %v", err)
|
||
}
|
||
}
|
||
|
||
// decideOnClient puts client on the CrowdSec decision list.
|
||
func decideOnClient(t *testing.T, server *discriminator.Server, now time.Time) {
|
||
t.Helper()
|
||
loadDecisions(t, server, now, clientsDecision)
|
||
}
|
||
|
||
// leaveAsItIs prepares nothing.
|
||
func leaveAsItIs(*testing.T, *discriminator.Server, time.Time) {}
|
||
|
||
func TestARefusedRequestDoesNotWaitForASlowGeoJS(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
const forbidden = http.StatusForbidden
|
||
|
||
for _, tc := range []struct {
|
||
name string
|
||
env map[string]string
|
||
// prepare gets the server ready, at now, before the question.
|
||
prepare func(t *testing.T, server *discriminator.Server, now time.Time)
|
||
// cookie is the request's Cookie header.
|
||
cookie string
|
||
status int
|
||
action string
|
||
}{
|
||
{
|
||
name: "SWW_DENY_NETS", env: map[string]string{denyNets: client},
|
||
prepare: leaveAsItIs, status: forbidden, action: requestlog.ActionClientDenied,
|
||
},
|
||
{
|
||
name: "a ban",
|
||
prepare: banClient, status: forbidden, action: requestlog.ActionTemporarilyBanned,
|
||
},
|
||
{
|
||
name: "the rate limits", env: map[string]string{rateLimitPerMinute: "1"},
|
||
prepare: countClient, status: forbidden,
|
||
action: requestlog.ActionClientIsRateLimited,
|
||
},
|
||
{
|
||
name: "the rate limits, with a biased threshold",
|
||
env: map[string]string{rateLimitPerMinute: "1", unknownLimitPercent: "50"},
|
||
prepare: countClient, status: forbidden,
|
||
action: requestlog.ActionClientIsRateLimited,
|
||
},
|
||
{
|
||
name: "a blocklist", env: map[string]string{blocklistURLs: dropURL},
|
||
prepare: listClient, status: forbidden, action: requestlog.ActionClientDenied,
|
||
},
|
||
{
|
||
name: "the CrowdSec decision list",
|
||
env: map[string]string{crowdSecURL: lapi, crowdSecKey: bouncerKey},
|
||
prepare: decideOnClient, status: forbidden,
|
||
action: requestlog.ActionTemporarilyBanned,
|
||
},
|
||
{
|
||
name: "the header limits", cookie: cookies(cookieLimit + 1),
|
||
prepare: leaveAsItIs, status: tooLarge,
|
||
action: requestlog.ActionRequestHeaderTooLarge,
|
||
},
|
||
} {
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
// In a synctest bubble, as TestRequestWaitsAsLongAsTheLookupTimeoutSays
|
||
// says, so that a request that does not wait takes no time at all.
|
||
synctest.Test(t, func(t *testing.T) {
|
||
server, out := newDiscriminatorInBubble(t, tc.env)
|
||
tc.prepare(t, server, time.Now())
|
||
|
||
status, took := askDirectly(t, server, func(req *http.Request) {
|
||
req.Header.Set(cookieHeader, tc.cookie)
|
||
})
|
||
|
||
if status != tc.status || took != 0 {
|
||
t.Errorf("answered %d after %s, want %d at once; output:\n%s",
|
||
status, took, tc.status, out.text())
|
||
}
|
||
|
||
wantLine(t, out.requestLine(t), tc.status, tc.action)
|
||
|
||
// GeoJS's request is abandoned before the bubble ends.
|
||
time.Sleep(defaultLookupWait)
|
||
})
|
||
})
|
||
}
|
||
}
|
||
|
||
func TestAQuestionWaitsForASlowGeoJSOnlyAsLongAsTheDefaultSays(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
for _, tc := range []struct {
|
||
name string
|
||
env map[string]string
|
||
// prepare gets the server ready, at now, before the question.
|
||
prepare func(t *testing.T, server *discriminator.Server, now time.Time)
|
||
status int
|
||
action string
|
||
}{
|
||
{"nothing refuses it", nil, leaveAsItIs, http.StatusOK, requestlog.ActionForward},
|
||
{
|
||
"a country list needs the answer", map[string]string{allowedCountries: "DE"},
|
||
leaveAsItIs, http.StatusForbidden, requestlog.ActionCountryDenied,
|
||
},
|
||
{
|
||
"a blocklist that only logs",
|
||
map[string]string{blocklistURLs: dropURL, blocklistAction: actionLog},
|
||
listClient, http.StatusOK, requestlog.ActionForward,
|
||
},
|
||
{
|
||
"a blocklist that only limits",
|
||
map[string]string{blocklistURLs: dropURL, blocklistAction: limitQuarter},
|
||
listClient, http.StatusOK, requestlog.ActionForward,
|
||
},
|
||
{
|
||
"over a rate limit on an exempt path",
|
||
map[string]string{rateLimitPerMinute: "1", rateLimitExemptPaths: "/"},
|
||
countClient, http.StatusOK, requestlog.ActionForward,
|
||
},
|
||
} {
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
synctest.Test(t, func(t *testing.T) {
|
||
server, out := newDiscriminatorInBubble(t, tc.env)
|
||
tc.prepare(t, server, time.Now())
|
||
|
||
status, took := askDirectly(t, server, func(*http.Request) {})
|
||
|
||
if status != tc.status || took != defaultLookupWait {
|
||
t.Errorf("answered %d after %s, want %d after %s; output:\n%s",
|
||
status, took, tc.status, defaultLookupWait, out.text())
|
||
}
|
||
|
||
wantLine(t, out.requestLine(t), tc.status, tc.action)
|
||
})
|
||
})
|
||
}
|
||
}
|
||
|
||
func TestDiscriminatorAnswersWithinTheTimeLimitWhileEveryOutsideSourceHangs(
|
||
t *testing.T,
|
||
) {
|
||
t.Parallel()
|
||
|
||
held, askedGeoJS, _ := startHeldGeoJS(t)
|
||
|
||
// A resolver that takes every query and answers none.
|
||
resolver, err := (&net.ListenConfig{}).ListenPacket(t.Context(), "udp", localhost+":0")
|
||
if err != nil {
|
||
t.Fatalf("listen: %v", err)
|
||
}
|
||
|
||
t.Cleanup(func() { _ = resolver.Close() })
|
||
|
||
server, out, _ := newDiscriminatorWith(t, func(
|
||
params discriminator.Params,
|
||
) *discriminator.Server {
|
||
params.AbuseIPDBURL = silentAbuseIPDBURL
|
||
|
||
return discriminator.New(params)
|
||
}, held, time.Now, map[string]string{
|
||
tagDeny: "country=KP", wafMode: block,
|
||
dnsblZones: dnsblZone, dnsblResolver: resolver.LocalAddr().String(),
|
||
abuseIPDBKey: accountKey,
|
||
blocklistURLs: held + "/drop.txt", asnLimitPercentURL: held + "/asn.txt",
|
||
crowdSecURL: held, crowdSecKey: bouncerKey,
|
||
})
|
||
|
||
// AbuseIPDB is asked only about a client with an offence.
|
||
group := netip.MustParsePrefix(client + "/32")
|
||
server.Limiter.AddToHistory(group, time.Now(), ratelimit.Request{Attack: true})
|
||
|
||
go server.Lists.Run(t.Context())
|
||
|
||
status, took := askDirectly(t, server, func(req *http.Request) {
|
||
req.Header.Set("User-Agent", "test")
|
||
req.Header.Set("Accept", "*/*")
|
||
req.Host = siteA
|
||
})
|
||
|
||
if status != http.StatusOK || took >= answerTimeLimit {
|
||
t.Errorf("answered %d after %s, want %d within %s; output:\n%s",
|
||
status, took, http.StatusOK, answerTimeLimit, out.text())
|
||
}
|
||
|
||
// Each outside source was asked, and none has answered. The stand-in for
|
||
// GeoJS is the lists' too, which name no address.
|
||
if !slices.Contains(askedGeoJS(), client) {
|
||
t.Errorf("GeoJS was asked about %q, not %s", askedGeoJS(), client)
|
||
}
|
||
|
||
if queries := server.DNSBL.Queries(dnsblZone); queries != 1 {
|
||
t.Errorf("%d queries to the DNS blocklist, want 1", queries)
|
||
}
|
||
|
||
if checked := server.AbuseIPDB.Checked(); checked != 1 {
|
||
t.Errorf("%d checks with AbuseIPDB, want 1", checked)
|
||
}
|
||
}
|