Leave SWWAF_RATE_LIMIT_EXEMPT_PATHS out of the request rate limits (closes #77)
check / check (push) Successful in 3m35s
check / check (push) Successful in 3m35s
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
This commit is contained in:
@@ -59,6 +59,7 @@ const (
|
||||
denyNets = "SWWAF_DENY_NETS"
|
||||
rateLimitPerMinute = "SWWAF_RATE_LIMIT_PER_MINUTE"
|
||||
rateLimitPerDay = "SWWAF_RATE_LIMIT_PER_DAY"
|
||||
rateLimitExemptPaths = "SWWAF_RATE_LIMIT_EXEMPT_PATHS"
|
||||
deniedCountries = "SWWAF_DENIED_COUNTRIES"
|
||||
allowedCountries = "SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES"
|
||||
banResponse = "SWWAF_BAN_RESPONSE"
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/lookup"
|
||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||
)
|
||||
|
||||
@@ -69,3 +70,76 @@ func TestRateLimitRefusesBeforeTheApp(t *testing.T) {
|
||||
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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,9 @@ import (
|
||||
"net/http/httputil"
|
||||
"net/netip"
|
||||
"os"
|
||||
"path"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
@@ -145,8 +148,9 @@ func (rq *request) check(ctx context.Context) *refusal {
|
||||
// client either refuses is not looked up, and then the country lists; a
|
||||
// request any of them refuses is not counted for the rate limits. Then
|
||||
// come the rate limits, unless the client is in
|
||||
// SWWAF_RATE_LIMIT_EXEMPT_NETS, so that every other request is counted.
|
||||
// ctx is the request's own context.
|
||||
// SWWAF_RATE_LIMIT_EXEMPT_NETS or the path the app will act on starts
|
||||
// with one of SWWAF_RATE_LIMIT_EXEMPT_PATHS, so that every other request
|
||||
// is counted. ctx is the request's own context.
|
||||
func (rq *request) checkClient(ctx context.Context) string {
|
||||
cfg := rq.h.config
|
||||
if isInside(rq.client, cfg.AllowNets) {
|
||||
@@ -167,13 +171,27 @@ func (rq *request) checkClient(ctx context.Context) string {
|
||||
return requestlog.ActionCountryDenied
|
||||
}
|
||||
|
||||
if !isInside(rq.client, cfg.RateLimitExemptNets) && rq.limitBroken(now) {
|
||||
// The prefixes are matched against the path the app will act on:
|
||||
// URL.Path is the request's path percent-decoded, and path.Clean
|
||||
// resolves its . and .. segments and repeated slashes, and drops a
|
||||
// trailing slash. So /assets/..%2Flogin is /login, outside /assets/,
|
||||
// whatever the log line's path shows.
|
||||
exempt := isInside(rq.client, cfg.RateLimitExemptNets) ||
|
||||
startsWithAny(path.Clean(rq.in.URL.Path), cfg.RateLimitExemptPaths)
|
||||
if !exempt && rq.limitBroken(now) {
|
||||
return requestlog.ActionRateLimited
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// startsWithAny reports whether s starts with one of prefixes.
|
||||
func startsWithAny(s string, prefixes []string) bool {
|
||||
return slices.ContainsFunc(prefixes, func(prefix string) bool {
|
||||
return strings.HasPrefix(s, prefix)
|
||||
})
|
||||
}
|
||||
|
||||
// forward passes the request to the app and the app's answer back. ctx
|
||||
// is the request's own context.
|
||||
func (rq *request) forward(ctx context.Context) {
|
||||
|
||||
Reference in New Issue
Block a user