check / check (push) Failing after 15m18s
A question waits on GeoJS at most 800ms, the new default of SWWAF_LOOKUP_TIMEOUT. In enforce mode it does not wait at all when it is over the header limits, on a fetched blocklist with deny, on the CrowdSec decision list, or over a whole rate limit. Not waiting never lowers a client's limit. Just before the Core Rule Set, servewaf refuses more than 50 cookies or 100 header fields with 431. Shared default changed: SWWAF_LOOKUP_TIMEOUT also applies to smallwebwaf with no command. Judgement call: SWWAF_REPUTATION_TIMEOUT stays 2s, since no request waits on the DNS blocklists or AbuseIPDB. Model: opus-5-5
601 lines
19 KiB
Go
601 lines
19 KiB
Go
package proxy_test
|
||
|
||
import (
|
||
"fmt"
|
||
"maps"
|
||
"net"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"net/netip"
|
||
"slices"
|
||
"strconv"
|
||
"strings"
|
||
"testing"
|
||
"testing/synctest"
|
||
"time"
|
||
|
||
"sneak.berlin/go/smallwebwaf/internal/bans"
|
||
"sneak.berlin/go/smallwebwaf/internal/lookup"
|
||
"sneak.berlin/go/smallwebwaf/internal/proxy"
|
||
"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
|
||
// servewaf has to answer.
|
||
answerTimeLimit = 2 * time.Second
|
||
// defaultLookupWait is the default of SWWAF_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 servewaf refuses a request over the header limits.
|
||
tooLarge = http.StatusRequestHeaderFieldsTooLarge
|
||
// silentAbuseIPDBURL is where AbuseIPDB never answers: at the stand-in
|
||
// that unansweredGeoJSURL names too.
|
||
silentAbuseIPDBURL = "unanswered://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 servewaf 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 TestSixteenThousandCookiesAreRefusedWellWithinTheTimeLimit(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
s := startServeWAF(t, "", map[string]string{wafMode: block})
|
||
req := s.question(siteA, client, "/")
|
||
req.Header.Set(cookieHeader, strings.Repeat("a;", 16000))
|
||
|
||
began := time.Now()
|
||
got := do(t, req)
|
||
|
||
if took := time.Since(began); took >= answerTimeLimit/2 {
|
||
t.Errorf("answered after %s, want well within %s", took, answerTimeLimit)
|
||
}
|
||
|
||
wantDisposition(t, got, tooLarge, proxy.DispositionDeny)
|
||
|
||
line := s.out.requestLine(t)
|
||
wantLine(t, line, tooLarge, requestlog.ActionTooLarge)
|
||
|
||
if line.DurationWAF != nil {
|
||
t.Errorf("the Core Rule Set read the request, for %f ms", *line.DurationWAF)
|
||
}
|
||
}
|
||
|
||
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 := startServeWAF(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, proxy.DispositionDeny)
|
||
wantLine(t, line, tooLarge, requestlog.ActionTooLarge)
|
||
|
||
return
|
||
}
|
||
|
||
wantDisposition(t, got, http.StatusOK, proxy.DispositionAllow)
|
||
wantLine(t, line, http.StatusOK, requestlog.ActionForward)
|
||
|
||
if line.DurationWAF == 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, "SWWAF_WAF_EXEMPT_PATHS": "/"},
|
||
} {
|
||
t.Run(fmt.Sprint(env), func(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
s := startServeWAF(t, "", env)
|
||
req := s.question(siteA, client, "/")
|
||
req.Header.Set(cookieHeader, cookies(cookieLimit+1))
|
||
|
||
wantDisposition(t, do(t, req), tooLarge, proxy.DispositionDeny)
|
||
})
|
||
}
|
||
}
|
||
|
||
func TestClientInTheAllowNetsSkipsTheHeaderLimits(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
s := startServeWAF(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, proxy.DispositionAllow)
|
||
}
|
||
|
||
func TestObserveModeAllowsARequestOverTheHeaderLimitsUnread(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
s := startServeWAF(t, "", map[string]string{mode: observe, wafMode: block})
|
||
req := s.question(siteA, client, "/")
|
||
req.Header.Set(cookieHeader, strings.Repeat("a;", 16000))
|
||
|
||
began := time.Now()
|
||
got := do(t, req)
|
||
|
||
if took := time.Since(began); took >= answerTimeLimit/2 {
|
||
t.Errorf("answered after %s, want well within %s", took, answerTimeLimit)
|
||
}
|
||
|
||
wantDisposition(t, got, http.StatusOK, proxy.DispositionAllow)
|
||
|
||
line := s.out.requestLine(t)
|
||
if line.WouldAction != requestlog.ActionTooLarge || line.DurationWAF != nil {
|
||
t.Errorf("would_action %q and duration_waf %v, want %q and none",
|
||
line.WouldAction, line.DurationWAF, requestlog.ActionTooLarge)
|
||
}
|
||
}
|
||
|
||
func TestRequestsOverTheHeaderLimitsCountTowardTheRateLimits(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
s := startServeWAF(t, "", map[string]string{rateLimitPerMinute: "1"})
|
||
ask := func() answer {
|
||
req := s.question(siteA, client, "/")
|
||
req.Header.Set(cookieHeader, cookies(cookieLimit+1))
|
||
|
||
return do(t, req)
|
||
}
|
||
|
||
wantDisposition(t, ask(), tooLarge, proxy.DispositionDeny)
|
||
wantDisposition(t, ask(), http.StatusForbidden, proxy.DispositionDeny)
|
||
|
||
lines := s.out.requestLines(t, 2)
|
||
wantLine(t, lines[1], http.StatusForbidden, requestlog.ActionRateLimited)
|
||
}
|
||
|
||
func TestTheHeaderLimitsComeAfterTheTrapPathsAndTheRuleFiles(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
for _, tc := range []struct {
|
||
name, path, action string
|
||
}{
|
||
{"a trap path", "/wp-login.php", requestlog.ActionBanned},
|
||
{"a block rule", "/blocked", requestlog.ActionRuleBlocked},
|
||
} {
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
s := startServeWAF(t, "", map[string]string{
|
||
trapPaths: trapPathList, rulesDir: writeRules(t, testRules),
|
||
})
|
||
req := s.question(siteA, client, tc.path)
|
||
req.Header.Set(cookieHeader, cookies(cookieLimit+1))
|
||
|
||
wantDisposition(t, do(t, req), http.StatusForbidden, proxy.DispositionDeny)
|
||
wantLine(t, s.out.requestLine(t), http.StatusForbidden, tc.action)
|
||
})
|
||
}
|
||
}
|
||
|
||
func TestNotWaitingForGeoJSNeverLowersAClientsLimit(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
geojsURL, _ := startGeoJS(t)
|
||
s := startServeWAF(t, geojsURL, map[string]string{
|
||
unknownLimitPercent: "0", lookupTimeout: "1h",
|
||
})
|
||
req := s.question(siteA, fromDE, "/")
|
||
req.Header.Set(cookieHeader, cookies(cookieLimit+1))
|
||
|
||
wantDisposition(t, do(t, req), tooLarge, proxy.DispositionDeny)
|
||
|
||
line := s.out.requestLine(t)
|
||
wantLine(t, line, tooLarge, requestlog.ActionTooLarge)
|
||
|
||
if line.LimitPercent != nil {
|
||
t.Errorf("limit percentage %d, want none", *line.LimitPercent)
|
||
}
|
||
|
||
// The client is under no ban, and GeoJS places it in a country.
|
||
wantDisposition(t, s.ask(fromDE, "/"), http.StatusOK, proxy.DispositionAllow)
|
||
}
|
||
|
||
func TestAClientGeoJSPlacedInNoCountryCountsAsUnknownWithoutWaiting(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
geojsURL, _ := startGeoJS(t)
|
||
s := startServeWAF(t, geojsURL, map[string]string{unknownLimitPercent: "0"})
|
||
s.server.GeoJS.Load([]lookup.Answer{{
|
||
Client: netip.MustParsePrefix(client + "/32"), ASN: "AS64512",
|
||
Answered: s.clock.Now(), Used: s.clock.Now(),
|
||
}})
|
||
|
||
req := s.question(siteA, client, "/")
|
||
req.Header.Set(cookieHeader, cookies(cookieLimit+1))
|
||
|
||
wantDisposition(t, do(t, req), http.StatusForbidden, proxy.DispositionDeny)
|
||
wantLine(t, s.out.requestLine(t), http.StatusForbidden, requestlog.ActionRateLimited)
|
||
}
|
||
|
||
func TestObserveModeWaitsForGeoJSForARequestOverTheHeaderLimits(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
geojsURL, _ := startGeoJS(t)
|
||
s := startServeWAF(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, proxy.DispositionAllow)
|
||
|
||
asn, country := got.header.Get("X-Client-Asn"), got.header.Get("X-Client-Country")
|
||
if asn != asnDE || country != "DE" {
|
||
t.Errorf("answered with the AS number %q and the country %q, want %q and DE",
|
||
asn, country, asnDE)
|
||
}
|
||
}
|
||
|
||
func TestWithoutACommandTheHeaderLimitsDoNotApply(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
app := startApp(t, func(http.ResponseWriter, *http.Request) {})
|
||
addr, out := startProxy(t, app.URL, nil)
|
||
|
||
req := newRequest(t, http.MethodGet, addr, "/", http.NoBody)
|
||
req.Header.Set(cookieHeader, cookies(cookieLimit+1))
|
||
fillHeaders(req, headerFieldLimit+1)
|
||
|
||
wantStatus(t, do(t, req), http.StatusOK)
|
||
wantLine(t, out.requestLine(t), http.StatusOK, requestlog.ActionForward)
|
||
}
|
||
|
||
// newServeWAFInBubble makes a servewaf 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 newServeWAFInBubble(t *testing.T, env map[string]string) (*proxy.Server, *output) {
|
||
t.Helper()
|
||
|
||
settings := map[string]string{deniedCountries: "KP"}
|
||
maps.Copy(settings, env)
|
||
|
||
server, out, _ := newServerWith(t, proxy.NewServeWAF, "http://app.invalid",
|
||
unansweredGeoJSURL, 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 *proxy.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(proxy.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 *proxy.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 *proxy.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 *proxy.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 *proxy.Server, now time.Time) {
|
||
t.Helper()
|
||
loadDecisions(t, server, now, clientsDecision)
|
||
}
|
||
|
||
// leaveAsItIs prepares nothing.
|
||
func leaveAsItIs(*testing.T, *proxy.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 *proxy.Server, now time.Time)
|
||
// cookie is the request's Cookie header.
|
||
cookie string
|
||
status int
|
||
action string
|
||
}{
|
||
{
|
||
name: "SWWAF_DENY_NETS", env: map[string]string{denyNets: client},
|
||
prepare: leaveAsItIs, status: forbidden, action: requestlog.ActionDenied,
|
||
},
|
||
{
|
||
name: "a ban",
|
||
prepare: banClient, status: forbidden, action: requestlog.ActionBanned,
|
||
},
|
||
{
|
||
name: "the rate limits", env: map[string]string{rateLimitPerMinute: "1"},
|
||
prepare: countClient, status: forbidden, action: requestlog.ActionRateLimited,
|
||
},
|
||
{
|
||
name: "the rate limits, with a biased threshold",
|
||
env: map[string]string{rateLimitPerMinute: "1", unknownLimitPercent: "50"},
|
||
prepare: countClient, status: forbidden, action: requestlog.ActionRateLimited,
|
||
},
|
||
{
|
||
name: "a blocklist", env: map[string]string{blocklistURLs: dropURL},
|
||
prepare: listClient, status: forbidden, action: requestlog.ActionDenied,
|
||
},
|
||
{
|
||
name: "the CrowdSec decision list",
|
||
env: map[string]string{crowdSecURL: lapi, crowdSecKey: bouncerKey},
|
||
prepare: decideOnClient, status: forbidden, action: requestlog.ActionBanned,
|
||
},
|
||
{
|
||
name: "the header limits", cookie: cookies(cookieLimit + 1),
|
||
prepare: leaveAsItIs, status: tooLarge, action: requestlog.ActionTooLarge,
|
||
},
|
||
} {
|
||
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 := newServeWAFInBubble(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 *proxy.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 := newServeWAFInBubble(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 TestWithoutACommandARefusedRequestStillWaitsForGeoJS(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
synctest.Test(t, func(t *testing.T) {
|
||
server, out, _ := newProxy(t, "http://app.invalid", unansweredGeoJSURL,
|
||
time.Now, map[string]string{deniedCountries: "KP", blocklistURLs: dropURL})
|
||
listClient(t, server, time.Now())
|
||
|
||
status, took := askDirectly(t, server, func(req *http.Request) {
|
||
req.RemoteAddr = net.JoinHostPort(client, "1234")
|
||
})
|
||
|
||
if status != http.StatusForbidden || took != defaultLookupWait {
|
||
t.Errorf("answered %d after %s, want %d after %s; output:\n%s",
|
||
status, took, http.StatusForbidden, defaultLookupWait, out.text())
|
||
}
|
||
|
||
wantLine(t, out.requestLine(t), http.StatusForbidden, requestlog.ActionDenied)
|
||
})
|
||
}
|
||
|
||
func TestServeWAFAnswersWithinTheTimeLimitWhileEveryOutsideSourceHangs(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, _ := newServerWith(t, func(params proxy.Params) *proxy.Server {
|
||
params.AbuseIPDBURL = silentAbuseIPDBURL
|
||
|
||
return proxy.NewServeWAF(params)
|
||
}, "http://app.invalid", held, time.Now, map[string]string{
|
||
deniedCountries: "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)
|
||
}
|
||
}
|