1
0
Fork 0

Use routing path in v3 matchers

Co-authored-by: Romain <rtribotte@users.noreply.github.com>
This commit is contained in:
Kevin Pollet 2025-05-27 11:06:05 +02:00 committed by GitHub
parent de1802d849
commit 859f4e8868
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 338 additions and 209 deletions

View file

@ -142,7 +142,8 @@ func path(tree *matchersTree, paths ...string) error {
}
tree.matcher = func(req *http.Request) bool {
return req.URL.Path == path
routingPath := getRoutingPath(req)
return routingPath != nil && *routingPath == path
}
return nil
@ -157,7 +158,8 @@ func pathRegexp(tree *matchersTree, paths ...string) error {
}
tree.matcher = func(req *http.Request) bool {
return re.MatchString(req.URL.Path)
routingPath := getRoutingPath(req)
return routingPath != nil && re.MatchString(*routingPath)
}
return nil
@ -171,7 +173,8 @@ func pathPrefix(tree *matchersTree, paths ...string) error {
}
tree.matcher = func(req *http.Request) bool {
return strings.HasPrefix(req.URL.Path, path)
routingPath := getRoutingPath(req)
return routingPath != nil && strings.HasPrefix(*routingPath, path)
}
return nil

View file

@ -28,7 +28,7 @@ func pathV2(tree *matchersTree, paths ...string) error {
var routes []*mux.Route
for _, path := range paths {
route := mux.NewRouter().NewRoute()
route := mux.NewRouter().UseRoutingPath().NewRoute()
if err := route.Path(path).GetError(); err != nil {
return err
@ -54,7 +54,7 @@ func pathPrefixV2(tree *matchersTree, paths ...string) error {
var routes []*mux.Route
for _, path := range paths {
route := mux.NewRouter().NewRoute()
route := mux.NewRouter().UseRoutingPath().NewRoute()
if err := route.PathPrefix(path).GetError(); err != nil {
return err

View file

@ -1,10 +1,15 @@
package http
import (
"context"
"errors"
"fmt"
"net/http"
"net/url"
"sort"
"strings"
"github.com/gorilla/mux"
"github.com/rs/zerolog/log"
"github.com/traefik/traefik/v3/pkg/rules"
)
@ -33,6 +38,16 @@ func NewMuxer(parser SyntaxParser) *Muxer {
// ServeHTTP forwards the connection to the matching HTTP handler.
// Serves 404 if no handler is found.
func (m *Muxer) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
logger := log.Ctx(req.Context())
var err error
req, err = withRoutingPath(req)
if err != nil {
logger.Debug().Err(err).Msg("Unable to add routing path to request context")
rw.WriteHeader(http.StatusBadRequest)
return
}
for _, route := range m.routes {
if route.matchers.match(req) {
route.handler.ServeHTTP(rw, req)
@ -72,6 +87,86 @@ func (m *Muxer) AddRoute(rule string, syntax string, priority int, handler http.
return nil
}
// reservedCharacters contains the mapping of the percent-encoded form to the ASCII form
// of the reserved characters according to https://datatracker.ietf.org/doc/html/rfc3986#section-2.2.
// By extension to https://datatracker.ietf.org/doc/html/rfc3986#section-2.1 the percent character is also considered a reserved character.
// Because decoding the percent character would change the meaning of the URL.
var reservedCharacters = map[string]rune{
"%3A": ':',
"%2F": '/',
"%3F": '?',
"%23": '#',
"%5B": '[',
"%5D": ']',
"%40": '@',
"%21": '!',
"%24": '$',
"%26": '&',
"%27": '\'',
"%28": '(',
"%29": ')',
"%2A": '*',
"%2B": '+',
"%2C": ',',
"%3B": ';',
"%3D": '=',
"%25": '%',
}
// getRoutingPath retrieves the routing path from the request context.
// It returns nil if the routing path is not set in the context.
func getRoutingPath(req *http.Request) *string {
routingPath := req.Context().Value(mux.RoutingPathKey)
if routingPath != nil {
rp := routingPath.(string)
return &rp
}
return nil
}
// withRoutingPath decodes non-allowed characters in the EscapedPath and stores it in the request context to be able to use it for routing.
// This allows using the decoded version of the non-allowed characters in the routing rules for a better UX.
// For example, the rule PathPrefix(`/foo bar`) will match the following request path `/foo%20bar`.
func withRoutingPath(req *http.Request) (*http.Request, error) {
escapedPath := req.URL.EscapedPath()
var routingPathBuilder strings.Builder
for i := 0; i < len(escapedPath); i++ {
if escapedPath[i] != '%' {
routingPathBuilder.WriteString(string(escapedPath[i]))
continue
}
// This should never happen as the standard library will reject requests containing invalid percent-encodings.
// This discards URLs with a percent character at the end.
if i+2 >= len(escapedPath) {
return nil, errors.New("invalid percent-encoding at the end of the URL path")
}
encodedCharacter := escapedPath[i : i+3]
if _, reserved := reservedCharacters[encodedCharacter]; reserved {
routingPathBuilder.WriteString(encodedCharacter)
} else {
// This should never happen as the standard library will reject requests containing invalid percent-encodings.
decodedCharacter, err := url.PathUnescape(encodedCharacter)
if err != nil {
return nil, errors.New("invalid percent-encoding in URL path")
}
routingPathBuilder.WriteString(decodedCharacter)
}
i += 2
}
return req.WithContext(
context.WithValue(
req.Context(),
mux.RoutingPathKey,
routingPathBuilder.String(),
),
), nil
}
// ParseDomains extract domains from rule.
func ParseDomains(rule string) ([]string, error) {
var matchers []string

View file

@ -557,3 +557,43 @@ func TestGetRulePriority(t *testing.T) {
})
}
}
func TestRoutingPath(t *testing.T) {
tests := []struct {
desc string
path string
expectedRoutingPath string
}{
{
desc: "unallowed percent-encoded character is decoded",
path: "/foo%20bar",
expectedRoutingPath: "/foo bar",
},
{
desc: "reserved percent-encoded character is kept encoded",
path: "/foo%2Fbar",
expectedRoutingPath: "/foo%2Fbar",
},
{
desc: "multiple mixed characters",
path: "/foo%20bar%2Fbaz%23qux",
expectedRoutingPath: "/foo bar%2Fbaz%23qux",
},
}
for _, test := range tests {
t.Run(test.desc, func(t *testing.T) {
t.Parallel()
req := httptest.NewRequest(http.MethodGet, "http://foo"+test.path, http.NoBody)
var err error
req, err = withRoutingPath(req)
require.NoError(t, err)
gotRoutingPath := getRoutingPath(req)
assert.NotNil(t, gotRoutingPath)
assert.Equal(t, test.expectedRoutingPath, *gotRoutingPath)
})
}
}

View file

@ -17,7 +17,6 @@ import (
"github.com/containous/alice"
gokitmetrics "github.com/go-kit/kit/metrics"
"github.com/gorilla/mux"
"github.com/pires/go-proxyproto"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
@ -630,8 +629,6 @@ func createHTTPServer(ctx context.Context, ln net.Listener, configuration *stati
handler = http.AllowQuerySemicolons(handler)
}
handler = routingPath(handler)
// Note that the Path sanitization has to be done after the path normalization,
// hence the wrapping has to be done before the normalize path wrapping.
if configuration.HTTP.SanitizePath != nil && *configuration.HTTP.SanitizePath {
@ -866,76 +863,3 @@ func normalizePath(h http.Handler) http.Handler {
h.ServeHTTP(rw, r2)
})
}
// reservedCharacters contains the mapping of the percent-encoded form to the ASCII form
// of the reserved characters according to https://datatracker.ietf.org/doc/html/rfc3986#section-2.2.
// By extension to https://datatracker.ietf.org/doc/html/rfc3986#section-2.1 the percent character is also considered a reserved character.
// Because decoding the percent character would change the meaning of the URL.
var reservedCharacters = map[string]rune{
"%3A": ':',
"%2F": '/',
"%3F": '?',
"%23": '#',
"%5B": '[',
"%5D": ']',
"%40": '@',
"%21": '!',
"%24": '$',
"%26": '&',
"%27": '\'',
"%28": '(',
"%29": ')',
"%2A": '*',
"%2B": '+',
"%2C": ',',
"%3B": ';',
"%3D": '=',
"%25": '%',
}
// routingPath decodes non-allowed characters in the EscapedPath and stores it in the context to be able to use it for routing.
// This allows using the decoded version of the non-allowed characters in the routing rules for a better UX.
// For example, the rule PathPrefix(`/foo bar`) will match the following request path `/foo%20bar`.
func routingPath(h http.Handler) http.Handler {
return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
escapedPath := req.URL.EscapedPath()
var routingPathBuilder strings.Builder
for i := 0; i < len(escapedPath); i++ {
if escapedPath[i] != '%' {
routingPathBuilder.WriteString(string(escapedPath[i]))
continue
}
// This should never happen as the standard library will reject requests containing invalid percent-encodings.
// This discards URLs with a percent character at the end.
if i+2 >= len(escapedPath) {
rw.WriteHeader(http.StatusBadRequest)
return
}
encodedCharacter := escapedPath[i : i+3]
if _, reserved := reservedCharacters[encodedCharacter]; reserved {
routingPathBuilder.WriteString(encodedCharacter)
} else {
// This should never happen as the standard library will reject requests containing invalid percent-encodings.
decodedCharacter, err := url.PathUnescape(encodedCharacter)
if err != nil {
rw.WriteHeader(http.StatusBadRequest)
return
}
routingPathBuilder.WriteString(decodedCharacter)
}
i += 2
}
h.ServeHTTP(rw, req.WithContext(
context.WithValue(
req.Context(),
mux.RoutingPathKey,
routingPathBuilder.String(),
),
))
})
}

View file

@ -13,7 +13,6 @@ import (
"testing"
"time"
"github.com/gorilla/mux"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
ptypes "github.com/traefik/paerser/types"
@ -510,56 +509,7 @@ func TestNormalizePath_malformedPercentEncoding(t *testing.T) {
}
}
func TestRoutingPath(t *testing.T) {
tests := []struct {
desc string
path string
expRoutingPath string
expStatus int
}{
{
desc: "unallowed percent-encoded character is decoded",
path: "/foo%20bar",
expRoutingPath: "/foo bar",
expStatus: http.StatusOK,
},
{
desc: "reserved percent-encoded character is kept encoded",
path: "/foo%2Fbar",
expRoutingPath: "/foo%2Fbar",
expStatus: http.StatusOK,
},
{
desc: "multiple mixed characters",
path: "/foo%20bar%2Fbaz%23qux",
expRoutingPath: "/foo bar%2Fbaz%23qux",
expStatus: http.StatusOK,
},
}
for _, test := range tests {
t.Run(test.desc, func(t *testing.T) {
t.Parallel()
var gotRoute string
handler := routingPath(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotRoute, _ = r.Context().Value(mux.RoutingPathKey).(string)
w.WriteHeader(http.StatusOK)
}))
req := httptest.NewRequest(http.MethodGet, "http://foo"+test.path, http.NoBody)
res := httptest.NewRecorder()
handler.ServeHTTP(res, req)
assert.Equal(t, test.expStatus, res.Code)
assert.Equal(t, test.expRoutingPath, gotRoute)
})
}
}
// TestPathOperations tests the whole behavior of normalizePath, sanitizePath, and routingPath combined through the use of the createHTTPServer func.
// TestPathOperations tests the whole behavior of normalizePath, and sanitizePath combined through the use of the createHTTPServer func.
// It aims to guarantee the server entrypoint handler is secure regarding a large variety of cases that could lead to path traversal attacks.
func TestPathOperations(t *testing.T) {
// Create a listener for the server.
@ -580,7 +530,6 @@ func TestPathOperations(t *testing.T) {
server.Switcher.UpdateHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Path", r.URL.Path)
w.Header().Set("RawPath", r.URL.EscapedPath())
w.Header().Set("RoutingPath", r.Context().Value(mux.RoutingPathKey).(string))
w.WriteHeader(http.StatusOK)
}))
@ -596,100 +545,88 @@ func TestPathOperations(t *testing.T) {
}
tests := []struct {
desc string
rawPath string
expectedPath string
expectedRaw string
expectedRoutingPath string
expectedStatus int
desc string
rawPath string
expectedPath string
expectedRaw string
expectedStatus int
}{
{
desc: "normalize and sanitize path",
rawPath: "/a/../b/%41%42%43//%2f/",
expectedPath: "/b/ABC///",
expectedRaw: "/b/ABC/%2F/",
expectedRoutingPath: "/b/ABC/%2F/",
expectedStatus: http.StatusOK,
desc: "normalize and sanitize path",
rawPath: "/a/../b/%41%42%43//%2f/",
expectedPath: "/b/ABC///",
expectedRaw: "/b/ABC/%2F/",
expectedStatus: http.StatusOK,
},
{
desc: "path with traversal attempt",
rawPath: "/../../b/",
expectedPath: "/b/",
expectedRaw: "/b/",
expectedRoutingPath: "/b/",
expectedStatus: http.StatusOK,
desc: "path with traversal attempt",
rawPath: "/../../b/",
expectedPath: "/b/",
expectedRaw: "/b/",
expectedStatus: http.StatusOK,
},
{
desc: "path with multiple traversal attempts",
rawPath: "/a/../../b/../c/",
expectedPath: "/c/",
expectedRaw: "/c/",
expectedRoutingPath: "/c/",
expectedStatus: http.StatusOK,
desc: "path with multiple traversal attempts",
rawPath: "/a/../../b/../c/",
expectedPath: "/c/",
expectedRaw: "/c/",
expectedStatus: http.StatusOK,
},
{
desc: "path with mixed traversal and valid segments",
rawPath: "/a/../b/./c/../d/",
expectedPath: "/b/d/",
expectedRaw: "/b/d/",
expectedRoutingPath: "/b/d/",
expectedStatus: http.StatusOK,
desc: "path with mixed traversal and valid segments",
rawPath: "/a/../b/./c/../d/",
expectedPath: "/b/d/",
expectedRaw: "/b/d/",
expectedStatus: http.StatusOK,
},
{
desc: "path with trailing slash and traversal",
rawPath: "/a/b/../",
expectedPath: "/a/",
expectedRaw: "/a/",
expectedRoutingPath: "/a/",
expectedStatus: http.StatusOK,
desc: "path with trailing slash and traversal",
rawPath: "/a/b/../",
expectedPath: "/a/",
expectedRaw: "/a/",
expectedStatus: http.StatusOK,
},
{
desc: "path with encoded traversal sequences",
rawPath: "/a/%2E%2E/%2E%2E/b/",
expectedPath: "/b/",
expectedRaw: "/b/",
expectedRoutingPath: "/b/",
expectedStatus: http.StatusOK,
desc: "path with encoded traversal sequences",
rawPath: "/a/%2E%2E/%2E%2E/b/",
expectedPath: "/b/",
expectedRaw: "/b/",
expectedStatus: http.StatusOK,
},
{
desc: "path with over-encoded traversal sequences",
rawPath: "/a/%252E%252E/%252E%252E/b/",
expectedPath: "/a/%2E%2E/%2E%2E/b/",
expectedRaw: "/a/%252E%252E/%252E%252E/b/",
expectedRoutingPath: "/a/%252E%252E/%252E%252E/b/",
expectedStatus: http.StatusOK,
desc: "path with over-encoded traversal sequences",
rawPath: "/a/%252E%252E/%252E%252E/b/",
expectedPath: "/a/%2E%2E/%2E%2E/b/",
expectedRaw: "/a/%252E%252E/%252E%252E/b/",
expectedStatus: http.StatusOK,
},
{
desc: "routing path with unallowed percent-encoded character",
rawPath: "/foo%20bar",
expectedPath: "/foo bar",
expectedRaw: "/foo%20bar",
expectedRoutingPath: "/foo bar",
expectedStatus: http.StatusOK,
desc: "routing path with unallowed percent-encoded character",
rawPath: "/foo%20bar",
expectedPath: "/foo bar",
expectedRaw: "/foo%20bar",
expectedStatus: http.StatusOK,
},
{
desc: "routing path with reserved percent-encoded character",
rawPath: "/foo%2Fbar",
expectedPath: "/foo/bar",
expectedRaw: "/foo%2Fbar",
expectedRoutingPath: "/foo%2Fbar",
expectedStatus: http.StatusOK,
desc: "routing path with reserved percent-encoded character",
rawPath: "/foo%2Fbar",
expectedPath: "/foo/bar",
expectedRaw: "/foo%2Fbar",
expectedStatus: http.StatusOK,
},
{
desc: "routing path with unallowed and reserved percent-encoded character",
rawPath: "/foo%20%2Fbar",
expectedPath: "/foo /bar",
expectedRaw: "/foo%20%2Fbar",
expectedRoutingPath: "/foo %2Fbar",
expectedStatus: http.StatusOK,
desc: "routing path with unallowed and reserved percent-encoded character",
rawPath: "/foo%20%2Fbar",
expectedPath: "/foo /bar",
expectedRaw: "/foo%20%2Fbar",
expectedStatus: http.StatusOK,
},
{
desc: "path with traversal and encoded slash",
rawPath: "/a/..%2Fb/",
expectedPath: "/a/../b/",
expectedRaw: "/a/..%2Fb/",
expectedRoutingPath: "/a/..%2Fb/",
expectedStatus: http.StatusOK,
desc: "path with traversal and encoded slash",
rawPath: "/a/..%2Fb/",
expectedPath: "/a/../b/",
expectedRaw: "/a/..%2Fb/",
expectedStatus: http.StatusOK,
},
}
@ -706,7 +643,6 @@ func TestPathOperations(t *testing.T) {
assert.Equal(t, test.expectedStatus, res.StatusCode)
assert.Equal(t, test.expectedPath, res.Header.Get("Path"))
assert.Equal(t, test.expectedRaw, res.Header.Get("RawPath"))
assert.Equal(t, test.expectedRoutingPath, res.Header.Get("RoutingPath"))
})
}
}