[#6410] added rate limit option to exclude IPs/CIDR subnets

This commit is contained in:
Gani Georgiev
2026-05-05 17:29:25 +03:00
parent 24d72877e9
commit 5a144e1342
12 changed files with 453 additions and 150 deletions
+4
View File
@@ -23,6 +23,10 @@
./pocketbase superuser ips 127.0.0.1 10.0.0.0 --dir=/custom/path/to/pb_data
```
- Added rate limit option to exclude IPs/CIDR subnets ([#6410](https://github.com/pocketbase/pocketbase/issues/6410)).
- (@todo) Bumped min Go GitHub action version to 1.26.3 because it comes with some [minor bug and security fixes](https://github.com/golang/go/issues?q=milestone%3AGo1.26.3).
## v0.37.5
+4 -2
View File
@@ -109,7 +109,7 @@ func checkCollectionRateLimit(e *core.RequestEvent, collection *core.Collection,
// isIPInList checks if the specified IP is in a list of other individual IPs or subnets.
func isIPInList(ipsOrSubnets []string, ip string) bool {
if ip == "" || len(ipsOrSubnets) == 0 {
if len(ipsOrSubnets) == 0 || ip == "" {
return false
}
@@ -189,7 +189,9 @@ func checkRateLimit(e *core.RequestEvent, rtId string, rule core.RateLimitRule)
}
func skipRateLimit(e *core.RequestEvent) bool {
return !e.App.Settings().RateLimits.Enabled || e.HasSuperuserAuth()
return !e.App.Settings().RateLimits.Enabled ||
e.HasSuperuserAuth() ||
isIPInList(e.App.Settings().RateLimits.ExcludedIPs, e.RealIP())
}
var defaultAuthAudience = []string{core.RateLimitRuleAudienceAll, core.RateLimitRuleAudienceAuth}
+163 -3
View File
@@ -8,6 +8,7 @@ import (
"github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tests"
"github.com/pocketbase/pocketbase/tools/hook"
)
func TestDefaultRateLimitMiddleware(t *testing.T) {
@@ -85,9 +86,8 @@ func TestDefaultRateLimitMiddleware(t *testing.T) {
{"/norate", 0, false, 200},
{"/rate/a", 0, false, 200},
{"/rate/a", 800, false, 200}, // (fixed window check) wait enough to ensure that it can't fit more than 2 requests in 1s
{"/rate/a", 600, false, 200},
{"/rate/a", 850, false, 200},
{"/rate/a", 900, false, 200}, // (fixed window check) wait enough to ensure that it can't fit more than 2 requests in 1s
{"/rate/a", 900, false, 200},
{"/rate/a", 0, false, 200},
{"/rate/a", 0, false, 429},
{"/rate/a", 0, false, 429},
@@ -160,3 +160,163 @@ func TestDefaultRateLimitMiddleware(t *testing.T) {
})
}
}
func TestDefaultRateLimitMiddlewareSkipChecks(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
app.Settings().RateLimits.Enabled = true
app.Settings().RateLimits.Rules = []core.RateLimitRule{
{
Label: "/rate",
MaxRequests: 1,
Duration: 5,
},
}
pbRouter, err := apis.NewRouter(app)
if err != nil {
t.Fatal(err)
}
// just for the exclude tests - load the user IP from a query param
pbRouter.Bind(&hook.Handler[*core.RequestEvent]{
Priority: apis.DefaultRateLimitMiddlewarePriority - 1,
Func: func(e *core.RequestEvent) error {
testIp := e.Request.URL.Query().Get("testIP")
if testIp != "" {
e.Request.Header.Set("x-test-ip", testIp)
}
return e.Next()
},
})
pbRouter.GET("/rate", func(e *core.RequestEvent) error {
return e.String(200, "test")
})
mux, err := pbRouter.BuildMux()
if err != nil {
t.Fatal(err)
}
checkStatusCodes := func(t *testing.T, got []int, expected []int) {
if len(expected) != len(got) {
t.Fatalf("Expected status codes %v, got %v", expected, got)
}
for i, item := range expected {
if got[i] != item {
t.Fatalf("Expected %d status code to be %d, got %d:\n%v", i, item, got[i], got)
}
}
}
t.Run("base check", func(t *testing.T) {
app.Settings().RateLimits.Enabled = true
statusCodes := []int{}
for range 3 {
rec := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/rate", nil)
mux.ServeHTTP(rec, req)
result := rec.Result()
statusCodes = append(statusCodes, result.StatusCode)
}
checkStatusCodes(t, statusCodes, []int{200, 429, 429})
})
t.Run("disabled rate limiter", func(t *testing.T) {
app.Settings().RateLimits.Enabled = false
statusCodes := []int{}
for range 3 {
rec := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/rate", nil)
mux.ServeHTTP(rec, req)
result := rec.Result()
statusCodes = append(statusCodes, result.StatusCode)
}
checkStatusCodes(t, statusCodes, []int{200, 200, 200})
})
t.Run("authenticated as superuser", func(t *testing.T) {
app.Settings().RateLimits.Enabled = true
superuser, err := app.FindAuthRecordByEmail(core.CollectionNameSuperusers, "test@example.com")
if err != nil {
t.Fatal(err)
}
token, err := superuser.NewAuthToken()
if err != nil {
t.Fatal(err)
}
statusCodes := []int{}
for range 3 {
rec := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/rate", nil)
req.Header.Add("Authorization", token)
mux.ServeHTTP(rec, req)
result := rec.Result()
statusCodes = append(statusCodes, result.StatusCode)
}
checkStatusCodes(t, statusCodes, []int{200, 200, 200})
})
t.Run("excludedIPs (different)", func(t *testing.T) {
app.Settings().RateLimits.Enabled = true
app.Settings().RateLimits.ExcludedIPs = []string{"10.0.0.0"}
app.Settings().TrustedProxy.Headers = []string{"x-test-ip"}
statusCodes := []int{}
for range 3 {
rec := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/rate", nil)
req.Header.Set("x-test-ip", "127.0.0.1")
mux.ServeHTTP(rec, req)
result := rec.Result()
statusCodes = append(statusCodes, result.StatusCode)
}
checkStatusCodes(t, statusCodes, []int{200, 429, 429})
})
t.Run("excludedIPs (match)", func(t *testing.T) {
app.Settings().RateLimits.Enabled = true
app.Settings().RateLimits.ExcludedIPs = []string{"127.0.0.1"}
app.Settings().TrustedProxy.Headers = []string{"x-test-ip"}
statusCodes := []int{}
for range 3 {
rec := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/rate", nil)
req.Header.Set("x-test-ip", "127.0.0.1")
mux.ServeHTTP(rec, req)
result := rec.Result()
statusCodes = append(statusCodes, result.StatusCode)
}
checkStatusCodes(t, statusCodes, []int{200, 200, 200})
})
}
+11 -3
View File
@@ -290,7 +290,7 @@ func (s *Settings) PostValidate(ctx context.Context, app App) error {
defer s.mu.RUnlock()
return validation.ValidateStructWithContext(ctx, s,
validation.Field(&s.SuperuserIPs, validation.Each(validation.By(validators.IPOrSubnet))),
validation.Field(&s.SuperuserIPs, validation.Each(validation.Required, validation.By(validators.IPOrSubnet))),
validation.Field(&s.Meta),
validation.Field(&s.Logs),
validation.Field(&s.SMTP),
@@ -604,8 +604,9 @@ func (c TrustedProxyConfig) Validate() error {
// -------------------------------------------------------------------
type RateLimitsConfig struct {
Rules []RateLimitRule `form:"rules" json:"rules"`
Enabled bool `form:"enabled" json:"enabled"`
Rules []RateLimitRule `form:"rules" json:"rules"`
ExcludedIPs []string `form:"excludedIPs" json:"excludedIPs"`
Enabled bool `form:"enabled" json:"enabled"`
}
// FindRateLimitRule returns the first matching rule based on the provided labels.
@@ -650,6 +651,9 @@ func (c RateLimitsConfig) MarshalJSON() ([]byte, error) {
if c.Rules == nil {
c.Rules = []RateLimitRule{}
}
if c.ExcludedIPs == nil {
c.ExcludedIPs = []string{}
}
return json.Marshal(alias(c))
}
@@ -662,6 +666,10 @@ func (c RateLimitsConfig) Validate() error {
validation.When(c.Enabled, validation.Required),
validation.By(checkUniqueRuleLabel),
),
validation.Field(
&c.ExcludedIPs,
validation.Each(validation.Required, validation.By(validators.IPOrSubnet)),
),
)
}
+8 -6
View File
@@ -84,7 +84,7 @@ func TestSettings_DBExport(t *testing.T) {
valueStr = string(export["value"].([]byte))
}
expected := `{"superuserIPs":[],"smtp":{"enabled":false,"port":0,"host":"smtp_host","username":"smtp_username","password":"","authMethod":"","tls":false,"localName":""},"backups":{"cron":"* * * * *","cronMaxKeep":0,"s3":{"enabled":true,"bucket":"","region":"","endpoint":"","accessKey":"","forcePathStyle":false}},"s3":{"enabled":false,"bucket":"","region":"","endpoint":"s3_endpoint","accessKey":"","secret":"s3_secret","forcePathStyle":false},"meta":{"accentColor":"","appName":"test_app_name","appURL":"","senderName":"","senderAddress":"","hideControls":false},"rateLimits":{"rules":[],"enabled":true},"trustedProxy":{"headers":[],"useLeftmostIP":true},"batch":{"enabled":false,"maxRequests":0,"timeout":15,"maxBodySize":0},"logs":{"maxDays":123,"minLevel":0,"logIP":false,"logAuthId":false}}`
expected := `{"superuserIPs":[],"smtp":{"enabled":false,"port":0,"host":"smtp_host","username":"smtp_username","password":"","authMethod":"","tls":false,"localName":""},"backups":{"cron":"* * * * *","cronMaxKeep":0,"s3":{"enabled":true,"bucket":"","region":"","endpoint":"","accessKey":"","forcePathStyle":false}},"s3":{"enabled":false,"bucket":"","region":"","endpoint":"s3_endpoint","accessKey":"","secret":"s3_secret","forcePathStyle":false},"meta":{"accentColor":"","appName":"test_app_name","appURL":"","senderName":"","senderAddress":"","hideControls":false},"rateLimits":{"rules":[],"excludedIPs":[],"enabled":true},"trustedProxy":{"headers":[],"useLeftmostIP":true},"batch":{"enabled":false,"maxRequests":0,"timeout":15,"maxBodySize":0},"logs":{"maxDays":123,"minLevel":0,"logIP":false,"logAuthId":false}}`
if valueStr != expected {
t.Fatalf("Expected exported settings\n%s\ngot\n%s", expected, valueStr)
}
@@ -180,7 +180,7 @@ func TestSettingsMarshalJSON(t *testing.T) {
}
rawStr := string(raw)
expected := `{"superuserIPs":[],"smtp":{"enabled":false,"port":0,"host":"","username":"abc","authMethod":"","tls":false,"localName":""},"backups":{"cron":"","cronMaxKeep":0,"s3":{"enabled":false,"bucket":"","region":"","endpoint":"","accessKey":"","forcePathStyle":false}},"s3":{"enabled":false,"bucket":"","region":"","endpoint":"","accessKey":"","forcePathStyle":false},"meta":{"accentColor":"","appName":"test123","appURL":"","senderName":"","senderAddress":"","hideControls":false},"rateLimits":{"rules":[],"enabled":false},"trustedProxy":{"headers":[],"useLeftmostIP":false},"batch":{"enabled":false,"maxRequests":0,"timeout":0,"maxBodySize":0},"logs":{"maxDays":0,"minLevel":0,"logIP":false,"logAuthId":false}}`
expected := `{"superuserIPs":[],"smtp":{"enabled":false,"port":0,"host":"","username":"abc","authMethod":"","tls":false,"localName":""},"backups":{"cron":"","cronMaxKeep":0,"s3":{"enabled":false,"bucket":"","region":"","endpoint":"","accessKey":"","forcePathStyle":false}},"s3":{"enabled":false,"bucket":"","region":"","endpoint":"","accessKey":"","forcePathStyle":false},"meta":{"accentColor":"","appName":"test123","appURL":"","senderName":"","senderAddress":"","hideControls":false},"rateLimits":{"rules":[],"excludedIPs":[],"enabled":false},"trustedProxy":{"headers":[],"useLeftmostIP":false},"batch":{"enabled":false,"maxRequests":0,"timeout":0,"maxBodySize":0},"logs":{"maxDays":0,"minLevel":0,"logIP":false,"logAuthId":false}}`
if rawStr != expected {
t.Fatalf("Expected\n%v\ngot\n%v", expected, rawStr)
@@ -196,7 +196,7 @@ func TestSettingsValidate(t *testing.T) {
s := app.Settings()
// set invalid settings data
s.SuperuserIPs = []string{"127.0.0.1", "invalid"}
s.SuperuserIPs = []string{"127.0.0.1", ""}
s.Meta.AppName = ""
s.Logs.MaxDays = -10
s.SMTP.Enabled = true
@@ -597,7 +597,8 @@ func TestRateLimitsConfigValidate(t *testing.T) {
{
"invalid data",
core.RateLimitsConfig{
Enabled: true,
Enabled: true,
ExcludedIPs: []string{"", "127.0.0.1"},
Rules: []core.RateLimitRule{
{
Label: "/123abc/",
@@ -611,12 +612,13 @@ func TestRateLimitsConfigValidate(t *testing.T) {
},
},
},
[]string{"rules"},
[]string{"rules", "excludedIPs"},
},
{
"valid data",
core.RateLimitsConfig{
Enabled: true,
Enabled: true,
ExcludedIPs: []string{"127.0.0.1", "10.0.0.1/20"},
Rules: []core.RateLimitRule{
{
Label: "123_abc",
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -13,9 +13,9 @@
<!-- prism -->
<script src="./libs/prism/prism.js" data-manual></script>
<script type="module" crossorigin src="./assets/index-C6EtHz6e.js"></script>
<script type="module" crossorigin src="./assets/index-1sz-Wwkw.js"></script>
<link rel="modulepreload" crossorigin href="./assets/pocketbase.es-B_4DUNUU.js">
<link rel="stylesheet" crossorigin href="./assets/index-DMmjfPb3.css">
<link rel="stylesheet" crossorigin href="./assets/index-D92J8BMA.css">
</head>
<body>
</body>
+6
View File
@@ -61,6 +61,12 @@
}
}
td.col-new-btn {
padding: 3px;
&:focus-within {
background: var(--surfaceAlt2Color);
}
}
.col-label {
width: 60%;
}
@@ -15,8 +15,11 @@ export function pageApplicationSettings() {
get originalFormSettingsHash() {
return JSON.stringify(data.originalFormSettings);
},
get formSettingsHash() {
return JSON.stringify(data.formSettings);
},
get hasChanges() {
return data.originalFormSettingsHash != JSON.stringify(data.formSettings);
return data.originalFormSettingsHash != data.formSettingsHash;
},
});
@@ -136,7 +139,7 @@ export function pageApplicationSettings() {
meta: settings.meta || {},
batch: settings.batch || {},
trustedProxy: settings.trustedProxy || { headers: [] },
rateLimits: settings.rateLimits || { rules: [] },
rateLimits: settings.rateLimits || { excludedIPs: [], rules: [] },
};
sortRules(data.originalFormSettings.rateLimits.rules);
+246 -128
View File
@@ -80,6 +80,23 @@ export function rateLimitAccordion(pageData) {
const accordionData = store({
predefinedTags: basePredefinedTags,
showMoreOptions: false,
get originalRateLimitFieldsHash() {
return JSON.stringify(pageData.originalFormSettings?.rateLimits.rules)
+ JSON.stringify(pageData.originalFormSettings?.rateLimits.excludedIPs);
},
get hasRateLimitFieldsChanged() {
const newHash = JSON.stringify(pageData.formSettings?.rateLimits.rules)
+ JSON.stringify(pageData.formSettings?.rateLimits.excludedIPs);
return accordionData.originalRateLimitFieldsHash != newHash;
},
get enableWarn() {
return (
!pageData.formSettings?.rateLimits?.enabled
&& pageData.formSettings?.rateLimits?.rules?.length
&& accordionData.hasRateLimitFieldsChanged
);
},
});
loadPredefinedTags();
@@ -206,6 +223,16 @@ export function rateLimitAccordion(pageData) {
delete app.store.errors.rateLimits;
},
),
// ensure that the excluded IP field is visible in case of an error
watch(
() => app.store.errors?.rateLimits?.excludedIPs,
(newErr) => {
if (newErr) {
accordionData.showMoreOptions = true;
return;
}
},
),
);
},
onunmount: () => {
@@ -224,7 +251,7 @@ export function rateLimitAccordion(pageData) {
return t.span({ className: "label" }, "Disabled");
},
() => {
if (!app.utils.isEmpty(app.store.errors?.rateLimits)) {
if (pageData.formSettingsHash && !app.utils.isEmpty(app.store.errors?.rateLimits)) {
return t.i({
className: "ri-error-warning-fill txt-danger",
ariaDescription: app.attrs.tooltip("Has errors", "left"),
@@ -237,18 +264,32 @@ export function rateLimitAccordion(pageData) {
t.div(
{ className: "col-lg-12" },
t.div(
{ className: "field" },
t.input({
id: "rateLimits.enabled",
name: "rateLimits.enabled",
type: "checkbox",
className: "switch",
checked: () => pageData.formSettings.rateLimits.enabled || false,
onchange: (e) => (pageData.formSettings.rateLimits.enabled = e.target.checked),
}),
t.label(
{ htmlFor: "rateLimits.enabled" },
t.span({ className: "txt" }, "Enable"),
{ className: "flex" },
t.div(
{ className: "field" },
t.input({
id: "rateLimits.enabled",
name: "rateLimits.enabled",
type: "checkbox",
className: "switch",
checked: () => pageData.formSettings.rateLimits.enabled || false,
onchange: (e) => (pageData.formSettings.rateLimits.enabled = e.target.checked),
}),
t.label(
{ htmlFor: "rateLimits.enabled" },
t.span(
{ className: () => `txt ${accordionData.enableWarn ? "txt-warning" : ""}` },
"Enable",
),
),
),
t.button(
{
type: "button",
className: "link-hint txt-sm m-l-auto",
onclick: () => openRateLimitInfoModal(),
},
t.em(null, "Learn more about the rate limit rules"),
),
),
),
@@ -281,131 +322,208 @@ export function rateLimitAccordion(pageData) {
t.th({ className: "col-action" }),
),
),
t.tbody(null, () => {
const rows = [];
const rules = pageData.formSettings.rateLimits.rules || [];
t.tbody(
null,
() => {
const rows = [];
const rules = pageData.formSettings.rateLimits.rules || [];
for (let i = 0; i < rules.length; i++) {
const rule = rules[i];
for (let i = 0; i < rules.length; i++) {
const rule = rules[i];
rows.push(
t.tr(
{ className: "rate-limit-row" },
t.td(
{ className: "col-label" },
t.div(
{ className: "field" },
t.input({
type: "text",
required: true,
className: "inline-error",
id: "rateLimits.rules." + i + ".label",
name: "rateLimits.rules." + i + ".label",
placeholder: "tag (users:create) or path (/api/)",
"html-list": "rateLimits.rules." + i + ".label_list",
value: () => rule.label,
oninput: (e) => (rule.label = e.target.value),
}),
t.datalist(
rows.push(
t.tr(
{ className: "rate-limit-row" },
t.td(
{ className: "col-label" },
t.div(
{ className: "field" },
t.input({
type: "text",
required: true,
className: "inline-error",
id: "rateLimits.rules." + i + ".label",
name: "rateLimits.rules." + i + ".label",
placeholder: "tag (users:create) or path (/api/)",
"html-list": "rateLimits.rules." + i + ".label_list",
value: () => rule.label,
oninput: (e) => (rule.label = e.target.value),
}),
t.datalist(
{
id: "rateLimits.rules." + i + ".label_list",
},
() => {
return accordionData.predefinedTags.map((tag) => {
return t.option({ value: tag.value }, tag.label || "");
});
},
),
),
),
t.td(
{ className: "col-requests" },
t.div(
{ className: "field" },
t.input({
type: "number",
required: true,
placeholder: "Max requests*",
className: "inline-error",
min: 1,
step: 1,
name: "rateLimits.rules." + i + ".maxRequests",
value: () => rule.maxRequests || 0,
oninput: (e) => rule.maxRequests = parseInt(e.target.value, 10),
}),
),
),
t.td(
{ className: "col-duration" },
t.div(
{ className: "field" },
t.input({
type: "number",
required: true,
placeholder: "Interval*",
className: "inline-error",
min: 1,
step: 1,
name: "rateLimits.rules." + i + ".duration",
value: () => rule.duration,
oninput: (e) => rule.duration = parseInt(e.target.value, 10),
}),
),
),
t.td(
{ className: "col-audience" },
t.div(
{ className: "field" },
app.components.select({
name: "rateLimits.rules." + i + ".audience",
className: "inline-error",
options: audienceOptions,
required: true,
value: () => rule.audience || "",
onchange: (selected) => {
rule.audience = selected?.[0]?.value;
},
}),
),
),
t.td(
{ className: "col-action" },
t.button(
{
id: "rateLimits.rules." + i + ".label_list",
},
() => {
return accordionData.predefinedTags.map((tag) => {
return t.option({ value: tag.value }, tag.label || "");
});
type: "button",
araiaDescription: app.attrs.tooltip("Remove rule"),
className: "btn sm secondary transparent circle",
onclick: () => removeRule(i),
},
t.i({ className: "ri-close-line" }),
),
),
),
t.td(
{ className: "col-requests" },
t.div(
{ className: "field" },
t.input({
type: "number",
required: true,
placeholder: "Max requests*",
className: "inline-error",
min: 1,
step: 1,
name: "rateLimits.rules." + i + ".maxRequests",
value: () => rule.maxRequests || 0,
oninput: (e) => rule.maxRequests = parseInt(e.target.value, 10),
}),
),
),
t.td(
{ className: "col-duration" },
t.div(
{ className: "field" },
t.input({
type: "number",
required: true,
placeholder: "Interval*",
className: "inline-error",
min: 1,
step: 1,
name: "rateLimits.rules." + i + ".duration",
value: () => rule.duration,
oninput: (e) => rule.duration = parseInt(e.target.value, 10),
}),
),
),
t.td(
{ className: "col-audience" },
t.div(
{ className: "field" },
app.components.select({
name: "rateLimits.rules." + i + ".audience",
className: "inline-error",
options: audienceOptions,
required: true,
value: () => rule.audience || "",
onchange: (selected) => {
rule.audience = selected?.[0]?.value;
},
}),
),
),
t.td(
{ className: "col-action" },
t.button(
{
type: "button",
araiaDescription: app.attrs.tooltip("Remove rule"),
className: "btn sm secondary transparent circle",
onclick: () => removeRule(i),
},
t.i({ className: "ri-close-line" }),
),
),
),
);
}
);
}
return rows;
}),
return rows;
},
t.tr(
{ className: "rate-limit-row" },
t.td(
{ colSpan: 99, className: "col-new-btn" },
t.button(
{
type: "button",
className: "btn secondary sm full-width",
onclick: () => newRule(),
},
t.i({ className: "ri-add-line", ariaHidden: true }),
t.span({ className: "txt" }, "Add rate limit rule"),
),
),
),
),
),
),
t.div(
{ className: "flex m-t-sm" },
t.button(
{
type: "button",
className: "btn secondary sm",
onclick: () => newRule(),
},
t.i({ className: "ri-add-line", ariaHidden: true }),
t.span({ className: "txt" }, "Add rate limit rule"),
),
t.button(
{
type: "button",
className: "link-hint txt-sm m-l-auto",
onclick: () => openRateLimitInfoModal(),
},
t.em(null, "Learn more about the rate limit rules"),
),
t.div(
{ className: "col-lg-12" },
t.button(
{
type: "button",
className: () => `btn secondary sm ${!accordionData.showMoreOptions ? "transparent" : ""}`,
onclick: () => accordionData.showMoreOptions = !accordionData.showMoreOptions,
},
t.span({ className: "txt" }, "More options"),
t.i({
ariaHidden: true,
className: () => accordionData.showMoreOptions ? "ri-arrow-up-s-line" : "ri-arrow-down-s-line",
}),
),
app.components.slide(
() => accordionData.showMoreOptions,
t.div(
{ className: "p-t-10" },
t.div(
{ className: "fields" },
t.div(
{ className: "field" },
t.label(
{ htmlFor: "excludedIPs" },
t.span({ className: "txt" }, "Excluded IPs and subnets"),
),
t.input({
id: "excludedIPs",
name: "rateLimits.excludedIPs",
type: "text",
value: () => app.utils.joinNonEmpty(pageData.formSettings.rateLimits.excludedIPs),
oninput: (e) => {
const newValue = app.utils.splitNonEmpty(e.target.value, ",");
const newStr = app.utils.joinNonEmpty(newValue);
const oldStr = app.utils.joinNonEmpty(
pageData.formSettings.rateLimits.excludedIPs,
);
// has an actual change
if (oldStr != newStr) {
pageData.formSettings.rateLimits.excludedIPs = newValue;
}
},
}),
),
t.div(
{ className: "field addon" },
t.button(
{
type: "button",
className: () =>
`btn sm secondary transparent ${
app.utils.isEmpty(pageData.formSettings.rateLimits.excludedIPs)
? "hidden"
: ""
}`,
onclick: () => {
pageData.formSettings.rateLimits.excludedIPs = [];
if (app.store.errors?.rateLimits?.excludedIPs) {
delete app.store.errors.rateLimits.excludedIPs;
}
},
},
t.span({ className: "txt" }, "Clear"),
),
),
),
t.div(
{ className: "field-help" },
t.p(null, "Comma separated list of IPs and CIDR subnets to exclude from the rate limiter."),
t.p(
null,
"Superusers are always excluded and they can send as many requests as they want.",
),
),
),
),
),
@@ -61,7 +61,7 @@ export function trustedProxyAccordion(pageData) {
t.summary(
null,
t.i({ className: "ri-route-line", ariaHidden: true }),
t.span({ className: "txt" }, "User IP proxy headers"),
t.span({ className: "txt" }, "IP proxy headers"),
() => {
if (proxyInfo.isLoading) {
return t.span({ className: "loader sm" });