Files
smallwebwaf/internal/waf/answertime_test.go
T
clawbot 0b23352767
check / check (push) Waiting to run
Format Go with gofmt, then golines -m 77 (closes #215)
script/fmt now runs golines -m 77 after gofmt, repeating golines until
it changes nothing, since one pass can leave a line the next splits.
script/fmt-check fails when either would change a file.
script/bootstrap installs golines v0.13.0 with go install, pinned to
its commit, and checks the installed version with go version -m.

The tree is reformatted. The longer lines this makes are fixed by
hand: nolint directives moved onto their own line above the statement,
one string split, and 20 functions over funlen's 80 lines shortened or
split, RunWAF and limitPercentages among them.

Model: opus-5-5
2026-11-11 17:29:12 +01:00

898 lines
25 KiB
Go
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package waf_test
import (
"encoding/binary"
"fmt"
"maps"
"net"
"net/http"
"net/http/httptest"
"net/netip"
"path/filepath"
"slices"
"strconv"
"strings"
"sync"
"testing"
"testing/synctest"
"time"
"github.com/prometheus/client_golang/prometheus/testutil"
"sneak.berlin/go/smallwebwaf/internal/bans"
"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"
"sneak.berlin/go/smallwebwaf/internal/waf"
)
const (
// answerTimeLimit is the default of rproxy's SWRP_TIMEOUT_MS, the time
// waf has to answer.
answerTimeLimit = 2 * time.Second
// defaultLookupWait is the default of SWW_LOOKUP_TIMEOUT.
defaultLookupWait = 800 * time.Millisecond
// defaultReputationWait is the default of SWW_REPUTATION_TIMEOUT.
defaultReputationWait = 2 * time.Second
// The header limits SPEC.md gives.
cookieLimit = 50
headerFieldLimit = 100
// cookieHeader is the header the cookie limit counts.
cookieHeader = "Cookie"
// tooLarge is how waf 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 waf 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 := startWAF(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, waf.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 := startWAF(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 := startWAF(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, waf.DispositionDeny)
wantLine(
t,
line,
tooLarge,
requestlog.ActionRequestHeaderTooLarge,
)
return
}
wantDisposition(t, got, http.StatusOK, waf.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 := startWAF(t, "", env)
req := s.question(siteA, client, "/")
req.Header.Set(cookieHeader, cookies(cookieLimit+1))
wantDisposition(t, do(t, req), tooLarge, waf.DispositionDeny)
})
}
}
func TestClientInTheAllowNetsSkipsTheHeaderLimits(t *testing.T) {
t.Parallel()
s := startWAF(
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, waf.DispositionAllow)
}
func TestObserveModeAllowsARequestOverTheHeaderLimitsUnread(t *testing.T) {
t.Parallel()
s := startWAF(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, waf.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 := startWAF(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, waf.DispositionDeny)
}
wantDisposition(
t,
s.ask(client, "/"),
http.StatusOK,
waf.DispositionAllow,
)
}
func TestTheHeaderLimitsComeBeforeEveryCheckThatRefuses(t *testing.T) {
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 {
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)
}{
{"SWW_DENY_NETS", map[string]string{denyNets: client}, "/", leaveAsItIs},
{"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.Parallel()
s := startWAF(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, waf.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 := 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) {
t.Parallel()
// The client's next request takes it over the whole limit, so it does
// not wait.
geojsURL, _ := startGeoJS(t)
s := startWAF(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,
waf.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 := startWAF(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,
waf.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 := startWAF(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, waf.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"),
)
}
}
// newWAFInBubble makes a waf 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 newWAFInBubble(
t *testing.T, env map[string]string,
) (*waf.Server, *output) {
t.Helper()
settings := map[string]string{tagDeny: "country=KP"}
maps.Copy(settings, env)
server, out, _ := newWAF(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 *waf.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(waf.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 *waf.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 *waf.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 *waf.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 *waf.Server, now time.Time) {
t.Helper()
loadDecisions(t, server, now, clientsDecision)
}
// leaveAsItIs prepares nothing.
func leaveAsItIs(*testing.T, *waf.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 *waf.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 := newWAFInBubble(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 *waf.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 := newWAFInBubble(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 TestWAFAnswersWithinTheTimeLimitWhileEveryOutsideSourceHangs(
t *testing.T,
) {
t.Parallel()
// The lists' stand-in: one for GeoJS that answers nothing.
held, _, _ := startHeldGeoJS(t)
resolver, releaseResolver := startHeldResolver(t)
server, out, _ := newWAFWith(t, func(
params waf.Params,
) *waf.Server {
params.AbuseIPDBURL = silentAbuseIPDBURL
return waf.New(params)
}, silentGeoJSURL, time.Now, map[string]string{
tagDeny: "country=KP", wafMode: block,
dnsblZones: dnsblZone, dnsblResolver: resolver,
abuseIPDBKey: accountKey,
blocklistURLs: held + "/drop.txt", asnLimitPercentURL: held + "/asn.txt",
crowdSecURL: held, crowdSecKey: bouncerKey,
})
// The lists are fetched outside the synctest bubble below: a wait on the
// network inside it would keep its clock from moving on.
go server.Lists.Run(t.Context())
// In the bubble, as TestRequestWaitsAsLongAsTheLookupTimeoutSays says, the
// answer takes exactly as long as waf waits, however busy the host is.
// GeoJS and AbuseIPDB never answer there, without the network. The query to
// the DNS blocklist waits on the network, which stops the bubble's clock,
// but it starts only after the wait for GeoJS.
synctest.Test(t, func(t *testing.T) {
// 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},
)
status, took := askDirectly(t, server, func(req *http.Request) {
req.Header.Set("User-Agent", "test")
req.Header.Set("Accept", "*/*")
req.Host = siteA
})
// The wait for GeoJS decides when the answer comes.
if status != http.StatusOK || took != defaultLookupWait ||
took >= answerTimeLimit {
t.Errorf(
"answered %d after %s, want %d after %s, within %s; output:\n%s",
status,
took,
http.StatusOK,
defaultLookupWait,
answerTimeLimit,
out.text(),
)
}
// Each outside source was asked, and none has answered. GeoJS is asked
// only about the clients waiting for it, here client alone.
if requests := testutil.ToFloat64(server.Metrics.GeoJSRequests); requests != 1 {
t.Errorf("%v requests to GeoJS, want 1", requests)
}
// The resolver holds the query until it is released below, so a waf
// that waited for the query would answer only once it had failed.
queries, failures := server.DNSBL.Queries(
dnsblZone,
), server.DNSBL.Failures(
dnsblZone,
)
if queries != 1 || failures != 0 {
t.Errorf(
"%d queries to the DNS blocklist, %d failed, want 1, none failed",
queries,
failures,
)
}
if checked := server.AbuseIPDB.Checked(); checked != 1 {
t.Errorf("%d checks with AbuseIPDB, want 1", checked)
}
// The query is answered, and the check with AbuseIPDB abandoned,
// before the bubble ends.
releaseResolver()
time.Sleep(defaultReputationWait)
})
}
// How the stand-in for the resolver reads a query and makes it its reply.
const (
// maxQueryLength is the longest query it reads.
maxQueryLength = 512
// flagsAt is where a DNS message's flags start, after its id.
flagsAt = 2
// noSuchNameFlags mark a reply, to a query that asked for recursion,
// which is available, that no such name exists.
noSuchNameFlags = 0x8183
)
// startHeldResolver starts a stand-in for the resolver the DNSBL zones are
// asked through, and returns its address and release. Each query waits until
// release is called, and is then answered that no such name exists, so that
// the zone does not list the client.
func startHeldResolver(t *testing.T) (string, func()) {
t.Helper()
conn, err := (&net.ListenConfig{}).ListenPacket(
t.Context(),
"udp",
localhost+":0",
)
if err != nil {
t.Fatalf("listen: %v", err)
}
t.Cleanup(func() { _ = conn.Close() })
var (
released = make(chan struct{})
once sync.Once
)
go func() {
query := make([]byte, maxQueryLength)
for {
n, from, err := conn.ReadFrom(query)
if err != nil {
return // conn is closed
}
<-released
// The query back, with the flags of the reply in place of its own.
reply := slices.Clone(query[:n])
binary.BigEndian.PutUint16(reply[flagsAt:], noSuchNameFlags)
_, _ = conn.WriteTo(reply, from)
}
}()
release := func() { once.Do(func() { close(released) }) }
// Run before conn is closed, so that no query is left waiting.
t.Cleanup(release)
return conn.LocalAddr().String(), release
}