Per-client request rate limits over a minute, an hour and a day (closes #43)
check / check (push) Successful in 2m27s

Each client, one IPv4 address or one IPv6 /64, is counted in two buckets
per window, the earlier weighted by how much of it the window covers; at
most 20,000 clients are kept, least recently seen dropped first. A
request over SWWAF_RATE_LIMIT_PER_MINUTE, _HOUR or _DAY (1000, 10000,
50000, or off) gets 429 before reaching the app. Refused requests count,
413s included. A clock set back over a second behind a bucket's start
restarts that window. The log line gains limit_hit and the action
rate_limited.

Deviation from SPEC.md, per the issue: the 20,000 bound and /64 are fixed.
Judgement call: golang-lru/v2 holds the table; httprate does not count refused requests.
Deviation: go.mod and go.sum hand-written; no make target tidies them.

Model: opus-5-5
This commit is contained in:
2026-10-04 01:42:52 +00:00
committed by sneak
parent bedd324f3c
commit 808245e7d5
15 changed files with 557 additions and 28 deletions
+42 -2
View File
@@ -16,7 +16,8 @@ import (
"time"
)
// Config is smallwebwaf's settings. A timeout or size of zero is off.
// Config is smallwebwaf's settings. A timeout, size or rate limit of zero
// is off.
type Config struct {
// ListenAddr is where smallwebwaf listens (SWWAF_LISTEN_ADDR).
ListenAddr string
@@ -43,13 +44,21 @@ type Config struct {
// ResponseMaxBytes is the largest response body
// (SWWAF_RESPONSE_MAX_BYTES).
ResponseMaxBytes int64
// RateLimitPerMinute, RateLimitPerHour and RateLimitPerDay are the
// most requests a client may make in a minute, an hour and a day
// (SWWAF_RATE_LIMIT_PER_MINUTE, SWWAF_RATE_LIMIT_PER_HOUR and
// SWWAF_RATE_LIMIT_PER_DAY).
RateLimitPerMinute int64
RateLimitPerHour int64
RateLimitPerDay int64
// settings are the values read, as given or by default, for the
// log line at start.
settings []slog.Attr
}
// off is the value that switches a timeout or a size limit off.
// off is the value that switches a timeout, a size limit or a rate limit
// off.
const off = "off"
const (
@@ -64,6 +73,8 @@ var (
"is not a duration such as 90s, 15m or 7d, or off")
errNotSize = errors.New(
"is not a size such as 512K, 100M or 5G, or off")
errNotCount = errors.New(
"is not a whole number of requests such as 1000, or off")
errNotPositive = errors.New("must be more than zero, or off")
errEmptyItem = errors.New("has an empty item in its list")
errNotNetblock = errors.New(
@@ -90,6 +101,9 @@ func FromEnvironment(lookupEnv func(string) (string, bool)) (*Config, error) {
UpstreamResponseTimeout: env.duration("SWWAF_UPSTREAM_RESPONSE_TIMEOUT", "30m"),
RequestMaxBytes: env.size("SWWAF_REQUEST_MAX_BYTES", "100M"),
ResponseMaxBytes: env.size("SWWAF_RESPONSE_MAX_BYTES", "5G"),
RateLimitPerMinute: env.count("SWWAF_RATE_LIMIT_PER_MINUTE", "1000"),
RateLimitPerHour: env.count("SWWAF_RATE_LIMIT_PER_HOUR", "10000"),
RateLimitPerDay: env.count("SWWAF_RATE_LIMIT_PER_DAY", "50000"),
}
if env.err != nil {
@@ -179,6 +193,14 @@ func (e *environment) size(name, defaultValue string) int64 {
return size
}
// count reads a setting that is a number of requests.
func (e *environment) count(name, defaultValue string) int64 {
count, err := parseCount(e.value(name, defaultValue))
e.check(name, err)
return count
}
// parseDuration reads a duration in Go's syntax, such as 90s or 15m, a
// whole number of days such as 7d, or off.
func parseDuration(value string) (time.Duration, error) {
@@ -249,6 +271,24 @@ func splitUnit(value string) (string, int64) {
}
}
// parseCount reads a whole number of requests, or off.
func parseCount(value string) (int64, error) {
if value == off {
return 0, nil
}
n, err := strconv.ParseInt(value, 10, 64)
if err != nil {
return 0, fmt.Errorf("%q %w", value, errNotCount)
}
if n <= 0 {
return 0, fmt.Errorf("%q %w", value, errNotPositive)
}
return n, nil
}
// parseList splits a comma-separated list and trims the spaces around
// each item. An empty value is an empty list.
func parseList(value string) ([]string, error) {
+42 -2
View File
@@ -25,9 +25,12 @@ const (
upstreamResponseTimeout = "SWWAF_UPSTREAM_RESPONSE_TIMEOUT"
requestMaxBytes = "SWWAF_REQUEST_MAX_BYTES"
responseMaxBytes = "SWWAF_RESPONSE_MAX_BYTES"
rateLimitPerMinute = "SWWAF_RATE_LIMIT_PER_MINUTE"
rateLimitPerHour = "SWWAF_RATE_LIMIT_PER_HOUR"
rateLimitPerDay = "SWWAF_RATE_LIMIT_PER_DAY"
)
// off switches a timeout or a size limit off.
// off switches a timeout, a size limit or a rate limit off.
const off = "off"
// environment is a set of environment variables, for FromEnvironment.
@@ -65,6 +68,9 @@ func TestDefaults(t *testing.T) {
UpstreamResponseTimeout: 30 * time.Minute,
RequestMaxBytes: 100 << 20,
ResponseMaxBytes: 5 << 30,
RateLimitPerMinute: 1000,
RateLimitPerHour: 10000,
RateLimitPerDay: 50000,
})
if cfg.UpstreamURL.String() != "http://127.0.0.1:8081" {
@@ -88,6 +94,9 @@ func TestValuesAsSet(t *testing.T) {
upstreamResponseTimeout: off,
requestMaxBytes: "512K",
responseMaxBytes: "1234",
rateLimitPerMinute: "60",
rateLimitPerHour: "600",
rateLimitPerDay: "6000",
})
wantSettings(t, cfg, config.Config{
@@ -98,6 +107,9 @@ func TestValuesAsSet(t *testing.T) {
UpstreamResponseTimeout: 0,
RequestMaxBytes: 512 << 10,
ResponseMaxBytes: 1234,
RateLimitPerMinute: 60,
RateLimitPerHour: 600,
RateLimitPerDay: 6000,
})
if cfg.UpstreamURL.String() != "https://app.internal:8443/" {
@@ -123,6 +135,22 @@ func TestSizesAndOff(t *testing.T) {
}
}
func TestRateLimitsOff(t *testing.T) {
t.Parallel()
cfg := fromEnvironment(t, environment{
rateLimitPerMinute: off,
rateLimitPerHour: off,
rateLimitPerDay: off,
})
if cfg.RateLimitPerMinute != 0 || cfg.RateLimitPerHour != 0 ||
cfg.RateLimitPerDay != 0 {
t.Errorf("off read as %d, %d and %d",
cfg.RateLimitPerMinute, cfg.RateLimitPerHour, cfg.RateLimitPerDay)
}
}
func TestTrustedProxiesSetButEmptyTrustNothing(t *testing.T) {
t.Parallel()
@@ -164,6 +192,12 @@ func TestInvalidValueStopsTheStart(t *testing.T) {
{responseMaxBytes, "0"},
{responseMaxBytes, "-5"},
{responseMaxBytes, "99999999999G"},
{rateLimitPerMinute, ""},
{rateLimitPerMinute, "1K"},
{rateLimitPerHour, "0"},
{rateLimitPerHour, "1.5"},
{rateLimitPerDay, "-1"},
{rateLimitPerDay, "lots"},
} {
t.Run(tc.name+"="+tc.value, func(t *testing.T) {
t.Parallel()
@@ -208,6 +242,9 @@ func TestLogsEachSettingWithItsValue(t *testing.T) {
upstreamResponseTimeout: "30m",
requestMaxBytes: "100M",
responseMaxBytes: "5G",
rateLimitPerMinute: "1000",
rateLimitPerHour: "10000",
rateLimitPerDay: "50000",
}
if !maps.Equal(line.Settings, want) {
t.Errorf("logged settings\n%v\nwant\n%v", line.Settings, want)
@@ -224,7 +261,10 @@ func wantSettings(t *testing.T, got *config.Config, want config.Config) {
got.UpstreamRequestTimeout != want.UpstreamRequestTimeout ||
got.UpstreamResponseTimeout != want.UpstreamResponseTimeout ||
got.RequestMaxBytes != want.RequestMaxBytes ||
got.ResponseMaxBytes != want.ResponseMaxBytes {
got.ResponseMaxBytes != want.ResponseMaxBytes ||
got.RateLimitPerMinute != want.RateLimitPerMinute ||
got.RateLimitPerHour != want.RateLimitPerHour ||
got.RateLimitPerDay != want.RateLimitPerDay {
t.Errorf("settings\n%+v\nwant\n%+v", got, want)
}
}
+14
View File
@@ -48,6 +48,20 @@ func clientAddress(
return client
}
// ipv6GroupPrefix is the length of the IPv6 netblock that is one client.
const ipv6GroupPrefix = 64
// clientGroup is the client a request is counted toward: its IPv4
// address, or the /64 its IPv6 address is in, since one abuser usually
// holds a whole /64.
func clientGroup(addr netip.Addr) netip.Prefix {
if addr.Is6() {
return netip.PrefixFrom(addr, ipv6GroupPrefix).Masked()
}
return netip.PrefixFrom(addr, addr.BitLen())
}
// isInside reports whether addr is in one of the netblocks.
func isInside(addr netip.Addr, netblocks []netip.Prefix) bool {
return slices.ContainsFunc(netblocks, func(netblock netip.Prefix) bool {
+7
View File
@@ -11,6 +11,7 @@ import (
"time"
"sneak.berlin/go/smallwebwaf/internal/config"
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
)
// The request line and headers a client may send, and how long a
@@ -57,6 +58,11 @@ func New(params Params) *http.Server {
processLog: params.ProcessLog,
errorLog: errorLog,
transport: newTransport(),
limiter: ratelimit.New(ratelimit.Limits{
PerMinute: params.Config.RateLimitPerMinute,
PerHour: params.Config.RateLimitPerHour,
PerDay: params.Config.RateLimitPerDay,
}),
},
ReadHeaderTimeout: params.Config.ClientRequestTimeout,
IdleTimeout: clientIdleTimeout,
@@ -73,6 +79,7 @@ type handler struct {
processLog *slog.Logger
errorLog *log.Logger
transport http.RoundTripper
limiter *ratelimit.Limiter
}
// newTransport returns what carries requests to the app. It never goes
+1
View File
@@ -44,6 +44,7 @@ const (
requestMaxBytes = "SWWAF_REQUEST_MAX_BYTES"
responseMaxBytes = "SWWAF_RESPONSE_MAX_BYTES"
trustedProxies = "SWWAF_TRUSTED_PROXIES"
rateLimitPerMinute = "SWWAF_RATE_LIMIT_PER_MINUTE"
)
// output collects what smallwebwaf writes on stdout.
+62
View File
@@ -0,0 +1,62 @@
package proxy_test
import (
"net/http"
"sync/atomic"
"testing"
"sneak.berlin/go/smallwebwaf/internal/requestlog"
)
func TestRateLimitRefusesWith429BeforeTheApp(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",
})
// With a limit of one request a minute, a client's second request is
// refused. A client is one IPv4 address, or one IPv6 /64.
requests := []struct {
client string
want int
}{
{client, http.StatusOK},
{client, http.StatusTooManyRequests},
{"203.0.113.10", http.StatusOK},
{"2001:db8::1", http.StatusOK},
{"2001:db8::8000:0:0:1", http.StatusTooManyRequests},
{"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.client {
t.Errorf("log line has client_ip %q, want %q", line.ClientIP, sent.client)
}
if sent.want == http.StatusOK {
wantLine(t, line, http.StatusOK, requestlog.ActionForward)
} else {
wantLine(t, line, http.StatusTooManyRequests, 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())
}
}
+15 -3
View File
@@ -38,6 +38,7 @@ type request struct {
body *requestBody // nil for a request without a body
line requestlog.Line
client netip.Addr
peer netip.Addr
peerTrusted bool
start time.Time
@@ -75,6 +76,7 @@ func (h *handler) newRequest(w http.ResponseWriter, r *http.Request) *request {
in: r,
rc: http.NewResponseController(w),
out: &responseWriter{ResponseWriter: w},
client: client,
peer: peer,
peerTrusted: isInside(peer, trusted),
start: start,
@@ -100,10 +102,20 @@ func (h *handler) newRequest(w http.ResponseWriter, r *http.Request) *request {
}
// check is the one place where a request can be refused once its client
// is known, before its body is read or anything reaches the app; the rate
// limits and country lists of milestone 2 go here. It returns nil to let
// the request through.
// is known, before its body is read or anything reaches the app. It
// returns nil to let the request through. The rate limits come first, so
// that every request is counted, one refused for its size too.
func (rq *request) check() *refusal {
limitHit := rq.h.limiter.Count(clientGroup(rq.client), rq.start)
if limitHit != "" {
rq.line.LimitHit = limitHit
return &refusal{
status: http.StatusTooManyRequests,
action: requestlog.ActionRateLimited,
}
}
maxBytes := rq.h.config.RequestMaxBytes
if maxBytes > 0 && rq.in.ContentLength > maxBytes {
return &refusal{
+132
View File
@@ -0,0 +1,132 @@
// Package ratelimit counts each client's requests over a minute, an hour
// and a day, as the "Counting method" section of SPEC.md describes, and
// tells when a request takes a client over a rate limit. The counts are
// kept in memory only, for at most 20,000 clients.
package ratelimit
import (
"net/netip"
"sync"
"time"
"github.com/hashicorp/golang-lru/v2/simplelru"
)
// maxClients is how many clients are kept. Past it, the least recently
// seen client is dropped, and starts afresh if it comes back.
const maxClients = 20000
const day = 24 * time.Hour
// Limits are the most requests a client may make in a minute, an hour and
// a day. Zero is no limit.
type Limits struct {
PerMinute int64
PerHour int64
PerDay int64
}
// Limiter counts each client's requests against the limits. It is safe
// for concurrent use.
type Limiter struct {
windows [3]window
mu sync.Mutex
// clients holds each client's buckets, one pair for each of windows,
// in the same order.
clients *simplelru.LRU[netip.Prefix, *[3]buckets]
}
// New returns a Limiter for limits, with no client counted yet.
func New(limits Limits) *Limiter {
clients, err := simplelru.NewLRU[netip.Prefix, *[3]buckets](maxClients, nil)
if err != nil {
panic(err) // NewLRU fails only for a size below one
}
return &Limiter{
windows: [3]window{
{name: "minute", length: time.Minute, limit: limits.PerMinute},
{name: "hour", length: time.Hour, limit: limits.PerHour},
{name: "day", length: day, limit: limits.PerDay},
},
clients: clients,
}
}
// Count counts a request from client at now, in every window, whether or
// not it is refused. It returns the window whose limit the request takes
// the client over, "minute", "hour" or "day", the shortest if it is over
// several, or "" if it is within every limit.
func (l *Limiter) Count(client netip.Prefix, now time.Time) string {
l.mu.Lock()
defer l.mu.Unlock()
counts, seen := l.clients.Get(client)
if !seen {
counts = &[3]buckets{}
l.clients.Add(client, counts)
}
limitHit := ""
for i, w := range l.windows {
requests := counts[i].add(now, w.length)
if limitHit == "" && w.limit > 0 && requests > float64(w.limit) {
limitHit = w.name
}
}
return limitHit
}
// window is a length of time over which requests are counted, and the
// most requests a client may make in it.
type window struct {
name string
length time.Duration
limit int64
}
// buckets are a client's two buckets in one window: the requests in the
// bucket under way, which began at start, and in the bucket before it.
type buckets struct {
start time.Time
current int64
previous int64
}
// add counts a request at now in a window of length, and returns the
// client's requests in the window that ends at now: those in the bucket
// under way, and those in the bucket before it weighted by how much of
// that bucket the window still covers.
//
// Concurrent requests can be counted out of order, so now can be a moment
// before the bucket under way began; such a request is counted in that
// bucket. A request dated more than a second before it means the clock
// was set back, and the buckets start afresh: otherwise the bucket before
// would keep its full weight until the clock caught up.
func (b *buckets) add(now time.Time, length time.Duration) float64 {
if now.Before(b.start.Add(-time.Second)) {
*b = buckets{}
}
start := now.Truncate(length)
if start.After(b.start) {
if start.Equal(b.start.Add(length)) {
b.previous = b.current
} else {
b.previous = 0
}
b.start = start
b.current = 0
}
b.current++
elapsed := max(now.Sub(b.start), 0)
covered := 1 - float64(elapsed)/float64(length)
return float64(b.previous)*covered + float64(b.current)
}
+181
View File
@@ -0,0 +1,181 @@
package ratelimit_test
import (
"net/netip"
"testing"
"time"
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
)
// limit is the limit the tests set.
const limit = 3
// The windows, as Count names them.
const (
minute = "minute"
hour = "hour"
)
func TestEachWindowRefusesAtItsLimitAndLetsTheClientBack(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
window string
limits ratelimit.Limits
length time.Duration
}{
{minute, ratelimit.Limits{PerMinute: limit}, time.Minute},
{hour, ratelimit.Limits{PerHour: limit}, time.Hour},
{"day", ratelimit.Limits{PerDay: limit}, 24 * time.Hour},
} {
t.Run(tc.window, func(t *testing.T) {
t.Parallel()
limiter := ratelimit.New(tc.limits)
client := netip.MustParsePrefix("203.0.113.9/32")
start := midnight()
quarter := tc.length / 4
for range limit {
wantCount(t, limiter, client, start, "")
}
wantCount(t, limiter, client, start, tc.window)
// A quarter into the next bucket, the window still covers three
// quarters of the bucket before, with its four requests: 3 + 1
// is over the limit.
wantCount(t, limiter, client, start.Add(tc.length+quarter), tc.window)
// Three quarters into it, a quarter: 1 + 2 is within.
wantCount(t, limiter, client, start.Add(tc.length+3*quarter), "")
})
}
}
func TestRefusedRequestsCount(t *testing.T) {
t.Parallel()
limiter := ratelimit.New(ratelimit.Limits{PerMinute: limit, PerHour: 2 * limit})
refused := netip.MustParsePrefix("203.0.113.9/32")
within := netip.MustParsePrefix("203.0.113.10/32")
start := midnight()
for range limit {
wantCount(t, limiter, refused, start, "")
wantCount(t, limiter, within, start, "")
}
for range limit {
wantCount(t, limiter, refused, start, minute)
}
// Half a minute into the next bucket the window covers half of the
// bucket before: 3 + 1 is over the minute's limit for the client
// whose three refused requests count, and 1.5 + 1 within it for the
// other. The first is over the hour's limit too, and the shorter
// window is named.
halfway := start.Add(time.Minute + time.Minute/2)
wantCount(t, limiter, refused, halfway, minute)
wantCount(t, limiter, within, halfway, "")
// The refused requests count in the hour as well: 6 + 1 + 1 is over
// its limit, and 3 + 1 + 1 within it.
later := start.Add(10 * time.Minute)
wantCount(t, limiter, refused, later, hour)
wantCount(t, limiter, within, later, "")
}
func TestRequestCountedLateGoesInTheBucketUnderWay(t *testing.T) {
t.Parallel()
limiter := ratelimit.New(ratelimit.Limits{PerMinute: limit})
client := netip.MustParsePrefix("203.0.113.9/32")
start := midnight()
for range limit {
wantCount(t, limiter, client, start, "")
}
// A concurrent request dated a moment before the bucket under way, but
// counted after it began, is counted in it: 3 + 1 is over the limit.
wantCount(t, limiter, client, start.Add(-time.Millisecond), minute)
}
func TestClockSetBackStartsTheBucketsAfresh(t *testing.T) {
t.Parallel()
limiter := ratelimit.New(ratelimit.Limits{PerHour: limit})
client := netip.MustParsePrefix("203.0.113.9/32")
start := midnight()
for range limit {
wantCount(t, limiter, client, start, "")
}
// Half an hour into the next bucket: 3 / 2 + 1 is within the limit.
wantCount(t, limiter, client, start.Add(time.Hour+time.Hour/2), "")
// The clock is set back an hour. Counted in the bucket under way, the
// next request would find the bucket before it at full weight, 3 + 2,
// over the limit until the clock caught up. The buckets start afresh
// instead, and the client is refused only past the limit again.
setBack := start.Add(time.Hour / 2)
for range limit {
wantCount(t, limiter, client, setBack, "")
}
wantCount(t, limiter, client, setBack, hour)
}
func TestKeepsAtMost20000ClientsDroppingTheLeastRecentlySeen(t *testing.T) {
t.Parallel()
const maxClients = 20000
limiter := ratelimit.New(ratelimit.Limits{PerMinute: 1})
now := midnight()
clients := make([]netip.Prefix, maxClients+1)
addr := netip.MustParseAddr("10.0.0.0")
for i := range clients {
clients[i] = netip.PrefixFrom(addr, addr.BitLen())
addr = addr.Next()
}
for _, client := range clients[:maxClients] {
wantCount(t, limiter, client, now, "")
}
// The first client is seen again: its second request is over the
// limit of one, so it is still counted.
wantCount(t, limiter, clients[0], now, minute)
// One client more drops the least recently seen, the second, which
// starts afresh, while the first is kept.
wantCount(t, limiter, clients[maxClients], now, "")
wantCount(t, limiter, clients[1], now, "")
wantCount(t, limiter, clients[0], now, minute)
}
// midnight is the start of a bucket in every window.
func midnight() time.Time {
return time.Date(2026, 10, 4, 0, 0, 0, 0, time.UTC)
}
// wantCount counts a request from client at now, and checks the window
// whose limit it goes over, "" for none.
func wantCount(
t *testing.T, limiter *ratelimit.Limiter, client netip.Prefix, now time.Time,
want string,
) {
t.Helper()
got := limiter.Count(client, now)
if got != want {
t.Errorf("request from %s at %s is over %q, want %q",
client, now.Format(time.RFC3339), got, want)
}
}
+6
View File
@@ -23,6 +23,9 @@ const (
// ActionUpstreamError is a request the app could not be reached
// for, or whose answer could not be passed on.
ActionUpstreamError = "upstream_error"
// ActionRateLimited is a request refused because it took its client
// over a rate limit, or came while the client was over one.
ActionRateLimited = "rate_limited"
)
// timeLayout is RFC 3339 with milliseconds.
@@ -49,6 +52,9 @@ type Line struct {
Referer string `json:"referer"`
UserAgent string `json:"user_agent"`
Action string `json:"action"`
// LimitHit is the window whose rate limit the request went over:
// minute, hour or day.
LimitHit string `json:"limit_hit,omitempty"`
// Aborted is true when the client went away early.
Aborted bool `json:"aborted,omitempty"`
// DurationTotal and DurationUpstreamTotal are in milliseconds.
+3 -1
View File
@@ -49,7 +49,9 @@ func TestWriteWritesOneJSONLineMarkedRequest(t *testing.T) {
}
}
unset := []string{"upstream_status", "aborted", "duration_upstream_total"}
unset := []string{
"upstream_status", "limit_hit", "aborted", "duration_upstream_total",
}
for _, name := range unset {
_, present := fields[name]
if present {
+3
View File
@@ -185,6 +185,9 @@ func wantStartingLine(t *testing.T, line map[string]any, appURL string) {
"SWWAF_UPSTREAM_RESPONSE_TIMEOUT": "30m",
"SWWAF_REQUEST_MAX_BYTES": "100M",
"SWWAF_RESPONSE_MAX_BYTES": "5G",
"SWWAF_RATE_LIMIT_PER_MINUTE": "1000",
"SWWAF_RATE_LIMIT_PER_HOUR": "10000",
"SWWAF_RATE_LIMIT_PER_DAY": "50000",
}
for name, value := range want {