Skip to content

Commit a458752

Browse files
committed
added decoding and normalizing urlencoded chars when registering endpoints. Disabled decoding urlencoded slash
1 parent 4b6ff4f commit a458752

File tree

7 files changed

+140
-51
lines changed

7 files changed

+140
-51
lines changed

internal/strutil/url.go

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
package strutil
2+
3+
import (
4+
"strings"
5+
6+
"github.com/indigo-web/indigo/internal/hexconv"
7+
)
8+
9+
// IsURLUnsafeChar tells whether it's safe to decode an urlencoded character.
10+
func IsURLUnsafeChar(c byte) bool {
11+
return c == '/'
12+
}
13+
14+
// URLDecode decodes an urlencoded string and tells whether the string was properly formed.
15+
func URLDecode(str string) (string, bool) {
16+
var b strings.Builder
17+
b.Grow(len(str))
18+
s := str
19+
20+
for len(s) > 0 {
21+
percent := strings.IndexByte(s, '%')
22+
if percent == -1 {
23+
break
24+
}
25+
26+
b.WriteString(s[:percent])
27+
s = s[percent+1:]
28+
if len(s) < 2 {
29+
return "", false
30+
}
31+
32+
c1, c2 := s[0], s[1]
33+
s = s[2:]
34+
x, y := hexconv.Halfbyte[c1], hexconv.Halfbyte[c2]
35+
if x|y == 0xFF {
36+
return "", false
37+
}
38+
39+
char := (x << 4) | y
40+
if IsASCIINonprintable(char) {
41+
return "", false
42+
}
43+
if IsURLUnsafeChar(char) {
44+
b.Write([]byte{'%', c1 | 0x20, c2 | 0x20})
45+
continue
46+
}
47+
48+
b.WriteByte(char)
49+
}
50+
51+
b.WriteString(s)
52+
53+
return b.String(), true
54+
}

internal/strutil/url_test.go

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
package strutil
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/require"
7+
)
8+
9+
func TestURLDecode(t *testing.T) {
10+
t.Run("base", func(t *testing.T) {
11+
res, ok := URLDecode("%61")
12+
require.True(t, ok)
13+
require.Equal(t, "a", res)
14+
15+
for i, tc := range []string{"abc", "%61bc", "a%62c", "ab%63", "%61%62%63"} {
16+
res, ok = URLDecode(tc)
17+
require.True(t, ok, i)
18+
require.Equal(t, "abc", res, i)
19+
}
20+
})
21+
22+
t.Run("unsafe char", func(t *testing.T) {
23+
res, ok := URLDecode("%61%2f")
24+
require.True(t, ok)
25+
require.Equal(t, "a%2f", res)
26+
})
27+
28+
t.Run("unsafe normalization", func(t *testing.T) {
29+
res, ok := URLDecode("%61%2F")
30+
require.True(t, ok)
31+
require.Equal(t, "a%2f", res)
32+
})
33+
}

router/inbuilt/inbuilt_test.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,38 @@ func TestRoute(t *testing.T) {
141141
t.Run("server-wide", testOPTIONS("*", "GET, HEAD, OPTIONS", false))
142142
t.Run("server-wide with TRACE", testOPTIONS("*", "GET, HEAD, OPTIONS, TRACE", true))
143143
})
144+
145+
t.Run("escaping", func(t *testing.T) {
146+
testRoute := func(r router.Router, path string) func(t *testing.T) {
147+
return func(t *testing.T) {
148+
request := getRequest(method.GET, path)
149+
resp := r.OnRequest(request)
150+
require.Equal(t, 200, int(resp.Expose().Code))
151+
}
152+
}
153+
154+
newRouter := func(dynamic bool) router.Router {
155+
r := New().
156+
Get("/foo%2fbar", http.Respond).
157+
Get("/foo%3abar", http.Respond)
158+
159+
if dynamic {
160+
r.Get("/unreachable route/:", http.Respond)
161+
}
162+
163+
return r.Build()
164+
}
165+
166+
test := func(r router.Router) func(t *testing.T) {
167+
return func(t *testing.T) {
168+
t.Run("escaped slash", testRoute(r, "/foo%2fbar"))
169+
t.Run("unescaped colon", testRoute(r, "/foo:bar"))
170+
}
171+
}
172+
173+
t.Run("static", test(newRouter(false)))
174+
t.Run("dynamic", test(newRouter(true)))
175+
})
144176
}
145177

146178
func TestDynamic(t *testing.T) {

router/inbuilt/internal/radix/tree.go

Lines changed: 10 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,12 @@ package radix
33
import (
44
"cmp"
55
"errors"
6+
"fmt"
67
"slices"
8+
"strconv"
79
"strings"
810

11+
"github.com/indigo-web/indigo/internal/strutil"
912
"github.com/indigo-web/indigo/kv"
1013
)
1114

@@ -130,7 +133,12 @@ func addWildcard(wildcard, value string, into *kv.Storage) {
130133
}
131134

132135
func (n *Node[T]) Insert(key string, value T) error {
133-
return n.insert(splitPath(key), value)
136+
str, ok := strutil.URLDecode(key)
137+
if !ok {
138+
return fmt.Errorf("poorly encoded path: %s", strconv.Quote(key))
139+
}
140+
141+
return n.insert(splitPath(str), value)
134142
}
135143

136144
func (n *Node[T]) insert(segs []pathSegment, value T) error {
@@ -239,24 +247,13 @@ type pathSegment struct {
239247
}
240248

241249
func splitPath(str string) (path []pathSegment) {
242-
offset := 0
243-
244250
for len(str) > 0 {
245-
colon := strings.IndexByte(str[offset:], ':')
251+
colon := strings.IndexByte(str, ':')
246252
if colon == -1 {
247253
path = append(path, pathSegment{false, false, str})
248254
break
249255
}
250256

251-
colon += offset
252-
253-
if colon > 0 && str[colon-1] == '\\' {
254-
// escaped colon: isn't a wildcard, skip
255-
str = str[:colon-1] + str[colon:]
256-
offset = colon
257-
continue
258-
}
259-
260257
path = append(path, pathSegment{false, false, str[:colon]})
261258
str = str[colon+1:]
262259

router/inbuilt/internal/radix/tree_test.go

Lines changed: 0 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -228,40 +228,6 @@ func TestTree(t *testing.T) {
228228
test(t, tree, "/prefix42", 1, "path", "42")
229229
test(t, tree, "/prefixnowhere/like/this", 1, "path", "nowhere/like/this")
230230
})
231-
232-
t.Run("escape wildcard", func(t *testing.T) {
233-
t.Run("static leftmost", func(t *testing.T) {
234-
tree := New[int]()
235-
require.NoError(t, tree.Insert("\\:hello/\\:world", 1))
236-
value, found := tree.Lookup(":hello/:world", nil)
237-
require.True(t, found)
238-
require.Equal(t, 1, value)
239-
})
240-
241-
t.Run("static rightmost", func(t *testing.T) {
242-
tree := New[int]()
243-
require.NoError(t, tree.Insert("/\\:hello", 1))
244-
value, found := tree.Lookup("/:hello", nil)
245-
require.True(t, found)
246-
require.Equal(t, 1, value)
247-
})
248-
249-
t.Run("dynamic", func(t *testing.T) {
250-
tree := New[int]()
251-
require.NoError(t, tree.Insert("/\\:hello/:name", 1))
252-
wildcards := kv.New()
253-
value, found := tree.Lookup("/:hello/Pavlo", wildcards)
254-
require.True(t, found)
255-
require.Equal(t, 1, value)
256-
require.Equal(t, "Pavlo", wildcards.Value("name"))
257-
})
258-
})
259-
260-
t.Run("path classifier", func(t *testing.T) {
261-
require.False(t, IsDynamicTemplate("\\:hello/\\:world"))
262-
require.False(t, IsDynamicTemplate("/\\:hello"))
263-
require.True(t, IsDynamicTemplate("/\\:hello/:name"))
264-
})
265231
}
266232

267233
// isn't used anymore. Left just in case the tree needs to be debugged.

router/inbuilt/types.go

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
package inbuilt
22

33
import (
4+
"fmt"
5+
"strconv"
46
"strings"
57

68
"github.com/indigo-web/indigo/http"
79
"github.com/indigo-web/indigo/http/method"
810
"github.com/indigo-web/indigo/http/status"
11+
"github.com/indigo-web/indigo/internal/strutil"
912
"github.com/indigo-web/indigo/router/inbuilt/internal/radix"
1013
)
1114

@@ -26,10 +29,15 @@ type (
2629
)
2730

2831
func (r routesMap) Add(path string, m method.Method, handler Handler) {
29-
entry := r[path]
32+
p, ok := strutil.URLDecode(path)
33+
if !ok {
34+
panic(fmt.Errorf("poorly encoded path: %s", strconv.Quote(path)))
35+
}
36+
37+
entry := r[p]
3038
entry.methods[m] = handler
3139
entry.allow = getAllowString(entry.methods)
32-
r[path] = entry
40+
r[p] = entry
3341
}
3442

3543
func getAllowString(methods methodLUT) (allowed string) {

router/inbuilt/uri/normalize.go

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
package uri
22

3-
// Normalize removes trailing slashes, as all request paths are also trimmed, resulting
4-
// in consensus between these two.
3+
// Normalize eliminates a trailing slash if presented.
54
func Normalize(path string) string {
65
if len(path) > 1 && path[len(path)-1] == '/' {
76
return path[:len(path)-1]

0 commit comments

Comments
 (0)