[#6410] added rate limit option to exclude IPs/CIDR subnets
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
@@ -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)),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
Vendored
+2
-2
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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" });
|
||||
|
||||
Reference in New Issue
Block a user