Files
smallwebwaf/internal/proxy/answertime_test.go
T
sneak 5a278949d7
check / check (push) Failing after 15m18s
servewaf always answers rproxy well within its time limit (closes #182)
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
2026-11-05 04:11:56 +00:00

601 lines
19 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 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)
}
}