Answer-time tests check the answer, not wall-clock milliseconds (closes #316)
check / check (push) Waiting to run
check / check (push) Waiting to run
Tests only. `TestWAFAnswersWithinTheTimeLimitWhileEveryOutsideSourceHangs` and `TestHangingRoutewatchNeverDelaysAndFailsTheQuery` run in a `synctest` bubble, whose clock load cannot move, instead of comparing wall-clock durations with fixed bounds. The first checks the answer comes after exactly `SWW_LOOKUP_TIMEOUT`, and that the DNS blocklist's query, held by a stand-in resolver, had not failed when it came. The second requires `no_answer_yet` after exactly zero time. The other wall-clock checks in the tests were already in bubbles or are bounds load cannot break. - The held DNS query still times out on the real clock after two seconds; a stall that long between question and check fails the test. Model: opus-5-5
This commit was merged in pull request #343.
This commit is contained in:
1 parent
1748a670f9
commit
b93bad5842
2 files changed
+153
-46
No files matched your search
+108
-33
@@ -1,6 +1,7 @@
|
||||
package waf_test
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"maps"
|
||||
"net"
|
||||
@@ -11,10 +12,13 @@ import (
|
||||
"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"
|
||||
@@ -30,6 +34,8 @@ const (
|
||||
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
|
||||
@@ -628,15 +634,9 @@ func TestWAFAnswersWithinTheTimeLimitWhileEveryOutsideSourceHangs(
|
||||
) {
|
||||
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() })
|
||||
// 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,
|
||||
@@ -644,42 +644,117 @@ func TestWAFAnswersWithinTheTimeLimitWhileEveryOutsideSourceHangs(
|
||||
params.AbuseIPDBURL = silentAbuseIPDBURL
|
||||
|
||||
return waf.New(params)
|
||||
}, held, time.Now, map[string]string{
|
||||
}, silentGeoJSURL, time.Now, map[string]string{
|
||||
tagDeny: "country=KP", wafMode: block,
|
||||
dnsblZones: dnsblZone, dnsblResolver: resolver.LocalAddr().String(),
|
||||
dnsblZones: dnsblZone, dnsblResolver: resolver,
|
||||
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})
|
||||
|
||||
// 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())
|
||||
|
||||
status, took := askDirectly(t, server, func(req *http.Request) {
|
||||
req.Header.Set("User-Agent", "test")
|
||||
req.Header.Set("Accept", "*/*")
|
||||
req.Host = siteA
|
||||
// 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)
|
||||
})
|
||||
}
|
||||
|
||||
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())
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
<-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)
|
||||
}
|
||||
}()
|
||||
|
||||
if queries := server.DNSBL.Queries(dnsblZone); queries != 1 {
|
||||
t.Errorf("%d queries to the DNS blocklist, want 1", queries)
|
||||
}
|
||||
release := func() { once.Do(func() { close(released) }) }
|
||||
// Run before conn is closed, so that no query is left waiting.
|
||||
t.Cleanup(release)
|
||||
|
||||
if checked := server.AbuseIPDB.Checked(); checked != 1 {
|
||||
t.Errorf("%d checks with AbuseIPDB, want 1", checked)
|
||||
}
|
||||
return conn.LocalAddr().String(), release
|
||||
}
|
||||
Reference in new issue
Block a user