check / check (push) Waiting to run
Each check servewaf runs on a request is a type in its own file behind one interface, heuristic, and is registered in one of four ordered lists in heuristics.go. Order and behaviour are unchanged. Every refusal now logs heuristic, the name of the heuristic that made it, and heuristic_figures, the figures it acted on. A ban by refusal_limit or byte_limits after the answer logs after_answer_heuristic and after_answer_heuristic_figures instead, so a line can name both. All four are in docs/request-log.md, and SPEC.md names each heuristic at its step. Model: opus-5-5
218 lines
7.0 KiB
Go
218 lines
7.0 KiB
Go
package servewaf_test
|
|
|
|
import (
|
|
"net/http"
|
|
"sync/atomic"
|
|
"testing"
|
|
|
|
"sneak.berlin/go/smallwebwaf/internal/lookup"
|
|
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
|
)
|
|
|
|
// minute is the window of SWWAF_RATE_LIMIT_PER_MINUTE, as a log line's
|
|
// limit_hit names it.
|
|
const minute = "minute"
|
|
|
|
func TestRateLimitRefusesBeforeTheApp(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
var calls atomic.Int32
|
|
|
|
app := startApp(t, func(http.ResponseWriter, *http.Request) {
|
|
calls.Add(1)
|
|
})
|
|
addr, out := startProxy(t, app.URL, map[string]string{
|
|
trustedProxies: trustLocalhost,
|
|
rateLimitPerMinute: "1",
|
|
})
|
|
|
|
const otherClient = "203.0.113.10"
|
|
|
|
// With a limit of one request a minute, a client's second request is
|
|
// refused, with 403 by default. A client is one IPv4 address, or one
|
|
// IPv6 /64; an IPv4 address in IPv6 form is that IPv4 address.
|
|
requests := []struct {
|
|
client string // as X-Forwarded-For names it
|
|
logged string // as the log line's client_ip names it
|
|
want int
|
|
}{
|
|
{client, client, http.StatusOK},
|
|
{client, client, http.StatusForbidden},
|
|
{otherClient, otherClient, http.StatusOK},
|
|
{"::ffff:" + otherClient, otherClient, http.StatusForbidden},
|
|
{"2001:db8::1", "2001:db8::1", http.StatusOK},
|
|
{"2001:db8::8000:0:0:1", "2001:db8::8000:0:0:1", http.StatusForbidden},
|
|
{"2001:db8:0:1::1", "2001:db8:0:1::1", http.StatusOK},
|
|
}
|
|
|
|
for i, sent := range requests {
|
|
req := newRequest(t, http.MethodGet, addr, "/", http.NoBody)
|
|
req.Header.Set(forwardedFor, sent.client)
|
|
wantStatus(t, do(t, req), sent.want)
|
|
|
|
line := out.endedLines(t, i+1)[i]
|
|
if line.ClientIP != sent.logged {
|
|
t.Errorf("log line has client_ip %q, want %q", line.ClientIP, sent.logged)
|
|
}
|
|
|
|
if sent.want == http.StatusOK {
|
|
wantLine(t, line, http.StatusOK, requestlog.ActionForward)
|
|
} else {
|
|
wantLine(t, line, http.StatusForbidden, requestlog.ActionRateLimited)
|
|
|
|
if line.LimitHit != minute {
|
|
t.Errorf("log line has limit_hit %q, want minute", line.LimitHit)
|
|
}
|
|
}
|
|
}
|
|
|
|
if calls.Load() != 4 {
|
|
t.Errorf("the app was called %d times, want 4", calls.Load())
|
|
}
|
|
}
|
|
|
|
func TestRateLimitRefusalNamesItsHeuristicWindowCountAndLimit(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
s, _, _ := startWithClock(t, "", map[string]string{rateLimitPerMinute: "1"})
|
|
|
|
s.get(client, http.StatusOK, requestlog.ActionForward)
|
|
|
|
line := s.get(client, http.StatusForbidden, requestlog.ActionRateLimited)
|
|
wantHeuristic(t, line, "rate_limits", `{"window": "minute", "count": 2, "limit": 1}`)
|
|
}
|
|
|
|
func TestIPv6GroupPrefixSetsTheClientTheLimitsCount(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// With SWWAF_IPV6_GROUP_PREFIX at 48, the first two addresses, in two
|
|
// /64s of one /48, are one client, and the second's request breaks the
|
|
// limit; the third, in the next /48, is another client.
|
|
const (
|
|
first = "2001:db8:9::1"
|
|
second = "2001:db8:9:1::1"
|
|
other = "2001:db8:a::1"
|
|
)
|
|
|
|
for _, tc := range []struct {
|
|
setting, value string
|
|
// status and action are those of the request that breaks the
|
|
// limit: a rate limit refuses it, a byte limit passes it on.
|
|
status int
|
|
action string
|
|
}{
|
|
{rateLimitPerMinute, "1", http.StatusForbidden, requestlog.ActionRateLimited},
|
|
{bytesLimitPerMinute, byteLimit, http.StatusOK, requestlog.ActionForward},
|
|
} {
|
|
t.Run(tc.setting, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
s, _ := startWithAnswers(t, map[string]string{
|
|
ipv6GroupPrefix: "48", tc.setting: tc.value,
|
|
})
|
|
|
|
s.get(first, http.StatusOK, requestlog.ActionForward)
|
|
|
|
line := s.get(second, tc.status, tc.action)
|
|
if line.ClientGroup != "2001:db8:9::/48" ||
|
|
line.Offence != requestlog.OffenceLimit {
|
|
t.Errorf("log line has client_group %q and offence %q, "+
|
|
"want 2001:db8:9::/48 and limit", line.ClientGroup, line.Offence)
|
|
}
|
|
|
|
s.get(other, http.StatusOK, requestlog.ActionForward)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRateLimitExemptPathsAreNeitherCountedNorRefused(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const denied = "192.0.2.50" // in SWWAF_DENY_NETS
|
|
|
|
geojsURL, _ := startGeoJS(t)
|
|
s, _, server := startWithClock(t, geojsURL, map[string]string{
|
|
rateLimitPerMinute: "1",
|
|
rateLimitExemptPaths: "/assets/,/favicon.ico",
|
|
denyNets: denied,
|
|
deniedCountries: "kp",
|
|
})
|
|
|
|
// The answers are kept before the requests, so that none waits for
|
|
// GeoJS.
|
|
server.GeoJS.Load([]lookup.Answer{
|
|
keptAnswer(client, "DE"), keptAnswer(fromKP, "KP"),
|
|
})
|
|
|
|
// With a limit of one request a minute, the requests for paths under a
|
|
// prefix are not counted, so client's first request for / is within
|
|
// the limit; and once client has reached it, they are not refused.
|
|
s.request(client, "/assets/app.js", http.StatusOK, requestlog.ActionForward)
|
|
s.request(client, "/favicon.ico?v=2", http.StatusOK, requestlog.ActionForward)
|
|
s.get(client, http.StatusOK, requestlog.ActionForward)
|
|
|
|
line := s.request(client, "/assets/app.js", http.StatusOK, requestlog.ActionForward)
|
|
if line.LimitHit != "" || line.Counts != (ratelimit.Counts{}) {
|
|
t.Errorf("log line has limit_hit %q and counts %+v, want neither",
|
|
line.LimitHit, line.Counts)
|
|
}
|
|
|
|
// A path outside every prefix is counted: /assets is not under
|
|
// /assets/, and breaks the limit.
|
|
s.request(client, "/assets", http.StatusForbidden, requestlog.ActionRateLimited)
|
|
|
|
// A ban, SWWAF_DENY_NETS and the country lists still refuse a path
|
|
// under a prefix.
|
|
s.request(client, "/assets/app.js", http.StatusForbidden, requestlog.ActionBanned)
|
|
s.request(denied, "/assets/app.js", http.StatusForbidden, requestlog.ActionDenied)
|
|
s.request(fromKP, "/assets/app.js",
|
|
http.StatusForbidden, requestlog.ActionCountryDenied)
|
|
}
|
|
|
|
func TestRateLimitCountsPathsThatAreNotExempt(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
for _, sent := range []string{
|
|
// A prefix matches only at the start of the path.
|
|
"/static/assets/app.js",
|
|
// A prefix matches the path as sent: a router that matches the
|
|
// path as received does not take /%61ssets/x for a path under
|
|
// /assets/.
|
|
"/%61ssets/x",
|
|
// .. once percent-decoded: an app may act on these as /login, the
|
|
// last as a path under /sneak/app/ or as /assets/x.
|
|
"/assets/../login",
|
|
"/assets/%2e%2e/login",
|
|
"/assets/..%2Flogin",
|
|
"/assets/..;/login",
|
|
"/sneak/app/src/branch/main/..%2F..%2F..%2F..%2F..%2F..%2Fassets/x",
|
|
// Not under /assets/ as sent: Go's router takes /assets%2Fx for one
|
|
// path segment, not a path under /assets/.
|
|
"/assets%2Fx",
|
|
"/assets%2fx",
|
|
// Under /assets/ as sent, but holding an encoded slash, in either
|
|
// case, or a backslash: never exempt, whatever the prefix.
|
|
"/assets/x%2Fy",
|
|
"/assets/x%2fy",
|
|
`/assets/x\y`,
|
|
// The same with a character Go escapes, |, for which Go's
|
|
// URL.EscapedPath gives /assets/x/y%7Cz, with no encoded slash.
|
|
"/assets/x%2Fy|z",
|
|
} {
|
|
t.Run(sent, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
s, _, _ := startWithClock(t, "", map[string]string{
|
|
rateLimitPerMinute: "1",
|
|
rateLimitExemptPaths: "/assets/",
|
|
})
|
|
|
|
// Counted, the second request breaks the limit of one request
|
|
// a minute.
|
|
s.request(client, sent, http.StatusOK, requestlog.ActionForward)
|
|
s.request(client, sent, http.StatusForbidden, requestlog.ActionRateLimited)
|
|
})
|
|
}
|
|
}
|