Skip to content

Commit d8acfea

Browse files
kkk777-7rudrakhp
authored andcommitted
feat: support separated path match in ratelimit path (#7413)
* update: path match ratelimit e2e Signed-off-by: kkk777-7 <[email protected]>
1 parent 54a10c9 commit d8acfea

File tree

6 files changed

+210
-5
lines changed

6 files changed

+210
-5
lines changed

internal/gatewayapi/backendtrafficpolicy.go

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1246,9 +1246,18 @@ func buildRateLimitRule(rule egv1a1.RateLimitRule) (*ir.RateLimitRule, error) {
12461246
if match.Path != nil {
12471247
switch ptr.Deref(match.Path.Type, gwapiv1.PathMatchPathPrefix) {
12481248
case gwapiv1.PathMatchPathPrefix:
1249-
irRule.PathMatch = &ir.StringMatch{
1250-
Prefix: ptr.To(match.Path.Value),
1251-
Invert: match.Path.Invert,
1249+
if match.Path.Value == "/" {
1250+
irRule.PathMatch = &ir.StringMatch{
1251+
Prefix: ptr.To(match.Path.Value),
1252+
Invert: match.Path.Invert,
1253+
}
1254+
} else {
1255+
// envoy ratelimit HeaderMatcher doesn't support PathSeparatedPrefix like route matching,
1256+
// so we use regex to achieve the same path-separated prefix behavior.
1257+
irRule.PathMatch = &ir.StringMatch{
1258+
SafeRegex: ptr.To(regex.PathSeparatedPrefixRegex(match.Path.Value)),
1259+
Invert: match.Path.Invert,
1260+
}
12521261
}
12531262
case gwapiv1.PathMatchExact:
12541263
irRule.PathMatch = &ir.StringMatch{

internal/gatewayapi/testdata/backendtrafficpolicy-with-ratelimit.in.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,9 @@ backendTrafficPolicies:
114114
- name: x-org-id
115115
value: admin
116116
invert: true
117+
path:
118+
type: PathPrefix
119+
value: "/user"
117120
limit:
118121
requests: 10
119122
unit: Hour
@@ -135,6 +138,9 @@ backendTrafficPolicies:
135138
- sourceCIDR:
136139
type: "Distinct"
137140
value: 192.168.0.0/16
141+
path:
142+
type: PathPrefix
143+
value: "/"
138144
limit:
139145
requests: 20
140146
unit: Hour

internal/gatewayapi/testdata/backendtrafficpolicy-with-ratelimit.out.yaml

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,10 @@ backendTrafficPolicies:
99
global:
1010
rules:
1111
- clientSelectors:
12-
- sourceCIDR:
12+
- path:
13+
type: PathPrefix
14+
value: /
15+
sourceCIDR:
1316
type: Distinct
1417
value: 192.168.0.0/16
1518
cost:
@@ -101,6 +104,9 @@ backendTrafficPolicies:
101104
- invert: true
102105
name: x-org-id
103106
value: admin
107+
path:
108+
type: PathPrefix
109+
value: /user
104110
limit:
105111
requests: 10
106112
unit: Hour
@@ -490,6 +496,10 @@ xdsIR:
490496
requests: 10
491497
unit: Hour
492498
name: envoy-gateway/policy-for-gateway/rule/0
499+
pathMatch:
500+
distinct: false
501+
name: ""
502+
safeRegex: ^/user(/.*|\?.*|#.*|;.*|$)
493503
readyListener:
494504
address: 0.0.0.0
495505
ipFamily: IPv4
@@ -583,6 +593,10 @@ xdsIR:
583593
requests: 20
584594
unit: Hour
585595
name: default/policy-for-route/rule/0
596+
pathMatch:
597+
distinct: false
598+
name: ""
599+
prefix: /
586600
requestCost:
587601
number: 1
588602
responseCost:

internal/utils/regex/regex.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ package regex
88
import (
99
"fmt"
1010
"regexp"
11+
"strings"
1112
)
1213

1314
// Validate validates a regex string.
@@ -17,3 +18,19 @@ func Validate(regex string) error {
1718
}
1819
return nil
1920
}
21+
22+
// PathSeparatedPrefixRegex creates a regex pattern that Envoy's PathSeparatedPrefix behavior.
23+
// The pattern matches paths that either exactly match the prefix or have the prefix followed by "/".
24+
// This ensures proper path separation (e.g., "/api" matches "/api" and "/api/v1" but not "/apiv1").
25+
//
26+
// References:
27+
// - Envoy 'path_separated_prefix' : https://www.envoyproxy.io/docs/envoy/latest/api-v3/config/route/v3/route_components.proto#config-route-v3-routematch
28+
func PathSeparatedPrefixRegex(prefix string) string {
29+
// Remove trailing slash
30+
trimmedPrefix := strings.TrimSuffix(prefix, "/")
31+
32+
// Escape special regex characters in the prefix
33+
escapedPrefix := regexp.QuoteMeta(trimmedPrefix)
34+
35+
return "^" + escapedPrefix + "(/.*|\\?.*|#.*|;.*|$)"
36+
}

internal/utils/regex/regex_test.go

Lines changed: 130 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,10 @@
55

66
package regex
77

8-
import "testing"
8+
import (
9+
"regexp"
10+
"testing"
11+
)
912

1013
func TestValidate(t *testing.T) {
1114
tests := []struct {
@@ -32,3 +35,129 @@ func TestValidate(t *testing.T) {
3235
})
3336
}
3437
}
38+
39+
func TestPathSeparatedPrefixRegex(t *testing.T) {
40+
tests := []struct {
41+
name string
42+
prefix string
43+
testPath string
44+
want bool
45+
}{
46+
// Tests for prefix "/api/v1" - what should match
47+
{
48+
name: "exact match",
49+
prefix: "/api/v1",
50+
testPath: "/api/v1",
51+
want: true,
52+
},
53+
{
54+
name: "with trailing slash",
55+
prefix: "/api/v1",
56+
testPath: "/api/v1/",
57+
want: true,
58+
},
59+
{
60+
name: "with sub-path",
61+
prefix: "/api/v1",
62+
testPath: "/api/v1/users",
63+
want: true,
64+
},
65+
{
66+
name: "with deep sub-path",
67+
prefix: "/api/v1",
68+
testPath: "/api/v1/users/123/profile",
69+
want: true,
70+
},
71+
{
72+
name: "with query params",
73+
prefix: "/api/v1",
74+
testPath: "/api/v1?version=latest",
75+
want: true,
76+
},
77+
{
78+
name: "with complex query",
79+
prefix: "/api/v1",
80+
testPath: "/api/v1?param1=value1&param2=value2",
81+
want: true,
82+
},
83+
{
84+
name: "with fragment",
85+
prefix: "/api/v1",
86+
testPath: "/api/v1#section",
87+
want: true,
88+
},
89+
{
90+
name: "with semicolon parameter",
91+
prefix: "/api/v1",
92+
testPath: "/api/v1;sessionid=123",
93+
want: true,
94+
},
95+
{
96+
name: "with semicolon and sub-path",
97+
prefix: "/api/v1",
98+
testPath: "/api/v1;sessionid=123/profile",
99+
want: true,
100+
},
101+
102+
// Tests for prefix "/api/v1" - what should NOT match
103+
{
104+
name: "alphanumeric continuation",
105+
prefix: "/api/v1",
106+
testPath: "/api/v1abc",
107+
want: false,
108+
},
109+
{
110+
name: "underscore continuation",
111+
prefix: "/api/v1",
112+
testPath: "/api/v1_test",
113+
want: false,
114+
},
115+
{
116+
name: "dash continuation",
117+
prefix: "/api/v1",
118+
testPath: "/api/v1-beta",
119+
want: false,
120+
},
121+
{
122+
name: "dot continuation",
123+
prefix: "/api/v1",
124+
testPath: "/api/v1.1",
125+
want: false,
126+
},
127+
{
128+
name: "different path completely",
129+
prefix: "/api/v1",
130+
testPath: "/api/v2",
131+
want: false,
132+
},
133+
{
134+
name: "prefix longer than path",
135+
prefix: "/api/v1",
136+
testPath: "/api",
137+
want: false,
138+
},
139+
{
140+
name: "similar but different",
141+
prefix: "/api/v1",
142+
testPath: "/api/v10",
143+
want: false,
144+
},
145+
}
146+
147+
for _, tt := range tests {
148+
t.Run(tt.name, func(t *testing.T) {
149+
pattern := PathSeparatedPrefixRegex(tt.prefix)
150+
151+
regex, err := regexp.Compile(pattern)
152+
if err != nil {
153+
t.Fatalf("Failed to compile regex pattern %q: %v", pattern, err)
154+
}
155+
156+
got := regex.MatchString(tt.testPath)
157+
if got != tt.want {
158+
t.Errorf("PathSeparatedPrefixRegex(%q).MatchString(%q) = %v, want %v (pattern: %q)",
159+
tt.prefix, tt.testPath, got, tt.want, pattern)
160+
}
161+
})
162+
}
163+
}

test/e2e/tests/ratelimit.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -258,6 +258,36 @@ var RateLimitPathMatchTest = suite.ConformanceTest{
258258
if err := GotExactExpectedResponse(t, 1, suite.RoundTripper, expectLimitReq, expectLimitResp); err != nil {
259259
t.Errorf("failed to get expected response for the last (fourth) request: %v", err)
260260
}
261+
262+
// Subpath should be rate limited due to prefix matching.
263+
expectLimitResp = http.ExpectedResponse{
264+
Request: http.Request{
265+
Path: "/get/specific-path/subpath",
266+
},
267+
Response: http.Response{
268+
StatusCode: 429,
269+
},
270+
Namespace: ns,
271+
}
272+
expectLimitReq = http.MakeRequest(t, &expectLimitResp, gwAddr, "HTTP", "http")
273+
if err := GotExactExpectedResponse(t, 1, suite.RoundTripper, expectLimitReq, expectLimitResp); err != nil {
274+
t.Errorf("failed to get expected response for the last (fourth) request: %v", err)
275+
}
276+
277+
// Different path (contains the path prefix) should not be rate limited.
278+
expectOkResp = http.ExpectedResponse{
279+
Request: http.Request{
280+
Path: "/get/specific-path2",
281+
},
282+
Response: http.Response{
283+
StatusCode: 200,
284+
},
285+
Namespace: ns,
286+
}
287+
expectOkReq = http.MakeRequest(t, &expectOkResp, gwAddr, "HTTP", "http")
288+
if err := GotExactExpectedResponse(t, 1, suite.RoundTripper, expectOkReq, expectOkResp); err != nil {
289+
t.Errorf("failed to get expected response for the last (fourth) request: %v", err)
290+
}
261291
})
262292

263293
t.Run("not matched path cannot got limited", func(t *testing.T) {

0 commit comments

Comments
 (0)