Skip to content

Commit 70ae18c

Browse files
authored
Fix regexp param anchoring (#105)
* fix: anchor user regexp with non-capturing group to bound alternation * refactor!(matcher): rename Regex to Value and return raw source string * refactor(simplelru): use range over int in Resize loop * docs(matcher): clarify Value godoc on regexp matchers
1 parent 0a61749 commit 70ae18c

7 files changed

Lines changed: 96 additions & 17 deletions

File tree

fox_test.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1546,6 +1546,38 @@ func TestWildcardSuffix(t *testing.T) {
15461546
}
15471547
}
15481548

1549+
func TestRegexpParamAlternationPrecedence(t *testing.T) {
1550+
r := MustRouter(AllowRegexpParam(true))
1551+
require.NoError(t, onlyError(r.Add(MethodGet, "/role/{role:admin|user|guest}", pathHandler)))
1552+
require.NoError(t, onlyError(r.Add(MethodGet, "/scope/{scope:read|write}/items", pathHandler)))
1553+
1554+
cases := []struct {
1555+
path string
1556+
want int
1557+
}{
1558+
{"/role/admin", http.StatusOK},
1559+
{"/role/user", http.StatusOK},
1560+
{"/role/guest", http.StatusOK},
1561+
{"/role/adminBYPASS", http.StatusNotFound},
1562+
{"/role/EVILguest", http.StatusNotFound},
1563+
{"/role/userX", http.StatusNotFound},
1564+
{"/role/Xuser", http.StatusNotFound},
1565+
{"/scope/read/items", http.StatusOK},
1566+
{"/scope/write/items", http.StatusOK},
1567+
{"/scope/readBYPASS/items", http.StatusNotFound},
1568+
{"/scope/EVILwrite/items", http.StatusNotFound},
1569+
}
1570+
1571+
for _, tc := range cases {
1572+
t.Run(tc.path, func(t *testing.T) {
1573+
req := httptest.NewRequest(http.MethodGet, tc.path, nil)
1574+
w := httptest.NewRecorder()
1575+
r.ServeHTTP(w, req)
1576+
assert.Equal(t, tc.want, w.Code)
1577+
})
1578+
}
1579+
}
1580+
15491581
func TestInsertUpdateAndDeleteWithHostname(t *testing.T) {
15501582
cases := []struct {
15511583
name string

internal/simplelru/lru.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,7 @@ func (c *LRU[K, V]) Cap() int {
154154
// Resize changes the cache size.
155155
func (c *LRU[K, V]) Resize(size int) (evicted int) {
156156
diff := max(c.Len()-size, 0)
157-
for i := 0; i < diff; i++ {
157+
for range diff {
158158
c.removeOldest()
159159
}
160160
c.size = size

matcher.go

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ func MatchQueryRegexp(key, expr string) (QueryRegexpMatcher, error) {
8585
if key == "" {
8686
return QueryRegexpMatcher{}, errors.New("empty query key")
8787
}
88-
regex, err := regexp.Compile("^" + expr + "$")
88+
regex, err := regexp.Compile("^(?:" + expr + ")$")
8989
if err != nil {
9090
return QueryRegexpMatcher{}, err
9191
}
@@ -108,14 +108,15 @@ func (m QueryRegexpMatcher) Key() string {
108108
return m.key
109109
}
110110

111-
// Regex returns a copy of the compiled regular expression used to evaluate query values.
112-
func (m QueryRegexpMatcher) Regex() *regexp.Regexp {
113-
return new(*m.regex)
111+
// Value returns the regular expression matching the query parameter.
112+
func (m QueryRegexpMatcher) Value() string {
113+
expr := m.regex.String()
114+
return expr[4 : len(expr)-2]
114115
}
115116

116117
// String returns a textual representation of the matcher in the form "qx:key=expr".
117118
func (m QueryRegexpMatcher) String() string {
118-
return "qx:" + m.key + "=" + m.regex.String()
119+
return "qx:" + m.key + "=" + m.Value()
119120
}
120121

121122
// Match reports whether the request URL contains the configured key with any value matching the configured regular
@@ -198,7 +199,7 @@ func MatchHeaderRegexp(key, expr string) (HeaderRegexpMatcher, error) {
198199
if key == "" {
199200
return HeaderRegexpMatcher{}, errors.New("empty header key")
200201
}
201-
regex, err := regexp.Compile("^" + expr + "$")
202+
regex, err := regexp.Compile("^(?:" + expr + ")$")
202203
if err != nil {
203204
return HeaderRegexpMatcher{}, err
204205
}
@@ -221,9 +222,10 @@ func (m HeaderRegexpMatcher) Key() string {
221222
return m.canonicalKey
222223
}
223224

224-
// Regex returns a copy of the compiled regular expression used to evaluate header values.
225-
func (m HeaderRegexpMatcher) Regex() *regexp.Regexp {
226-
return new(*m.regex)
225+
// Value returns the regular expression matching the header.
226+
func (m HeaderRegexpMatcher) Value() string {
227+
expr := m.regex.String()
228+
return expr[4 : len(expr)-2]
227229
}
228230

229231
// Match reports whether the request contains the configured header with any value matching the configured regular
@@ -247,7 +249,7 @@ func (m HeaderRegexpMatcher) Equal(matcher Matcher) bool {
247249

248250
// String returns a textual representation of the matcher in the form "hx:key=expr".
249251
func (m HeaderRegexpMatcher) String() string {
250-
return "hx:" + m.canonicalKey + "=" + m.regex.String()
252+
return "hx:" + m.canonicalKey + "=" + m.Value()
251253
}
252254

253255
// MatchClientIP returns a [ClientIPMatcher] that matches when the resolved client IP belongs to the given CIDR range.

matcher_test.go

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"testing"
1010

1111
"github.com/stretchr/testify/assert"
12+
"github.com/stretchr/testify/require"
1213
)
1314

1415
func TestQueryMatcher_Match(t *testing.T) {
@@ -622,7 +623,7 @@ func TestMatchQueryRegexp(t *testing.T) {
622623
}
623624
assert.NoError(t, err)
624625
assert.Equal(t, tc.wantKey, m.Key())
625-
assert.NotNil(t, m.Regex())
626+
assert.Equal(t, tc.expr, m.Value())
626627
})
627628
}
628629
}
@@ -727,7 +728,50 @@ func TestMatchHeaderRegexp(t *testing.T) {
727728
}
728729
assert.NoError(t, err)
729730
assert.Equal(t, tc.wantKey, m.Key())
730-
assert.NotNil(t, m.Regex())
731+
assert.Equal(t, tc.expr, m.Value())
732+
})
733+
}
734+
}
735+
736+
func TestRegexpMatcherAlternationPrecedence(t *testing.T) {
737+
mq, err := MatchQueryRegexp("scope", "read|write")
738+
require.NoError(t, err)
739+
mh, err := MatchHeaderRegexp("X-Role", "admin|user|guest")
740+
require.NoError(t, err)
741+
742+
queryCases := []struct {
743+
url string
744+
want bool
745+
}{
746+
{"/?scope=read", true},
747+
{"/?scope=write", true},
748+
{"/?scope=readBYPASS", false},
749+
{"/?scope=EVILwrite", false},
750+
}
751+
for _, tc := range queryCases {
752+
t.Run("query "+tc.url, func(t *testing.T) {
753+
req := httptest.NewRequest(http.MethodGet, tc.url, nil)
754+
c := NewTestContextOnly(httptest.NewRecorder(), req)
755+
assert.Equal(t, tc.want, mq.Match(c))
756+
})
757+
}
758+
759+
headerCases := []struct {
760+
value string
761+
want bool
762+
}{
763+
{"admin", true},
764+
{"user", true},
765+
{"guest", true},
766+
{"adminBYPASS", false},
767+
{"EVILguest", false},
768+
}
769+
for _, tc := range headerCases {
770+
t.Run("header "+tc.value, func(t *testing.T) {
771+
req := httptest.NewRequest(http.MethodGet, "/", nil)
772+
req.Header.Set("X-Role", tc.value)
773+
c := NewTestContextOnly(httptest.NewRecorder(), req)
774+
assert.Equal(t, tc.want, mh.Match(c))
731775
})
732776
}
733777
}

parser.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -391,7 +391,7 @@ func (fox *Router) compileParamRegexp(rawRegex string) (*regexp.Regexp, *Pattern
391391
return nil, newPatternError("regexp", 0, 0, "missing expression")
392392
}
393393

394-
re, err := regexp.Compile("^" + rawRegex + "$")
394+
re, err := regexp.Compile("^(?:" + rawRegex + ")$")
395395
if err != nil {
396396
return nil, &PatternError{
397397
Reason: "regexp",

parser_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ func Test_parsePattern(t *testing.T) {
3030
typ: nodeParam,
3131
}
3232
if reg != "" {
33-
tk.regexp = regexp.MustCompile("^" + reg + "$")
33+
tk.regexp = regexp.MustCompile("^(?:" + reg + ")$")
3434
}
3535
return tk
3636
}
@@ -41,7 +41,7 @@ func Test_parsePattern(t *testing.T) {
4141
typ: nodeWildcard,
4242
}
4343
if reg != "" {
44-
tk.regexp = regexp.MustCompile("^" + reg + "$")
44+
tk.regexp = regexp.MustCompile("^(?:" + reg + ")$")
4545
}
4646
return tk
4747
}

tree.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -953,8 +953,9 @@ func concat(a, b string) string {
953953
// placeholder ("?" for params, "*" for catch-alls).
954954
func canonicalKey(tk token) string {
955955
if tk.regexp != nil {
956+
// Strip the surrounding ^(?: and )$ added by compileParamRegexp.
956957
expr := tk.regexp.String()
957-
return expr[1 : len(expr)-1]
958+
return expr[4 : len(expr)-2]
958959
}
959960
switch tk.typ {
960961
case nodeParam:

0 commit comments

Comments
 (0)