check / check (push) Successful in 4m11s
A request over a rate limit is refused with SWWAF_BAN_RESPONSE and bans the client's netblock: an hour at first, three times the last ban when broken again within a day of its end, permanent past seven days. The ban ledger in internal/bans is checked after the static lists and before the lookup, and the requests it refuses are not counted. A ban resets the client's counters and carries notes holding the request that broke the limit, as SPEC.md now says. At most SWWAF_MAX_BANS are held. SWWAF_BAN_RESPONSE also answers SWWAF_DENY_NETS and the country lists. Judgement call: the six ban settings cannot be off. Judgement call: a permanent ban's ban_expires is "permanent". Model: opus-5-5
185 lines
5.8 KiB
Go
185 lines
5.8 KiB
Go
package proxy_test
|
|
|
|
import (
|
|
"net/http"
|
|
"strings"
|
|
"sync/atomic"
|
|
"testing"
|
|
|
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
|
)
|
|
|
|
// The rate limits count an IPv6 client by its /64, so these two addresses
|
|
// are one client for them. The static lists match each address on its own,
|
|
// and the tests list listedAddr alone.
|
|
const (
|
|
listedAddr = "2001:db8::1"
|
|
unlistedAddr = "2001:db8::2"
|
|
)
|
|
|
|
func TestAllowNetsSkipEveryCheckButTheSizeLimit(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
var calls atomic.Int32
|
|
|
|
app := startApp(t, func(http.ResponseWriter, *http.Request) {
|
|
calls.Add(1)
|
|
})
|
|
geojsURL, asked := startGeoJS(t)
|
|
// fromKP is in SWWAF_ALLOW_NETS, and in SWWAF_DENY_NETS too, which
|
|
// comes after it.
|
|
addr, out := startProxyWithGeoJS(t, app.URL, geojsURL, map[string]string{
|
|
trustedProxies: trustLocalhost,
|
|
allowNets: "198.51.100.0/24",
|
|
denyNets: fromKP,
|
|
deniedCountries: "kp",
|
|
rateLimitPerMinute: "1",
|
|
requestMaxBytes: "1K",
|
|
})
|
|
|
|
// Neither SWWAF_DENY_NETS, the country lists nor the limit of one
|
|
// request a minute refuses the client, and its country is not looked
|
|
// up.
|
|
wantAnswers(t, addr, out, []sentRequest{
|
|
{fromKP, http.StatusOK, requestlog.ActionForward},
|
|
{fromKP, http.StatusOK, requestlog.ActionForward},
|
|
})
|
|
|
|
if len(asked()) != 0 {
|
|
t.Errorf("GeoJS was asked about %v, want nothing", asked())
|
|
}
|
|
|
|
// The size limit still applies.
|
|
body := strings.NewReader(strings.Repeat("a", 2<<10))
|
|
req := newRequest(t, http.MethodPost, addr, "/", body)
|
|
req.Header.Set(forwardedFor, fromKP)
|
|
wantStatus(t, do(t, req), http.StatusRequestEntityTooLarge)
|
|
wantLine(t, out.requestLines(t, 3)[2],
|
|
http.StatusRequestEntityTooLarge, requestlog.ActionTooLarge)
|
|
|
|
if calls.Load() != 2 {
|
|
t.Errorf("the app was called %d times, want 2", calls.Load())
|
|
}
|
|
}
|
|
|
|
func TestRequestFromAllowNetsIsNotCounted(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
app := startApp(t, func(http.ResponseWriter, *http.Request) {})
|
|
addr, out := startProxy(t, app.URL, map[string]string{
|
|
trustedProxies: trustLocalhost,
|
|
allowNets: listedAddr,
|
|
rateLimitPerMinute: "1",
|
|
})
|
|
|
|
// listedAddr's requests are not counted, so the first request from
|
|
// unlistedAddr is within the limit of one a minute.
|
|
wantAnswers(t, addr, out, []sentRequest{
|
|
{listedAddr, http.StatusOK, requestlog.ActionForward},
|
|
{listedAddr, http.StatusOK, requestlog.ActionForward},
|
|
{unlistedAddr, http.StatusOK, requestlog.ActionForward},
|
|
{unlistedAddr, http.StatusForbidden, requestlog.ActionRateLimited},
|
|
})
|
|
}
|
|
|
|
func TestDenyNetsRefuseBeforeTheLookupAndTheBody(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
var calls atomic.Int32
|
|
|
|
app := startApp(t, func(http.ResponseWriter, *http.Request) {
|
|
calls.Add(1)
|
|
})
|
|
geojsURL, asked := startGeoJS(t)
|
|
addr, out := startProxyWithGeoJS(t, app.URL, geojsURL, map[string]string{
|
|
trustedProxies: trustLocalhost,
|
|
denyNets: "203.0.113.0/24",
|
|
deniedCountries: "kp",
|
|
})
|
|
|
|
req := newRequest(t, http.MethodPost, addr, "/", strings.NewReader("a body"))
|
|
req.Header.Set(forwardedFor, fromDE)
|
|
wantStatus(t, do(t, req), http.StatusForbidden)
|
|
|
|
line := out.requestLine(t)
|
|
wantLine(t, line, http.StatusForbidden, requestlog.ActionDenied)
|
|
|
|
if line.RequestBytes != 0 {
|
|
t.Errorf("log line has request_bytes %d, want 0", line.RequestBytes)
|
|
}
|
|
|
|
if len(asked()) != 0 {
|
|
t.Errorf("GeoJS was asked about %v, want nothing", asked())
|
|
}
|
|
|
|
if calls.Load() != 0 {
|
|
t.Errorf("the app was called %d times, want none", calls.Load())
|
|
}
|
|
}
|
|
|
|
func TestRequestRefusedByDenyNetsIsNotCounted(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
app := startApp(t, func(http.ResponseWriter, *http.Request) {})
|
|
addr, out := startProxy(t, app.URL, map[string]string{
|
|
trustedProxies: trustLocalhost,
|
|
denyNets: listedAddr,
|
|
rateLimitPerMinute: "1",
|
|
})
|
|
|
|
// listedAddr's refused requests are not counted, so the first request
|
|
// from unlistedAddr is within the limit of one a minute.
|
|
wantAnswers(t, addr, out, []sentRequest{
|
|
{listedAddr, http.StatusForbidden, requestlog.ActionDenied},
|
|
{listedAddr, http.StatusForbidden, requestlog.ActionDenied},
|
|
{unlistedAddr, http.StatusOK, requestlog.ActionForward},
|
|
{unlistedAddr, http.StatusForbidden, requestlog.ActionRateLimited},
|
|
})
|
|
}
|
|
|
|
func TestRateLimitExemptNetsAreNeitherCountedNorRefused(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
app := startApp(t, func(http.ResponseWriter, *http.Request) {})
|
|
geojsURL, _ := startGeoJS(t)
|
|
addr, out := startProxyWithGeoJS(t, app.URL, geojsURL, map[string]string{
|
|
trustedProxies: trustLocalhost,
|
|
rateLimitExemptNets: listedAddr + "," + fromKP,
|
|
deniedCountries: "kp",
|
|
rateLimitPerMinute: "1",
|
|
})
|
|
|
|
// listedAddr's requests are neither refused nor counted, so the first
|
|
// request from unlistedAddr is within the limit of one a minute. The
|
|
// country lists still refuse an exempt client.
|
|
wantAnswers(t, addr, out, []sentRequest{
|
|
{listedAddr, http.StatusOK, requestlog.ActionForward},
|
|
{listedAddr, http.StatusOK, requestlog.ActionForward},
|
|
{unlistedAddr, http.StatusOK, requestlog.ActionForward},
|
|
{unlistedAddr, http.StatusForbidden, requestlog.ActionRateLimited},
|
|
{fromKP, http.StatusForbidden, requestlog.ActionCountryDenied},
|
|
})
|
|
}
|
|
|
|
// sentRequest is a GET request from client, as X-Forwarded-For names it,
|
|
// and the status and log line action it should get.
|
|
type sentRequest struct {
|
|
client string
|
|
status int
|
|
action string
|
|
}
|
|
|
|
// wantAnswers sends requests to smallwebwaf at addr one after another and
|
|
// checks each one's answer and log line. They must be the first requests
|
|
// smallwebwaf is sent, since the log lines are matched to them in order.
|
|
func wantAnswers(t *testing.T, addr string, out *output, requests []sentRequest) {
|
|
t.Helper()
|
|
|
|
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.status)
|
|
wantLine(t, out.requestLines(t, i+1)[i], sent.status, sent.action)
|
|
}
|
|
}
|