You've already forked pocketbase
							
							
				mirror of
				https://github.com/pocketbase/pocketbase.git
				synced 2025-10-31 16:47:43 +02:00 
			
		
		
		
	fixed rate limiter rules matching to acount for the Audience field
This commit is contained in:
		| @@ -3,6 +3,8 @@ | ||||
| > [!CAUTION] | ||||
| > **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.). | ||||
|  | ||||
|  | ||||
|   | ||||
| @@ -32,9 +32,12 @@ func rateLimit() *hook.Handler[*core.RequestEvent] { | ||||
| 				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 { | ||||
| 				err := checkRateLimit(e, e.Request.Pattern, rule) | ||||
| 				err := checkRateLimit(e, rule.Label+rule.Audience, rule) | ||||
| 				if err != nil { | ||||
| 					return err | ||||
| 				} | ||||
| @@ -94,9 +97,9 @@ func checkCollectionRateLimit(e *core.RequestEvent, collection *core.Collection, | ||||
| 	} | ||||
| 	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 { | ||||
| 		return checkRateLimit(e, rtId, rule) | ||||
| 		return checkRateLimit(e, rtId+rule.Audience, rule) | ||||
| 	} | ||||
|  | ||||
| 	return nil | ||||
| @@ -174,6 +177,17 @@ func skipRateLimit(e *core.RequestEvent) bool { | ||||
| 	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 { | ||||
| 	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, 429}, | ||||
|  | ||||
| 		// "auth" with guest (should be ignored) | ||||
| 		{"/rate/auth", 0, false, 200}, | ||||
| 		{"/rate/auth", 0, false, 200}, | ||||
| 		// "auth" with guest (should fallback to the /rate/ rule) | ||||
| 		{"/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, 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, 429}, | ||||
| 		{"/rate/guest", 0, false, 429}, | ||||
|  | ||||
| 		// "guest" rule with regular user (should be ignored) | ||||
| 		{"/rate/guest", 0, true, 200}, | ||||
| 		{"/rate/guest", 0, true, 200}, | ||||
| 		{"/rate/guest", 0, true, 200}, | ||||
| 		// "guest" rule with regular user (should fallback to the /rate/ rule) | ||||
| 		{"/rate/guest", 1, true, 200}, | ||||
| 		{"/rate/guest", 0, true, 200}, | ||||
| 		{"/rate/guest", 0, true, 429}, | ||||
| 		{"/rate/guest", 0, true, 429}, | ||||
| 	} | ||||
|  | ||||
| 	for _, s := range scenarios { | ||||
|   | ||||
| @@ -7,6 +7,7 @@ import ( | ||||
| 	"fmt" | ||||
| 	"os" | ||||
| 	"regexp" | ||||
| 	"slices" | ||||
| 	"strconv" | ||||
| 	"strings" | ||||
| 	"sync" | ||||
| @@ -560,13 +561,17 @@ type RateLimitsConfig struct { | ||||
| } | ||||
|  | ||||
| // 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 | ||||
|  | ||||
| 	for i, label := range searchLabels { | ||||
| 		// check for direct match | ||||
| 		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 | ||||
| 			} | ||||
|  | ||||
| @@ -578,7 +583,8 @@ func (c *RateLimitsConfig) FindRateLimitRule(searchLabels []string) (RateLimitRu | ||||
| 		// check for prefix match | ||||
| 		if len(prefixRules) > 0 { | ||||
| 			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 | ||||
| 				} | ||||
| 			} | ||||
|   | ||||
| @@ -634,33 +634,43 @@ func TestRateLimitsFindRateLimitRule(t *testing.T) { | ||||
| 	limits := core.RateLimitsConfig{ | ||||
| 		Rules: []core.RateLimitRule{ | ||||
| 			{Label: "abc"}, | ||||
| 			{Label: "POST /test/a/"}, | ||||
| 			{Label: "/test/a/"}, | ||||
| 			{Label: "def", Audience: core.RateLimitRuleAudienceGuest}, | ||||
| 			{Label: "/test/a", Audience: core.RateLimitRuleAudienceGuest}, | ||||
| 			{Label: "POST /test/a"}, | ||||
| 			{Label: "/test/a"}, | ||||
| 			{Label: "/test/a/", Audience: core.RateLimitRuleAudienceAuth}, | ||||
| 			{Label: "POST /test/a/"}, | ||||
| 		}, | ||||
| 	} | ||||
|  | ||||
| 	scenarios := []struct { | ||||
| 		labels   []string | ||||
| 		audience []string | ||||
| 		expected string | ||||
| 	}{ | ||||
| 		{[]string{}, ""}, | ||||
| 		{[]string{"missing"}, ""}, | ||||
| 		{[]string{"abc"}, "abc"}, | ||||
| 		{[]string{"/test"}, ""}, | ||||
| 		{[]string{"/test/a"}, "/test/a"}, | ||||
| 		{[]string{"GET /test/a"}, ""}, | ||||
| 		{[]string{"POST /test/a"}, "POST /test/a"}, | ||||
| 		{[]string{"/test/a/b/c"}, "/test/a/"}, | ||||
| 		{[]string{"GET /test/a/b/c"}, ""}, | ||||
| 		{[]string{"POST /test/a/b/c"}, "POST /test/a/"}, | ||||
| 		{[]string{"/test/a", "abc"}, "/test/a"}, // priority checks | ||||
| 		{[]string{}, []string{}, ""}, | ||||
| 		{[]string{"missing"}, []string{}, ""}, | ||||
| 		{[]string{"abc"}, []string{}, "abc"}, | ||||
| 		{[]string{"abc"}, []string{core.RateLimitRuleAudienceGuest}, ""}, | ||||
| 		{[]string{"abc"}, []string{core.RateLimitRuleAudienceAuth}, ""}, | ||||
| 		{[]string{"def"}, []string{core.RateLimitRuleAudienceGuest}, "def"}, | ||||
| 		{[]string{"def"}, []string{core.RateLimitRuleAudienceAuth}, ""}, | ||||
| 		{[]string{"/test"}, []string{}, ""}, | ||||
| 		{[]string{"/test/a"}, []string{}, "/test/a"}, | ||||
| 		{[]string{"/test/a"}, []string{core.RateLimitRuleAudienceAuth}, "/test/a/"}, | ||||
| 		{[]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 { | ||||
| 		t.Run(strings.Join(s.labels, ""), func(t *testing.T) { | ||||
| 			rule, ok := limits.FindRateLimitRule(s.labels) | ||||
| 		t.Run(strings.Join(s.labels, "_")+":"+strings.Join(s.audience, "_"), func(t *testing.T) { | ||||
| 			rule, ok := limits.FindRateLimitRule(s.labels, s.audience...) | ||||
|  | ||||
| 			hasLabel := rule.Label != "" | ||||
| 			if hasLabel != ok { | ||||
|   | ||||
| @@ -39,7 +39,7 @@ | ||||
|  | ||||
|     <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} /> | ||||
|         <label for={uniqueId}>Enable</label> | ||||
|         <label for={uniqueId}>Enable <small class="txt-hint">(experimental)</small></label> | ||||
|     </Field> | ||||
|  | ||||
|     <div class="grid"> | ||||
|   | ||||
| @@ -150,7 +150,7 @@ | ||||
|  | ||||
|     <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} /> | ||||
|         <label for={uniqueId}>Enable</label> | ||||
|         <label for={uniqueId}>Enable <small class="txt-hint">(experimental)</small></label> | ||||
|     </Field> | ||||
|  | ||||
|     {#if !CommonHelper.isEmpty(formSettings.rateLimits.rules)} | ||||
| @@ -263,6 +263,22 @@ | ||||
|         <h4 class="center txt-break">Rate limit label format</h4> | ||||
|     </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> | ||||
|     <ul> | ||||
|         <li class="m-b-sm"> | ||||
|   | ||||
		Reference in New Issue
	
	Block a user