Skip to content

Commit b2bd007

Browse files
jackstinesoloio-bulldozer[bot]Nathan Fudenberg
authored
v1.11.x-route-validation (#7618)
* Route Validation (#7595) * inital commit * ReWrites added * fix issue with Focus * adding changelog * update * code gen update * allow "/" * update validation of path * updated route reporting with route validation * clean up code * moving some code around, and cleanning up some things * clean up * added a test * set the normalized path to true * Update changelog/v1.14.0-beta4/validate-routes.yaml Co-authored-by: Nathan Fudenberg <[email protected]> * Update projects/gloo/pkg/translator/route_config.go Co-authored-by: Nathan Fudenberg <[email protected]> * correct nits * Update projects/gloo/pkg/translator/route_config.go Co-authored-by: Nathan Fudenberg <[email protected]> * comments * global regex * comments on hcm * add full list of tests to validate route paths * update changelog Co-authored-by: soloio-bulldozer[bot] <48420018+soloio-bulldozer[bot]@users.noreply.github.com> Co-authored-by: Nathan Fudenberg <[email protected]> * fix * update generated code Co-authored-by: soloio-bulldozer[bot] <48420018+soloio-bulldozer[bot]@users.noreply.github.com> Co-authored-by: Nathan Fudenberg <[email protected]>
1 parent 23f2935 commit b2bd007

File tree

9 files changed

+287
-6
lines changed

9 files changed

+287
-6
lines changed
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
changelog:
2+
- type: FIX
3+
description: |
4+
Add route validation to reject and sanitize invalid paths.
5+
Set the normalized_path attribute for the [Envoy HTTP Connection Manager](https://www.envoyproxy.io/docs/envoy/latest/api-v3/extensions/filters/network/http_connection_manager/v3/http_connection_manager.proto.html?highlight=hcm) to true as the default value.
6+
issueLink: https://github.com/solo-io/solo-projects/issues/4336
7+
resolvesIssue: false

docs/content/reference/api/github.com/solo-io/gloo/projects/gloo/api/v1/options/hcm/hcm.proto.sk.md

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

projects/gloo/api/v1/options/hcm/hcm.proto

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -194,7 +194,7 @@ message HttpConnectionManagerSettings {
194194
// See here for more information: https://www.envoyproxy.io/docs/envoy/latest/api-v3/extensions/filters/network/http_connection_manager/v3/http_connection_manager.proto
195195
bool merge_slashes = 29;
196196

197-
// Should paths be normalized according to RFC 3986 before any processing of requests by HTTP filters or routing?
197+
// Should paths be normalized according to RFC 3986 before any processing of requests by HTTP filters or routing? Defaults to True.
198198
// See here for more information: https://www.envoyproxy.io/docs/envoy/latest/api-v3/extensions/filters/network/http_connection_manager/v3/http_connection_manager.proto
199199
google.protobuf.BoolValue normalize_path = 30;
200200

projects/gloo/pkg/api/v1/options/hcm/hcm.pb.go

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

projects/gloo/pkg/plugins/hcm/plugin.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import (
1515
"github.com/solo-io/gloo/projects/gloo/pkg/utils"
1616
"github.com/solo-io/go-utils/contextutils"
1717
"google.golang.org/protobuf/types/known/anypb"
18+
"google.golang.org/protobuf/types/known/wrapperspb"
1819
)
1920

2021
var (
@@ -61,7 +62,12 @@ func (p *plugin) ProcessHcmNetworkFilter(params plugins.Params, _ *v1.Listener,
6162
out.PathWithEscapedSlashesAction = envoyhttp.HttpConnectionManager_PathWithEscapedSlashesAction(in.GetPathWithEscapedSlashesAction())
6263
out.CodecType = envoyhttp.HttpConnectionManager_CodecType(in.GetCodecType())
6364
out.MergeSlashes = in.GetMergeSlashes()
64-
out.NormalizePath = in.GetNormalizePath()
65+
// default to true as per RFC 3986
66+
if in.GetNormalizePath() == nil {
67+
out.NormalizePath = &wrapperspb.BoolValue{Value: true}
68+
} else {
69+
out.NormalizePath = in.GetNormalizePath()
70+
}
6571

6672
if in.GetAcceptHttp_10() {
6773
out.HttpProtocolOptions = &envoycore.Http1ProtocolOptions{

projects/gloo/pkg/plugins/hcm/plugin_test.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"time"
1111

1212
v1 "github.com/solo-io/gloo/projects/gloo/pkg/api/v1"
13+
"google.golang.org/protobuf/types/known/wrapperspb"
1314

1415
envoycore "github.com/envoyproxy/go-control-plane/envoy/config/core/v3"
1516
envoyhttp "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/network/http_connection_manager/v3"
@@ -77,6 +78,7 @@ var _ = Describe("Plugin", func() {
7778
DrainTimeout: prototime.DurationToProto(time.Hour),
7879
DelayedCloseTimeout: prototime.DurationToProto(time.Hour),
7980
ServerName: "ServerName",
81+
NormalizePath: &wrapperspb.BoolValue{Value: false},
8082

8183
AcceptHttp_10: true,
8284
HeaderFormat: &hcm.HttpConnectionManagerSettings_ProperCaseHeaderKeyFormat{
@@ -222,6 +224,17 @@ var _ = Describe("Plugin", func() {
222224
Expect(cfg.GetServerHeaderTransformation()).To(Equal(envoyhttp.HttpConnectionManager_PASS_THROUGH))
223225
})
224226

227+
Context("Default Values", func() {
228+
It("should contain the default value for NormalizePath", func() {
229+
T := &wrapperspb.BoolValue{Value: true}
230+
settings = &hcm.HttpConnectionManagerSettings{}
231+
cfg := &envoyhttp.HttpConnectionManager{}
232+
err := processHcmNetworkFilter(cfg)
233+
Expect(err).NotTo(HaveOccurred())
234+
Expect(cfg.NormalizePath).To(Equal(T))
235+
})
236+
})
237+
225238
Context("Http2 passed", func() {
226239
//validation uses the same shared code so no need to validate large and small for all fields
227240
It(" should not accept connection streams that are too small", func() {

projects/gloo/pkg/translator/route_config.go

Lines changed: 76 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package translator
33
import (
44
"context"
55
"fmt"
6+
"regexp"
67
"strings"
78
"unicode"
89

@@ -30,9 +31,28 @@ import (
3031
)
3132

3233
var (
33-
NoDestinationSpecifiedError = errors.New("must specify at least one weighted destination for multi destination routes")
34+
// invalidPathSequences are path sequences that should not be contained in a path
35+
invalidPathSequences = []string{"//", "/./", "/../", "%2f", "%2F", "#"}
36+
// invalidPathSuffixes are path suffixes that should not be at the end of a path
37+
invalidPathSuffixes = []string{"/..", "/."}
38+
// validCharacterRegex pattern based off RFC 3986 similar to kubernetes-sigs/gateway-api implementation
39+
// for finding "pchar" characters = unreserved / pct-encoded / sub-delims / ":" / "@"
40+
validPathRegexCharacters = "^(?:([A-Za-z0-9/:@._~!$&'()*+,:=;-]*|[%][0-9a-fA-F]{2}))*$"
41+
42+
NoDestinationSpecifiedError = errors.New("must specify at least one weighted destination for multi destination routes")
43+
SubsetsMisconfiguredErr = errors.New("route has a subset config, but the upstream does not")
44+
CompilingRoutePathRegexError = errors.Errorf("error compiling route path regex: %s", validPathRegexCharacters)
45+
ValidRoutePatternError = errors.Errorf("must only contain valid characters matching pattern %s", validPathRegexCharacters)
46+
PathContainsInvalidCharacterError = func(s, invalid string) error {
47+
return errors.Errorf("path [%s] cannot contain [%s]", s, invalid)
48+
}
49+
PathEndsWithInvalidCharactersError = func(s, invalid string) error {
50+
return errors.Errorf("path [%s] cannot end with [%s]", s, invalid)
51+
}
52+
)
3453

35-
SubsetsMisconfiguredErr = errors.New("route has a subset config, but the upstream does not")
54+
var (
55+
validPathRegex *regexp.Regexp
3656
)
3757

3858
type RouteConfigurationTranslator interface {
@@ -153,6 +173,7 @@ func (h *httpRouteConfigurationTranslator) envoyRoutes(
153173

154174
for i := range out {
155175
h.setAction(params, routeReport, in, out[i])
176+
validateEnvoyRoute(out[i], routeReport)
156177
}
157178

158179
return out
@@ -199,6 +220,22 @@ func initRoutes(
199220
return out
200221
}
201222

223+
// validateEnvoyRoute will validate all paths on a route. Any path, rewrite, or redirect of the path
224+
// will be validated.
225+
func validateEnvoyRoute(r *envoy_config_route_v3.Route, routeReport *validationapi.RouteReport) {
226+
match := r.GetMatch()
227+
route := r.GetRoute()
228+
re := r.GetRedirect()
229+
name := r.GetName()
230+
validatePath(match.GetPath(), name, routeReport)
231+
validatePath(match.GetPrefix(), name, routeReport)
232+
validatePath(route.GetPrefixRewrite(), name, routeReport)
233+
validatePath(re.GetPrefixRewrite(), name, routeReport)
234+
validatePath(re.GetPathRedirect(), name, routeReport)
235+
validatePath(re.GetHostRedirect(), name, routeReport)
236+
validatePath(re.GetSchemeRedirect(), name, routeReport)
237+
}
238+
202239
// utility function to transform gloo matcher to envoy route matcher
203240
func GlooMatcherToEnvoyMatcher(ctx context.Context, matcher *matchers.Matcher) envoy_config_route_v3.RouteMatch {
204241
match := envoy_config_route_v3.RouteMatch{
@@ -797,3 +834,40 @@ func isWarningErr(err error) bool {
797834
return false
798835
}
799836
}
837+
838+
func validatePath(path, name string, routeReport *validationapi.RouteReport) {
839+
if err := ValidateRoutePath(path); err != nil {
840+
validation.AppendRouteError(routeReport, validationapi.RouteReport_Error_ProcessingError, err.Error(), name)
841+
}
842+
}
843+
844+
// ValidateRoutePath will validate a string for all characters according to RFC 3986
845+
// "pchar" characters = unreserved / pct-encoded / sub-delims / ":" / "@"
846+
// https://www.rfc-editor.org/rfc/rfc3986/
847+
func ValidateRoutePath(s string) error {
848+
if s == "" {
849+
return nil
850+
}
851+
if validPathRegex == nil {
852+
re, err := regexp.Compile(validPathRegexCharacters)
853+
if err != nil {
854+
validPathRegex = nil
855+
return CompilingRoutePathRegexError
856+
}
857+
validPathRegex = re
858+
}
859+
if !validPathRegex.Match([]byte(s)) {
860+
return ValidRoutePatternError
861+
}
862+
for _, invalid := range invalidPathSequences {
863+
if strings.Contains(s, invalid) {
864+
return PathContainsInvalidCharacterError(s, invalid)
865+
}
866+
}
867+
for _, invalid := range invalidPathSuffixes {
868+
if strings.HasSuffix(s, invalid) {
869+
return PathEndsWithInvalidCharactersError(s, invalid)
870+
}
871+
}
872+
return nil
873+
}
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
package translator_test
2+
3+
import (
4+
"strconv"
5+
"strings"
6+
7+
. "github.com/onsi/ginkgo"
8+
. "github.com/onsi/ginkgo/extensions/table"
9+
. "github.com/onsi/gomega"
10+
"github.com/solo-io/gloo/projects/gloo/pkg/translator"
11+
)
12+
13+
var _ = Describe("Route Configs", func() {
14+
15+
DescribeTable("validate route path", func(path string, expectedValue bool) {
16+
if expectedValue {
17+
Expect(translator.ValidateRoutePath(path)).ToNot(HaveOccurred())
18+
} else {
19+
Expect(translator.ValidateRoutePath(path)).To(HaveOccurred())
20+
}
21+
},
22+
Entry("Hex", "%af", true),
23+
Entry("Hex Camel", "%Af", true),
24+
Entry("Hex num", "%00", true),
25+
Entry("Hex double", "%11", true),
26+
Entry("Hex with valid", "%af801&*", true),
27+
Entry("valid with hex", "801&*%af", true),
28+
Entry("valid with hex and valid", "801&*%af719$@!", true),
29+
Entry("Hex single", "%0", false),
30+
Entry("unicode chars", "ĩ", false),
31+
Entry("unicode chars", "¥¨˚∫", false),
32+
Entry("//", "hello/something//", false),
33+
Entry("/./", "hello/something/./", false),
34+
Entry("/../", "hello/something/../", false),
35+
Entry("hex slash upper", "hello/something%2F", false),
36+
Entry("hex slash lower", "hello/something%2f", false),
37+
Entry("hash", "hello/something#", false),
38+
Entry("/..", "hello/../something", false),
39+
Entry("/.", "hello/./something", false),
40+
)
41+
42+
It("Should validate all seperate characters", func() {
43+
// must allow all "pchar" characters = unreserved / pct-encoded / sub-delims / ":" / "@"
44+
// https://www.rfc-editor.org/rfc/rfc3986
45+
// unreserved
46+
// alpha Upper and Lower
47+
for i := 'a'; i <= 'z'; i++ {
48+
Expect(translator.ValidateRoutePath(string(i))).ToNot(HaveOccurred())
49+
Expect(translator.ValidateRoutePath(strings.ToUpper(string(i)))).ToNot(HaveOccurred())
50+
}
51+
// digit
52+
for i := 0; i < 10; i++ {
53+
Expect(translator.ValidateRoutePath(strconv.Itoa(i))).ToNot(HaveOccurred())
54+
}
55+
unreservedChars := "-._~"
56+
for _, c := range unreservedChars {
57+
Expect(translator.ValidateRoutePath(string(c))).ToNot(HaveOccurred())
58+
}
59+
// sub-delims
60+
subDelims := "!$&'()*+,;="
61+
Expect(len(subDelims)).To(Equal(11))
62+
for _, c := range subDelims {
63+
Expect(translator.ValidateRoutePath(string(c))).ToNot(HaveOccurred())
64+
}
65+
// pchar
66+
pchar := ":@"
67+
for _, c := range pchar {
68+
Expect(translator.ValidateRoutePath(string(c))).ToNot(HaveOccurred())
69+
}
70+
// invalid characters
71+
invalid := "<>?\\|[]{}\"^%#"
72+
for _, c := range invalid {
73+
Expect(translator.ValidateRoutePath(string(c))).To(HaveOccurred())
74+
}
75+
})
76+
77+
})

projects/gloo/pkg/translator/translator_test.go

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -354,6 +354,16 @@ var _ = Describe("Translator", func() {
354354
return report
355355
}
356356

357+
translateWithInvalidRoutePath := func() *validation.ProxyReport {
358+
_, errs, report, err := translator.Translate(params, proxy)
359+
Expect(err).NotTo(HaveOccurred())
360+
err = errs.Validate()
361+
ExpectWithOffset(1, err).To(HaveOccurred())
362+
Expect(err.Error()).To(ContainSubstring("cannot contain [/../]"))
363+
return report
364+
365+
}
366+
357367
// returns md5 Sum of current snapshot
358368
translate := func() {
359369
snap, errs, report, err := translator.Translate(params, proxy)
@@ -659,6 +669,100 @@ var _ = Describe("Translator", func() {
659669
})
660670
})
661671

672+
Context("invalid route paths", func() {
673+
It("should report an invalid path redirect", func() {
674+
glooRoute := &v1.Route{
675+
Action: &v1.Route_RedirectAction{
676+
RedirectAction: &v1.RedirectAction{
677+
PathRewriteSpecifier: &v1.RedirectAction_PathRedirect{
678+
// invalid sequence
679+
PathRedirect: "/../../home/secretdata",
680+
},
681+
},
682+
},
683+
}
684+
routes[0] = glooRoute
685+
translateWithInvalidRoutePath()
686+
})
687+
It("should report an invalid prefix rewrite", func() {
688+
glooRoute := &v1.Route{
689+
Action: &v1.Route_RedirectAction{
690+
RedirectAction: &v1.RedirectAction{
691+
PathRewriteSpecifier: &v1.RedirectAction_PrefixRewrite{
692+
PrefixRewrite: "home/../secret",
693+
},
694+
},
695+
},
696+
}
697+
routes[0] = glooRoute
698+
translateWithInvalidRoutePath()
699+
})
700+
701+
It("should report an invalid host redirect", func() {
702+
glooRoute := &v1.Route{
703+
Action: &v1.Route_RedirectAction{
704+
RedirectAction: &v1.RedirectAction{
705+
HostRedirect: "otherhost/../secret",
706+
},
707+
},
708+
}
709+
routes[0] = glooRoute
710+
translateWithInvalidRoutePath()
711+
})
712+
713+
It("should report an invalid prefix rewrite", func() {
714+
glooRoute := &v1.Route{
715+
Action: &v1.Route_RouteAction{
716+
RouteAction: &v1.RouteAction{
717+
Destination: &v1.RouteAction_Single{
718+
Single: &v1.Destination{
719+
DestinationType: &v1.Destination_Upstream{
720+
Upstream: &core.ResourceRef{
721+
Name: "somename",
722+
Namespace: "someNamespace",
723+
},
724+
},
725+
},
726+
},
727+
},
728+
},
729+
Options: &v1.RouteOptions{
730+
PrefixRewrite: &wrapperspb.StringValue{Value: "/home/../secret"},
731+
},
732+
}
733+
routes[0] = glooRoute
734+
translateWithInvalidRoutePath()
735+
})
736+
737+
It("should report an invalid prefix", func() {
738+
glooRoute := &v1.Route{
739+
Matchers: []*matchers.Matcher{
740+
{
741+
PathSpecifier: &matchers.Matcher_Prefix{
742+
Prefix: "home/../secret",
743+
},
744+
},
745+
},
746+
}
747+
routes[0] = glooRoute
748+
translateWithInvalidRoutePath()
749+
})
750+
751+
It("should report an invalid prefix", func() {
752+
glooRoute := &v1.Route{
753+
Matchers: []*matchers.Matcher{
754+
{
755+
PathSpecifier: &matchers.Matcher_Exact{
756+
Exact: "home/../secret",
757+
},
758+
},
759+
},
760+
}
761+
routes[0] = glooRoute
762+
translateWithInvalidRoutePath()
763+
})
764+
})
765+
662766
Context("route header match", func() {
663767
It("should translate header matcher with no value to a PresentMatch", func() {
664768

0 commit comments

Comments
 (0)