check / check (push) Waiting to run
A request is neither counted nor refused by the request rate limits when the path the app will act on starts with one of the comma-separated prefixes in SWWAF_RATE_LIMIT_EXEMPT_PATHS. That path is the request's path percent-decoded, with its . and .. segments and repeated slashes resolved, so /assets/..%2Flogin is /login and is counted. The static lists, bans and the country lists still apply. The setting is empty by default, and a prefix that does not start with / stops the start. README.md documents it. Model: opus-5-5
146 lines
4.6 KiB
Go
146 lines
4.6 KiB
Go
package proxy_test
|
|
|
|
import (
|
|
"net/http"
|
|
"sync/atomic"
|
|
"testing"
|
|
|
|
"sneak.berlin/go/smallwebwaf/internal/lookup"
|
|
"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.requestLines(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 TestRateLimitExemptPathsAreNeitherCountedNorRefused(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const denied = "192.0.2.50" // in SWWAF_DENY_NETS
|
|
|
|
s, _, server := startWithClock(t, "", 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.
|
|
// The app acts on /static/../assets/app.js as /assets/app.js.
|
|
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)
|
|
s.request(client, "/static/../assets/app.js",
|
|
http.StatusOK, requestlog.ActionForward)
|
|
|
|
line := s.request(client, "/assets/app.js", http.StatusOK, requestlog.ActionForward)
|
|
if line.LimitHit != "" {
|
|
t.Errorf("log line has limit_hit %q, want none", line.LimitHit)
|
|
}
|
|
|
|
// 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 TestRateLimitCountsPathsOutsideEveryExemptPrefix(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// A prefix matches only at the start of the path, and the app acts on
|
|
// the last three paths as /login, once they are percent-decoded and
|
|
// their .. segments resolved.
|
|
for _, sent := range []string{
|
|
"/static/assets/app.js",
|
|
"/assets/../login",
|
|
"/assets/%2e%2e/login",
|
|
"/assets/..%2Flogin",
|
|
} {
|
|
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)
|
|
})
|
|
}
|
|
}
|