Normalize request path
Co-authored-by: Romain <rtribotte@users.noreply.github.com>
This commit is contained in:
parent
b669981018
commit
08d5dfee01
7 changed files with 504 additions and 17 deletions
|
@ -674,3 +674,32 @@ it can lead to unsafe routing when the `sanitizePath` option is set to `false`.
|
||||||
|
|
||||||
Setting the `sanitizePath` option to `false` is not safe.
|
Setting the `sanitizePath` option to `false` is not safe.
|
||||||
Ensure every request is properly url encoded instead.
|
Ensure every request is properly url encoded instead.
|
||||||
|
|
||||||
|
## v2.11.25
|
||||||
|
|
||||||
|
### Request Path Normalization
|
||||||
|
|
||||||
|
Since `v2.11.25`, the request path is now normalized by decoding unreserved characters in the request path,
|
||||||
|
and also uppercasing the percent-encoded characters.
|
||||||
|
This follows [RFC 3986 percent-encoding normalization](https://datatracker.ietf.org/doc/html/rfc3986#section-6.2.2.2),
|
||||||
|
and [RFC 3986 case normalization](https://datatracker.ietf.org/doc/html/rfc3986#section-6.2.2.1).
|
||||||
|
|
||||||
|
The normalization happens before the request path is sanitized,
|
||||||
|
and cannot be disabled.
|
||||||
|
This notably helps with encoded dots characters (which are unreserved characters) to be sanitized properly.
|
||||||
|
|
||||||
|
### Routing Path
|
||||||
|
|
||||||
|
Since `v2.11.25`, the reserved characters [(as per RFC 3986)](https://datatracker.ietf.org/doc/html/rfc3986#section-2.2) are kept encoded in the request path when matching the router rules.
|
||||||
|
Those characters, when decoded, change the meaning of the request path for routing purposes,
|
||||||
|
and Traefik now keeps them encoded to avoid any ambiguity.
|
||||||
|
|
||||||
|
### Request Path Matching Examples
|
||||||
|
|
||||||
|
| Request Path | Router Rule | Traefik v2.11.24 | Traefik v2.11.25 |
|
||||||
|
|-------------------|------------------------|------------------|------------------|
|
||||||
|
| `/foo%2Fbar` | PathPrefix(`/foo/bar`) | Match | No match |
|
||||||
|
| `/foo/../bar` | PathPrefix(`/foo`) | No match | No match |
|
||||||
|
| `/foo/../bar` | PathPrefix(`/bar`) | Match | Match |
|
||||||
|
| `/foo/%2E%2E/bar` | PathPrefix(`/foo`) | Match | No match |
|
||||||
|
| `/foo/%2E%2E/bar` | PathPrefix(`/bar`) | No match | Match |
|
||||||
|
|
2
go.mod
2
go.mod
|
@ -391,7 +391,7 @@ require (
|
||||||
// Containous forks
|
// Containous forks
|
||||||
replace (
|
replace (
|
||||||
github.com/abbot/go-http-auth => github.com/containous/go-http-auth v0.4.1-0.20200324110947-a37a7636d23e
|
github.com/abbot/go-http-auth => github.com/containous/go-http-auth v0.4.1-0.20200324110947-a37a7636d23e
|
||||||
github.com/gorilla/mux => github.com/containous/mux v0.0.0-20220627093034-b2dd784e613f
|
github.com/gorilla/mux => github.com/containous/mux v0.0.0-20250523120546-41b6ec3aed59
|
||||||
github.com/mailgun/minheap => github.com/containous/minheap v0.0.0-20190809180810-6e71eb837595
|
github.com/mailgun/minheap => github.com/containous/minheap v0.0.0-20190809180810-6e71eb837595
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
4
go.sum
4
go.sum
|
@ -298,8 +298,8 @@ github.com/containous/go-http-auth v0.4.1-0.20200324110947-a37a7636d23e h1:D+uTE
|
||||||
github.com/containous/go-http-auth v0.4.1-0.20200324110947-a37a7636d23e/go.mod h1:s8kLgBQolDbsJOPVIGCEEv9zGAKUUf/685Gi0Qqg8z8=
|
github.com/containous/go-http-auth v0.4.1-0.20200324110947-a37a7636d23e/go.mod h1:s8kLgBQolDbsJOPVIGCEEv9zGAKUUf/685Gi0Qqg8z8=
|
||||||
github.com/containous/minheap v0.0.0-20190809180810-6e71eb837595 h1:aPspFRO6b94To3gl4yTDOEtpjFwXI7V2W+z0JcNljQ4=
|
github.com/containous/minheap v0.0.0-20190809180810-6e71eb837595 h1:aPspFRO6b94To3gl4yTDOEtpjFwXI7V2W+z0JcNljQ4=
|
||||||
github.com/containous/minheap v0.0.0-20190809180810-6e71eb837595/go.mod h1:+lHFbEasIiQVGzhVDVw/cn0ZaOzde2OwNncp1NhXV4c=
|
github.com/containous/minheap v0.0.0-20190809180810-6e71eb837595/go.mod h1:+lHFbEasIiQVGzhVDVw/cn0ZaOzde2OwNncp1NhXV4c=
|
||||||
github.com/containous/mux v0.0.0-20220627093034-b2dd784e613f h1:1uEtynq2C0ljy3630jt7EAxg8jZY2gy6YHdGwdqEpWw=
|
github.com/containous/mux v0.0.0-20250523120546-41b6ec3aed59 h1:lJUOWjGohYjLKEfAz2nyI/dpzfKNPQLi5GLH7aaOZkw=
|
||||||
github.com/containous/mux v0.0.0-20220627093034-b2dd784e613f/go.mod h1:z8WW7n06n8/1xF9Jl9WmuDeZuHAhfL+bwarNjsciwwg=
|
github.com/containous/mux v0.0.0-20250523120546-41b6ec3aed59/go.mod h1:z8WW7n06n8/1xF9Jl9WmuDeZuHAhfL+bwarNjsciwwg=
|
||||||
github.com/coreos/bbolt v1.3.2/go.mod h1:iRUV2dpdMOn7Bo10OQBFzIJO9kkE559Wcmn+qkEiiKk=
|
github.com/coreos/bbolt v1.3.2/go.mod h1:iRUV2dpdMOn7Bo10OQBFzIJO9kkE559Wcmn+qkEiiKk=
|
||||||
github.com/coreos/etcd v3.3.13+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE=
|
github.com/coreos/etcd v3.3.13+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE=
|
||||||
github.com/coreos/go-oidc v2.1.0+incompatible/go.mod h1:CgnwVTmzoESiwO9qyAFEMiHoZ1nMCKZlZ9V6mm3/LKc=
|
github.com/coreos/go-oidc v2.1.0+incompatible/go.mod h1:CgnwVTmzoESiwO9qyAFEMiHoZ1nMCKZlZ9V6mm3/LKc=
|
||||||
|
|
|
@ -1429,6 +1429,18 @@ func (s *SimpleSuite) TestSanitizePath() {
|
||||||
target: "127.0.0.1:8000",
|
target: "127.0.0.1:8000",
|
||||||
expected: http.StatusFound,
|
expected: http.StatusFound,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
desc: "Implicit encoded dot dots call to the route with a middleware",
|
||||||
|
request: "GET /without/%2E%2E/with HTTP/1.1\r\nHost: other.localhost\r\n\r\n",
|
||||||
|
target: "127.0.0.1:8000",
|
||||||
|
expected: http.StatusFound,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
desc: "Implicit with encoded unreserved character call to the route with a middleware",
|
||||||
|
request: "GET /%77ith HTTP/1.1\r\nHost: other.localhost\r\n\r\n",
|
||||||
|
target: "127.0.0.1:8000",
|
||||||
|
expected: http.StatusFound,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
desc: "Explicit call to the route with a middleware, and disable path sanitization",
|
desc: "Explicit call to the route with a middleware, and disable path sanitization",
|
||||||
request: "GET /with HTTP/1.1\r\nHost: other.localhost\r\n\r\n",
|
request: "GET /with HTTP/1.1\r\nHost: other.localhost\r\n\r\n",
|
||||||
|
|
|
@ -48,7 +48,7 @@ func NewMuxer() (*Muxer, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
return &Muxer{
|
return &Muxer{
|
||||||
Router: mux.NewRouter().SkipClean(true),
|
Router: mux.NewRouter().UseRoutingPath().SkipClean(true),
|
||||||
parser: parser,
|
parser: parser,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
|
@ -16,6 +16,7 @@ import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/containous/alice"
|
"github.com/containous/alice"
|
||||||
|
"github.com/gorilla/mux"
|
||||||
"github.com/pires/go-proxyproto"
|
"github.com/pires/go-proxyproto"
|
||||||
"github.com/sirupsen/logrus"
|
"github.com/sirupsen/logrus"
|
||||||
"github.com/traefik/traefik/v2/pkg/config/static"
|
"github.com/traefik/traefik/v2/pkg/config/static"
|
||||||
|
@ -571,18 +572,6 @@ func createHTTPServer(ctx context.Context, ln net.Listener, configuration *stati
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if configuration.HTTP.SanitizePath != nil && *configuration.HTTP.SanitizePath {
|
|
||||||
// sanitizePath is used to clean the URL path by removing /../, /./ and duplicate slash sequences,
|
|
||||||
// to make sure the path is interpreted by the backends as it is evaluated inside rule matchers.
|
|
||||||
handler = sanitizePath(handler)
|
|
||||||
}
|
|
||||||
|
|
||||||
if configuration.HTTP.EncodeQuerySemicolons {
|
|
||||||
handler = encodeQuerySemicolons(handler)
|
|
||||||
} else {
|
|
||||||
handler = http.AllowQuerySemicolons(handler)
|
|
||||||
}
|
|
||||||
|
|
||||||
debugConnection := os.Getenv(debugConnectionEnv) != ""
|
debugConnection := os.Getenv(debugConnectionEnv) != ""
|
||||||
if debugConnection || (configuration.Transport != nil && (configuration.Transport.KeepAliveMaxTime > 0 || configuration.Transport.KeepAliveMaxRequests > 0)) {
|
if debugConnection || (configuration.Transport != nil && (configuration.Transport.KeepAliveMaxTime > 0 || configuration.Transport.KeepAliveMaxRequests > 0)) {
|
||||||
handler = newKeepAliveMiddleware(handler, configuration.Transport.KeepAliveMaxRequests, configuration.Transport.KeepAliveMaxTime)
|
handler = newKeepAliveMiddleware(handler, configuration.Transport.KeepAliveMaxRequests, configuration.Transport.KeepAliveMaxTime)
|
||||||
|
@ -594,6 +583,22 @@ func createHTTPServer(ctx context.Context, ln net.Listener, configuration *stati
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if configuration.HTTP.EncodeQuerySemicolons {
|
||||||
|
handler = encodeQuerySemicolons(handler)
|
||||||
|
} else {
|
||||||
|
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 {
|
||||||
|
handler = sanitizePath(handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
handler = normalizePath(handler)
|
||||||
|
|
||||||
handler = denyFragment(handler)
|
handler = denyFragment(handler)
|
||||||
|
|
||||||
serverHTTP := &http.Server{
|
serverHTTP := &http.Server{
|
||||||
|
@ -721,7 +726,7 @@ func denyFragment(h http.Handler) http.Handler {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// sanitizePath removes the "..", "." and duplicate slash segments from the URL.
|
// sanitizePath removes the "..", "." and duplicate slash segments from the URL according to https://datatracker.ietf.org/doc/html/rfc3986#section-6.2.2.3.
|
||||||
// It cleans the request URL Path and RawPath, and updates the request URI.
|
// It cleans the request URL Path and RawPath, and updates the request URI.
|
||||||
func sanitizePath(h http.Handler) http.Handler {
|
func sanitizePath(h http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
|
return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
|
||||||
|
@ -737,3 +742,158 @@ func sanitizePath(h http.Handler) http.Handler {
|
||||||
h.ServeHTTP(rw, r2)
|
h.ServeHTTP(rw, r2)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// unreservedCharacters contains the mapping of the percent-encoded form to the ASCII form
|
||||||
|
// of the unreserved characters according to https://datatracker.ietf.org/doc/html/rfc3986#section-2.3.
|
||||||
|
var unreservedCharacters = map[string]rune{
|
||||||
|
"%41": 'A', "%42": 'B', "%43": 'C', "%44": 'D', "%45": 'E', "%46": 'F',
|
||||||
|
"%47": 'G', "%48": 'H', "%49": 'I', "%4A": 'J', "%4B": 'K', "%4C": 'L',
|
||||||
|
"%4D": 'M', "%4E": 'N', "%4F": 'O', "%50": 'P', "%51": 'Q', "%52": 'R',
|
||||||
|
"%53": 'S', "%54": 'T', "%55": 'U', "%56": 'V', "%57": 'W', "%58": 'X',
|
||||||
|
"%59": 'Y', "%5A": 'Z',
|
||||||
|
|
||||||
|
"%61": 'a', "%62": 'b', "%63": 'c', "%64": 'd', "%65": 'e', "%66": 'f',
|
||||||
|
"%67": 'g', "%68": 'h', "%69": 'i', "%6A": 'j', "%6B": 'k', "%6C": 'l',
|
||||||
|
"%6D": 'm', "%6E": 'n', "%6F": 'o', "%70": 'p', "%71": 'q', "%72": 'r',
|
||||||
|
"%73": 's', "%74": 't', "%75": 'u', "%76": 'v', "%77": 'w', "%78": 'x',
|
||||||
|
"%79": 'y', "%7A": 'z',
|
||||||
|
|
||||||
|
"%30": '0', "%31": '1', "%32": '2', "%33": '3', "%34": '4',
|
||||||
|
"%35": '5', "%36": '6', "%37": '7', "%38": '8', "%39": '9',
|
||||||
|
|
||||||
|
"%2D": '-', "%2E": '.', "%5F": '_', "%7E": '~',
|
||||||
|
}
|
||||||
|
|
||||||
|
// normalizePath removes from the RawPath unreserved percent-encoded characters as they are equivalent to their non-encoded
|
||||||
|
// form according to https://datatracker.ietf.org/doc/html/rfc3986#section-2.3 and capitalizes percent-encoded characters
|
||||||
|
// according to https://datatracker.ietf.org/doc/html/rfc3986#section-6.2.2.1.
|
||||||
|
func normalizePath(h http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
|
||||||
|
rawPath := req.URL.RawPath
|
||||||
|
|
||||||
|
// When the RawPath is empty the encoded form of the Path is equivalent to the original request Path.
|
||||||
|
// Thus, the normalization is not needed as no unreserved characters were encoded and the encoded version
|
||||||
|
// of Path obtained with URL.EscapedPath contains only percent-encoded characters in upper case.
|
||||||
|
if rawPath == "" {
|
||||||
|
h.ServeHTTP(rw, req)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var normalizedRawPathBuilder strings.Builder
|
||||||
|
for i := 0; i < len(rawPath); i++ {
|
||||||
|
if rawPath[i] != '%' {
|
||||||
|
normalizedRawPathBuilder.WriteString(string(rawPath[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(rawPath) {
|
||||||
|
rw.WriteHeader(http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
encodedCharacter := strings.ToUpper(rawPath[i : i+3])
|
||||||
|
if r, unreserved := unreservedCharacters[encodedCharacter]; unreserved {
|
||||||
|
normalizedRawPathBuilder.WriteRune(r)
|
||||||
|
} else {
|
||||||
|
normalizedRawPathBuilder.WriteString(encodedCharacter)
|
||||||
|
}
|
||||||
|
|
||||||
|
i += 2
|
||||||
|
}
|
||||||
|
|
||||||
|
normalizedRawPath := normalizedRawPathBuilder.String()
|
||||||
|
|
||||||
|
// We do not have to alter the request URL as the original RawPath is already normalized.
|
||||||
|
if normalizedRawPath == rawPath {
|
||||||
|
h.ServeHTTP(rw, req)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
r2 := new(http.Request)
|
||||||
|
*r2 = *req
|
||||||
|
|
||||||
|
// Decoding unreserved characters only alter the RAW version of the URL,
|
||||||
|
// as unreserved percent-encoded characters are equivalent to their non encoded form.
|
||||||
|
r2.URL.RawPath = normalizedRawPath
|
||||||
|
|
||||||
|
// Because the reverse proxy director is building query params from RequestURI it needs to be updated as well.
|
||||||
|
r2.RequestURI = r2.URL.RequestURI()
|
||||||
|
|
||||||
|
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(),
|
||||||
|
),
|
||||||
|
))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
|
@ -13,10 +13,12 @@ import (
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/gorilla/mux"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
ptypes "github.com/traefik/paerser/types"
|
ptypes "github.com/traefik/paerser/types"
|
||||||
"github.com/traefik/traefik/v2/pkg/config/static"
|
"github.com/traefik/traefik/v2/pkg/config/static"
|
||||||
|
"github.com/traefik/traefik/v2/pkg/middlewares/requestdecorator"
|
||||||
tcprouter "github.com/traefik/traefik/v2/pkg/server/router/tcp"
|
tcprouter "github.com/traefik/traefik/v2/pkg/server/router/tcp"
|
||||||
"github.com/traefik/traefik/v2/pkg/tcp"
|
"github.com/traefik/traefik/v2/pkg/tcp"
|
||||||
"golang.org/x/net/http2"
|
"golang.org/x/net/http2"
|
||||||
|
@ -424,3 +426,287 @@ func TestSanitizePath(t *testing.T) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestNormalizePath(t *testing.T) {
|
||||||
|
unreservedDecoded := "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"
|
||||||
|
unreserved := []string{
|
||||||
|
"%41", "%42", "%43", "%44", "%45", "%46", "%47", "%48", "%49", "%4A", "%4B", "%4C", "%4D", "%4E", "%4F", "%50", "%51", "%52", "%53", "%54", "%55", "%56", "%57", "%58", "%59", "%5A",
|
||||||
|
"%61", "%62", "%63", "%64", "%65", "%66", "%67", "%68", "%69", "%6A", "%6B", "%6C", "%6D", "%6E", "%6F", "%70", "%71", "%72", "%73", "%74", "%75", "%76", "%77", "%78", "%79", "%7A",
|
||||||
|
"%30", "%31", "%32", "%33", "%34", "%35", "%36", "%37", "%38", "%39",
|
||||||
|
"%2D", "%2E", "%5F", "%7E",
|
||||||
|
}
|
||||||
|
|
||||||
|
reserved := []string{
|
||||||
|
"%3A", "%2F", "%3F", "%23", "%5B", "%5D", "%40", "%21", "%24", "%26", "%27", "%28", "%29", "%2A", "%2B", "%2C", "%3B", "%3D", "%25",
|
||||||
|
}
|
||||||
|
reservedJoined := strings.Join(reserved, "")
|
||||||
|
|
||||||
|
unallowedCharacter := "%0a" // line feed
|
||||||
|
unallowedCharacterUpperCased := "%0A" // line feed upper case
|
||||||
|
|
||||||
|
var callCount int
|
||||||
|
handler := normalizePath(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
callCount++
|
||||||
|
|
||||||
|
wantRawPath := "/" + unreservedDecoded + reservedJoined + unallowedCharacterUpperCased
|
||||||
|
assert.Equal(t, wantRawPath, r.URL.RawPath)
|
||||||
|
}))
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "http://foo/"+strings.Join(unreserved, "")+reservedJoined+unallowedCharacter, http.NoBody)
|
||||||
|
res := httptest.NewRecorder()
|
||||||
|
|
||||||
|
handler.ServeHTTP(res, req)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, res.Code)
|
||||||
|
assert.Equal(t, 1, callCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizePath_malformedPercentEncoding(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
desc string
|
||||||
|
path string
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
desc: "well formed path",
|
||||||
|
path: "/%20",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
desc: "percent sign at the end",
|
||||||
|
path: "/%",
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
desc: "incomplete percent encoding at the end",
|
||||||
|
path: "/%f",
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.desc, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var callCount int
|
||||||
|
handler := normalizePath(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
callCount++
|
||||||
|
}))
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "http://foo", http.NoBody)
|
||||||
|
req.URL.RawPath = test.path
|
||||||
|
|
||||||
|
res := httptest.NewRecorder()
|
||||||
|
|
||||||
|
handler.ServeHTTP(res, req)
|
||||||
|
|
||||||
|
if test.wantErr {
|
||||||
|
assert.Equal(t, http.StatusBadRequest, res.Code)
|
||||||
|
assert.Equal(t, 0, callCount)
|
||||||
|
} else {
|
||||||
|
assert.Equal(t, http.StatusOK, res.Code)
|
||||||
|
assert.Equal(t, 1, callCount)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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.
|
||||||
|
// 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.
|
||||||
|
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() {
|
||||||
|
_ = ln.Close()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Define the server configuration.
|
||||||
|
configuration := &static.EntryPoint{}
|
||||||
|
configuration.SetDefaults()
|
||||||
|
|
||||||
|
// Create the HTTP server using createHTTPServer.
|
||||||
|
server, err := createHTTPServer(context.Background(), ln, configuration, false, requestdecorator.New(nil))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
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)
|
||||||
|
}))
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
// server is expected to return an error if the listener is closed.
|
||||||
|
_ = server.Server.Serve(ln)
|
||||||
|
}()
|
||||||
|
|
||||||
|
client := http.Client{
|
||||||
|
Transport: &http.Transport{
|
||||||
|
ResponseHeaderTimeout: 1 * time.Second,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
desc string
|
||||||
|
rawPath string
|
||||||
|
expectedPath string
|
||||||
|
expectedRaw string
|
||||||
|
expectedRoutingPath 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: "path with traversal attempt",
|
||||||
|
rawPath: "/../../b/",
|
||||||
|
expectedPath: "/b/",
|
||||||
|
expectedRaw: "/b/",
|
||||||
|
expectedRoutingPath: "/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 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 trailing slash and traversal",
|
||||||
|
rawPath: "/a/b/../",
|
||||||
|
expectedPath: "/a/",
|
||||||
|
expectedRaw: "/a/",
|
||||||
|
expectedRoutingPath: "/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 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: "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 reserved percent-encoded character",
|
||||||
|
rawPath: "/foo%2Fbar",
|
||||||
|
expectedPath: "/foo/bar",
|
||||||
|
expectedRaw: "/foo%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",
|
||||||
|
expectedRoutingPath: "/foo %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,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.desc, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
req, err := http.NewRequest(http.MethodGet, "http://"+ln.Addr().String()+test.rawPath, http.NoBody)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
res, err := client.Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
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"))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue