fixed rate limiter rules matching to acount for the Audience field
This commit is contained in:
parent
487e83c84e
commit
70df03ffbb
|
@ -3,6 +3,8 @@
|
||||||
> [!CAUTION]
|
> [!CAUTION]
|
||||||
> **This is a prerelease intended for test and experimental purposes only!**
|
> **This is a prerelease intended for test and experimental purposes only!**
|
||||||
|
|
||||||
|
- Fixed rate limiter rules matching to acount for the `Audience` field.
|
||||||
|
|
||||||
- Minor UI fixes (fixed duplicate record control, removed duplicated id field in the record preview, hide Impersonate button for non-auth records, etc.).
|
- Minor UI fixes (fixed duplicate record control, removed duplicated id field in the record preview, hide Impersonate button for non-auth records, etc.).
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -32,9 +32,12 @@ func rateLimit() *hook.Handler[*core.RequestEvent] {
|
||||||
return e.Next()
|
return e.Next()
|
||||||
}
|
}
|
||||||
|
|
||||||
rule, ok := e.App.Settings().RateLimits.FindRateLimitRule(defaultRateLimitLabels(e))
|
rule, ok := e.App.Settings().RateLimits.FindRateLimitRule(
|
||||||
|
defaultRateLimitLabels(e),
|
||||||
|
defaultRateLimitAudience(e)...,
|
||||||
|
)
|
||||||
if ok {
|
if ok {
|
||||||
err := checkRateLimit(e, e.Request.Pattern, rule)
|
err := checkRateLimit(e, rule.Label+rule.Audience, rule)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
@ -94,9 +97,9 @@ func checkCollectionRateLimit(e *core.RequestEvent, collection *core.Collection,
|
||||||
}
|
}
|
||||||
labels = append(labels, defaultRateLimitLabels(e)...)
|
labels = append(labels, defaultRateLimitLabels(e)...)
|
||||||
|
|
||||||
rule, ok := e.App.Settings().RateLimits.FindRateLimitRule(labels)
|
rule, ok := e.App.Settings().RateLimits.FindRateLimitRule(labels, defaultRateLimitAudience(e)...)
|
||||||
if ok {
|
if ok {
|
||||||
return checkRateLimit(e, rtId, rule)
|
return checkRateLimit(e, rtId+rule.Audience, rule)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
@ -174,6 +177,17 @@ func skipRateLimit(e *core.RequestEvent) bool {
|
||||||
return !e.App.Settings().RateLimits.Enabled || e.HasSuperuserAuth()
|
return !e.App.Settings().RateLimits.Enabled || e.HasSuperuserAuth()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var defaultAuthAudience = []string{core.RateLimitRuleAudienceAll, core.RateLimitRuleAudienceAuth}
|
||||||
|
var defaultGuestAudience = []string{core.RateLimitRuleAudienceAll, core.RateLimitRuleAudienceGuest}
|
||||||
|
|
||||||
|
func defaultRateLimitAudience(e *core.RequestEvent) []string {
|
||||||
|
if e.Auth != nil {
|
||||||
|
return defaultAuthAudience
|
||||||
|
}
|
||||||
|
|
||||||
|
return defaultGuestAudience
|
||||||
|
}
|
||||||
|
|
||||||
func defaultRateLimitLabels(e *core.RequestEvent) []string {
|
func defaultRateLimitLabels(e *core.RequestEvent) []string {
|
||||||
return []string{e.Request.Method + " " + e.Request.URL.Path, e.Request.URL.Path}
|
return []string{e.Request.Method + " " + e.Request.URL.Path, e.Request.URL.Path}
|
||||||
}
|
}
|
||||||
|
|
|
@ -101,27 +101,27 @@ func TestDefaultRateLimitMiddleware(t *testing.T) {
|
||||||
{"/rate/b", 0, false, 200},
|
{"/rate/b", 0, false, 200},
|
||||||
{"/rate/b", 0, false, 429},
|
{"/rate/b", 0, false, 429},
|
||||||
|
|
||||||
// "auth" with guest (should be ignored)
|
// "auth" with guest (should fallback to the /rate/ rule)
|
||||||
{"/rate/auth", 0, false, 200},
|
|
||||||
{"/rate/auth", 0, false, 200},
|
|
||||||
{"/rate/auth", 0, false, 200},
|
{"/rate/auth", 0, false, 200},
|
||||||
{"/rate/auth", 0, false, 200},
|
{"/rate/auth", 0, false, 200},
|
||||||
|
{"/rate/auth", 0, false, 429},
|
||||||
|
{"/rate/auth", 0, false, 429},
|
||||||
|
|
||||||
// "auth" rule with regular user
|
// "auth" rule with regular user (should match the /rate/auth rule)
|
||||||
{"/rate/auth", 0, true, 200},
|
{"/rate/auth", 0, true, 200},
|
||||||
{"/rate/auth", 0, true, 429},
|
{"/rate/auth", 0, true, 429},
|
||||||
{"/rate/auth", 0, true, 429},
|
{"/rate/auth", 0, true, 429},
|
||||||
|
|
||||||
// "guest" with guest
|
// "guest" with guest (should match the /rate/guest rule)
|
||||||
{"/rate/guest", 0, false, 200},
|
{"/rate/guest", 0, false, 200},
|
||||||
{"/rate/guest", 0, false, 429},
|
{"/rate/guest", 0, false, 429},
|
||||||
{"/rate/guest", 0, false, 429},
|
{"/rate/guest", 0, false, 429},
|
||||||
|
|
||||||
// "guest" rule with regular user (should be ignored)
|
// "guest" rule with regular user (should fallback to the /rate/ rule)
|
||||||
{"/rate/guest", 0, true, 200},
|
{"/rate/guest", 1, true, 200},
|
||||||
{"/rate/guest", 0, true, 200},
|
|
||||||
{"/rate/guest", 0, true, 200},
|
|
||||||
{"/rate/guest", 0, true, 200},
|
{"/rate/guest", 0, true, 200},
|
||||||
|
{"/rate/guest", 0, true, 429},
|
||||||
|
{"/rate/guest", 0, true, 429},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, s := range scenarios {
|
for _, s := range scenarios {
|
||||||
|
|
|
@ -7,6 +7,7 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
@ -560,13 +561,17 @@ type RateLimitsConfig struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// FindRateLimitRule returns the first matching rule based on the provided labels.
|
// FindRateLimitRule returns the first matching rule based on the provided labels.
|
||||||
func (c *RateLimitsConfig) FindRateLimitRule(searchLabels []string) (RateLimitRule, bool) {
|
//
|
||||||
|
// Optionally you can further specify a list of valid RateLimitRule.Audience values to further filter the matching rule
|
||||||
|
// (aka. the rule Audience will have to exist in one of the specified options).
|
||||||
|
func (c *RateLimitsConfig) FindRateLimitRule(searchLabels []string, optOnlyAudience ...string) (RateLimitRule, bool) {
|
||||||
var prefixRules []int
|
var prefixRules []int
|
||||||
|
|
||||||
for i, label := range searchLabels {
|
for i, label := range searchLabels {
|
||||||
// check for direct match
|
// check for direct match
|
||||||
for j := range c.Rules {
|
for j := range c.Rules {
|
||||||
if label == c.Rules[j].Label {
|
if label == c.Rules[j].Label &&
|
||||||
|
(len(optOnlyAudience) == 0 || slices.Contains(optOnlyAudience, c.Rules[j].Audience)) {
|
||||||
return c.Rules[j], true
|
return c.Rules[j], true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -578,7 +583,8 @@ func (c *RateLimitsConfig) FindRateLimitRule(searchLabels []string) (RateLimitRu
|
||||||
// check for prefix match
|
// check for prefix match
|
||||||
if len(prefixRules) > 0 {
|
if len(prefixRules) > 0 {
|
||||||
for j := range prefixRules {
|
for j := range prefixRules {
|
||||||
if strings.HasPrefix(label+"/", c.Rules[prefixRules[j]].Label) {
|
if strings.HasPrefix(label+"/", c.Rules[prefixRules[j]].Label) &&
|
||||||
|
(len(optOnlyAudience) == 0 || slices.Contains(optOnlyAudience, c.Rules[prefixRules[j]].Audience)) {
|
||||||
return c.Rules[prefixRules[j]], true
|
return c.Rules[prefixRules[j]], true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -634,33 +634,43 @@ func TestRateLimitsFindRateLimitRule(t *testing.T) {
|
||||||
limits := core.RateLimitsConfig{
|
limits := core.RateLimitsConfig{
|
||||||
Rules: []core.RateLimitRule{
|
Rules: []core.RateLimitRule{
|
||||||
{Label: "abc"},
|
{Label: "abc"},
|
||||||
{Label: "POST /test/a/"},
|
{Label: "def", Audience: core.RateLimitRuleAudienceGuest},
|
||||||
{Label: "/test/a/"},
|
{Label: "/test/a", Audience: core.RateLimitRuleAudienceGuest},
|
||||||
{Label: "POST /test/a"},
|
{Label: "POST /test/a"},
|
||||||
{Label: "/test/a"},
|
{Label: "/test/a/", Audience: core.RateLimitRuleAudienceAuth},
|
||||||
|
{Label: "POST /test/a/"},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
scenarios := []struct {
|
scenarios := []struct {
|
||||||
labels []string
|
labels []string
|
||||||
|
audience []string
|
||||||
expected string
|
expected string
|
||||||
}{
|
}{
|
||||||
{[]string{}, ""},
|
{[]string{}, []string{}, ""},
|
||||||
{[]string{"missing"}, ""},
|
{[]string{"missing"}, []string{}, ""},
|
||||||
{[]string{"abc"}, "abc"},
|
{[]string{"abc"}, []string{}, "abc"},
|
||||||
{[]string{"/test"}, ""},
|
{[]string{"abc"}, []string{core.RateLimitRuleAudienceGuest}, ""},
|
||||||
{[]string{"/test/a"}, "/test/a"},
|
{[]string{"abc"}, []string{core.RateLimitRuleAudienceAuth}, ""},
|
||||||
{[]string{"GET /test/a"}, ""},
|
{[]string{"def"}, []string{core.RateLimitRuleAudienceGuest}, "def"},
|
||||||
{[]string{"POST /test/a"}, "POST /test/a"},
|
{[]string{"def"}, []string{core.RateLimitRuleAudienceAuth}, ""},
|
||||||
{[]string{"/test/a/b/c"}, "/test/a/"},
|
{[]string{"/test"}, []string{}, ""},
|
||||||
{[]string{"GET /test/a/b/c"}, ""},
|
{[]string{"/test/a"}, []string{}, "/test/a"},
|
||||||
{[]string{"POST /test/a/b/c"}, "POST /test/a/"},
|
{[]string{"/test/a"}, []string{core.RateLimitRuleAudienceAuth}, "/test/a/"},
|
||||||
{[]string{"/test/a", "abc"}, "/test/a"}, // priority checks
|
{[]string{"/test/a"}, []string{core.RateLimitRuleAudienceGuest}, "/test/a"},
|
||||||
|
{[]string{"GET /test/a"}, []string{}, ""},
|
||||||
|
{[]string{"POST /test/a"}, []string{}, "POST /test/a"},
|
||||||
|
{[]string{"/test/a/b/c"}, []string{}, "/test/a/"},
|
||||||
|
{[]string{"/test/a/b/c"}, []string{core.RateLimitRuleAudienceAuth}, "/test/a/"},
|
||||||
|
{[]string{"/test/a/b/c"}, []string{core.RateLimitRuleAudienceGuest}, ""},
|
||||||
|
{[]string{"GET /test/a/b/c"}, []string{}, ""},
|
||||||
|
{[]string{"POST /test/a/b/c"}, []string{}, "POST /test/a/"},
|
||||||
|
{[]string{"/test/a", "abc"}, []string{}, "/test/a"}, // priority checks
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, s := range scenarios {
|
for _, s := range scenarios {
|
||||||
t.Run(strings.Join(s.labels, ""), func(t *testing.T) {
|
t.Run(strings.Join(s.labels, "_")+":"+strings.Join(s.audience, "_"), func(t *testing.T) {
|
||||||
rule, ok := limits.FindRateLimitRule(s.labels)
|
rule, ok := limits.FindRateLimitRule(s.labels, s.audience...)
|
||||||
|
|
||||||
hasLabel := rule.Label != ""
|
hasLabel := rule.Label != ""
|
||||||
if hasLabel != ok {
|
if hasLabel != ok {
|
||||||
|
|
|
@ -39,7 +39,7 @@
|
||||||
|
|
||||||
<Field class="form-field form-field-toggle m-b-sm" name="batch.enabled" let:uniqueId>
|
<Field class="form-field form-field-toggle m-b-sm" name="batch.enabled" let:uniqueId>
|
||||||
<input type="checkbox" id={uniqueId} bind:checked={formSettings.batch.enabled} />
|
<input type="checkbox" id={uniqueId} bind:checked={formSettings.batch.enabled} />
|
||||||
<label for={uniqueId}>Enable</label>
|
<label for={uniqueId}>Enable <small class="txt-hint">(experimental)</small></label>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
<div class="grid">
|
<div class="grid">
|
||||||
|
|
|
@ -150,7 +150,7 @@
|
||||||
|
|
||||||
<Field class="form-field form-field-toggle m-b-xs" name="rateLimits.enabled" let:uniqueId>
|
<Field class="form-field form-field-toggle m-b-xs" name="rateLimits.enabled" let:uniqueId>
|
||||||
<input type="checkbox" id={uniqueId} bind:checked={formSettings.rateLimits.enabled} />
|
<input type="checkbox" id={uniqueId} bind:checked={formSettings.rateLimits.enabled} />
|
||||||
<label for={uniqueId}>Enable</label>
|
<label for={uniqueId}>Enable <small class="txt-hint">(experimental)</small></label>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
{#if !CommonHelper.isEmpty(formSettings.rateLimits.rules)}
|
{#if !CommonHelper.isEmpty(formSettings.rateLimits.rules)}
|
||||||
|
@ -263,6 +263,22 @@
|
||||||
<h4 class="center txt-break">Rate limit label format</h4>
|
<h4 class="center txt-break">Rate limit label format</h4>
|
||||||
</svelte:fragment>
|
</svelte:fragment>
|
||||||
|
|
||||||
|
<p>The rate limit rules are resolved in the following order (stops on the first match):</p>
|
||||||
|
<ol>
|
||||||
|
<li>exact tag (e.g. <code>users:create</code>)</li>
|
||||||
|
<li>wildcard tag (e.g. <code>*:create</code>)</li>
|
||||||
|
<li>METHOD + exact path (e.g. <code>POST /a/b</code>)</li>
|
||||||
|
<li>METHOD + prefix path (e.g. <code>POST /a/b<strong>/</strong></code>)</li>
|
||||||
|
<li>exact path (e.g. <code>/a/b</code>)</li>
|
||||||
|
<li>prefix path (e.g. <code>/a/b<strong>/</strong></code>)</li>
|
||||||
|
</ol>
|
||||||
|
<p>
|
||||||
|
In case of multiple rules with the same label but different target user audience (e.g. "guest" vs
|
||||||
|
"auth"), only the matching audience rule is taken in consideration.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<hr class="m-t-xs m-b-xs" />
|
||||||
|
|
||||||
<p>The rate limit label could be in one of the following formats:</p>
|
<p>The rate limit label could be in one of the following formats:</p>
|
||||||
<ul>
|
<ul>
|
||||||
<li class="m-b-sm">
|
<li class="m-b-sm">
|
||||||
|
|
Loading…
Reference in New Issue