Leave SWWAF_RATE_LIMIT_EXEMPT_PATHS out of the request rate limits (closes #77)
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:
2026-10-06 11:22:36 +00:00
parent cff385af41
commit 890dcedfd7
7 changed files with 194 additions and 27 deletions
+21 -3
View File
@@ -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) {