diff --git a/e2e/e2e_common_test.go b/e2e/e2e_common_test.go index f3246dd4..02f102dc 100644 --- a/e2e/e2e_common_test.go +++ b/e2e/e2e_common_test.go @@ -392,11 +392,13 @@ func startAndWaitTestUpstream( } } - //nolint:gosec server := &http.Server{ - Addr: listener.Addr().String(), - Handler: handler, - TLSConfig: tlsConfig, + Addr: listener.Addr().String(), + Handler: handler, + TLSConfig: tlsConfig, + WriteTimeout: 30 * time.Second, + ReadTimeout: 30 * time.Second, + ReadHeaderTimeout: 30 * time.Second, } errGroup.Go(func() error { diff --git a/e2e/e2e_normalization_test.go b/e2e/e2e_normalization_test.go new file mode 100644 index 00000000..af0730ae --- /dev/null +++ b/e2e/e2e_normalization_test.go @@ -0,0 +1,464 @@ +package e2e_test + +import ( + "context" + "crypto/tls" + "net/http" + "net/url" + "os" + "strings" + "time" + + resty "github.com/go-resty/resty/v2" + "github.com/gogatekeeper/gatekeeper/pkg/constant" + . "github.com/onsi/ginkgo/v2" //nolint:revive //we want to use it for ginkgo + . "github.com/onsi/gomega" //nolint:revive //we want to use it for gomega + "golang.org/x/sync/errgroup" +) + +var _ = Describe("Code Flow login/logout all normalization disabled", func() { + var ( + portNum string + proxyAddress string + server *http.Server + ) + + errGroup, _ := errgroup.WithContext(context.Background()) + + AfterEach(func() { + if server != nil { + err := server.Shutdown(context.Background()) + Expect(err).NotTo(HaveOccurred()) + } + + if errGroup != nil { + err := errGroup.Wait() + Expect(err).NotTo(HaveOccurred()) + } + }) + + BeforeEach(func() { + var ( + err error + upstreamSvcPort string + ) + + server, upstreamSvcPort = startAndWaitTestUpstream(errGroup, false, false, false) + portNum, err = generateRandomPort() + Expect(err).NotTo(HaveOccurred()) + + proxyAddress = localURI + portNum + + //nolint:goconst + proxyArgs := []string{ + "--discovery-url=" + idpRealmURI, + "--openid-provider-timeout=300s", + "--tls-openid-provider-ca-certificate=" + tlsCaCertificate, + "--tls-openid-provider-client-certificate=" + tlsCertificate, + "--tls-openid-provider-client-private-key=" + tlsPrivateKey, + "--listen=" + allInterfaces + portNum, + "--client-id=" + testClient, + "--client-secret=" + testClientSecret, + "--upstream-url=" + localURI + upstreamSvcPort, + "--no-redirects=false", + "--skip-access-token-clientid-check=true", + "--skip-access-token-issuer-check=true", + "--enable-idp-session-check=false", + "--enable-default-deny=false", + "--resources=uri=" + postLoginRedirectPath + "|roles=uma_authorization,offline_access", + "--resources=uri=/|roles=uma_authorization,offline_access", + "--resources=uri=/.%2e/../%2F/api/v1/%61uth/some*|roles=uma_authorization,offline_access", + "--resources=uri=" + anyURI + "|roles=uma_authorization,offline_access", + "--resources=uri=/../api/v1/%61uth/some|roles=non-existent", + "--openid-provider-retry-count=30", + "--enable-refresh-tokens=true", + "--encryption-key=" + testKey, + "--secure-cookie=false", + "--enable-register-handler=true", + "--enable-encrypted-token=false", + "--enable-id-token-claims=true", + "--enable-id-token-cookie=true", + "--enable-user-info-claims=true", + "--add-claims=email_verified", + "--add-claims=email", + "--enable-pkce=false", + "--tls-cert=" + tlsCertificate, + "--tls-private-key=" + tlsPrivateKey, + "--upstream-ca=" + tlsCaCertificate, + "--normalize-path=false", + "--normalize-path-upstream=false", + "--merge-slashes=false", + "--merge-slashes-upstream=false", + "--path-escaped-slashes=true", + "--path-escaped-slashes-upstream=true", + "--enable-logging=true", + } + + osArgs := make([]string, 0, 1+len(proxyArgs)) + osArgs = append(osArgs, os.Args[0]) + osArgs = append(osArgs, proxyArgs...) + startAndWait(portNum, osArgs) + }) + + When("Performing standard login", func() { + It( + "should login with user/password and logout successfully", + Label("code_flow"), + Label("basic_case"), + Label("normalization_disabled"), + func(_ context.Context) { + var err error + + ctx, cancel := context.WithTimeout(context.Background(), tlsTimeout) + dialer := tls.Dialer{ + Config: &tls.Config{ + ServerName: "localhost", + RootCAs: caPool, + MinVersion: tls.VersionTLS13, + }, + } + + conn, err := dialer.DialContext(ctx, "tcp", ":"+portNum) + Expect(err).NotTo(HaveOccurred()) + + loginPath := proxyAddress + postLoginRedirectPath + "?param=val1" + rClient := resty.New() + rClient.SetTLSClientConfig(&tls.Config{RootCAs: caPool, MinVersion: tls.VersionTLS13}) + resp := codeFlowLogin(rClient, loginPath, http.StatusOK, testUser, testPass) + Expect(resp.Header().Get("Proxy-Accepted")).To(Equal("true")) + body := resp.Body() + Expect(strings.Contains(string(body), postLoginRedirectPath+"?param=val1")).To(BeTrue()) + + jarURI, err := url.Parse(proxyAddress + postLoginRedirectPath) + Expect(err).NotTo(HaveOccurred()) + + cookiesLogin := rClient.GetClient().Jar.Cookies(jarURI) + + var ( + accessCookieLogin string + idCookieLogin string + ) + + for _, cook := range cookiesLogin { + if cook.Name == constant.AccessCookie { + accessCookieLogin = cook.Value + } + + if cook.Name == constant.IDTokenCookie { + idCookieLogin = cook.Value + } + } + + tricky := "/.%2e/../%2F/api/v1/%61uth/some" + rawRequest := "GET " + tricky + " HTTP/1.1\r\n" + repeatRaw := "Host: localhost\r\n" + repeatRaw += "Cookie: " + constant.AccessCookie + "=" + accessCookieLogin + "; " + repeatRaw += constant.IDTokenCookie + "=" + idCookieLogin + repeatRaw += "\r\n\r\n" + rawRequest += repeatRaw + + to := time.Now().Add(60 * time.Second) + err = conn.SetDeadline(to) + Expect(err).NotTo(HaveOccurred()) + + _, err = conn.Write([]byte(rawRequest)) + Expect(err).NotTo(HaveOccurred()) + + rawResp := make([]byte, 1024) + _, err = conn.Read(rawResp) + + cancel() + conn.Close() + + Expect(err).NotTo(HaveOccurred()) + Expect(strings.Contains(string(rawResp), tricky)).To(BeTrue()) + Expect(strings.Contains(string(rawResp), "200")).To(BeTrue()) + + ctx, cancel = context.WithTimeout(context.Background(), tlsTimeout) + conn, err = dialer.DialContext(ctx, "tcp", ":"+portNum) + Expect(err).NotTo(HaveOccurred()) + + tricky = "//" + rawRequest = "GET " + tricky + " HTTP/1.1\r\n" + rawRequest += repeatRaw + + _, err = conn.Write([]byte(rawRequest)) + Expect(err).NotTo(HaveOccurred()) + + rawResp = make([]byte, 1024) + _, err = conn.Read(rawResp) + + cancel() + conn.Close() + + Expect(err).NotTo(HaveOccurred()) + Expect(strings.Contains(string(rawResp), tricky)).To(BeTrue()) + Expect(strings.Contains(string(rawResp), "200")).To(BeTrue()) + + ctx, cancel = context.WithTimeout(context.Background(), tlsTimeout) + conn, err = dialer.DialContext(ctx, "tcp", ":"+portNum) + Expect(err).NotTo(HaveOccurred()) + + tricky = "//really%2e///tricky//" + rawRequest = "GET " + tricky + " HTTP/1.1\r\n" + rawRequest += repeatRaw + + _, err = conn.Write([]byte(rawRequest)) + Expect(err).NotTo(HaveOccurred()) + + rawResp = make([]byte, 1024) + _, err = conn.Read(rawResp) + + cancel() + conn.Close() + + Expect(err).NotTo(HaveOccurred()) + Expect(strings.Contains(string(rawResp), tricky)).To(BeTrue()) + Expect(strings.Contains(string(rawResp), "200")).To(BeTrue()) + + tricky = "/../api/v1/%61uth/some" + resp, err = rClient.R().Get(proxyAddress + tricky) + Expect(err).NotTo(HaveOccurred()) + + body = resp.Body() + Expect(strings.Contains(string(body), tricky)).NotTo(BeTrue()) + Expect(resp.StatusCode()).To(Equal(http.StatusForbidden)) + + resp, err = rClient.R().Get(proxyAddress + logoutURI) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.StatusCode()).To(Equal(http.StatusOK)) + + rClient.SetRedirectPolicy(resty.NoRedirectPolicy()) + resp, _ = rClient.R().Get(proxyAddress) + Expect(resp.StatusCode()).To(Equal(http.StatusSeeOther)) + }, + ) + }) +}) + +var _ = Describe("Code Flow login/logout all normalization enabled", func() { + var ( + portNum string + proxyAddress string + server *http.Server + ) + + errGroup, _ := errgroup.WithContext(context.Background()) + + AfterEach(func() { + if server != nil { + err := server.Shutdown(context.Background()) + Expect(err).NotTo(HaveOccurred()) + } + + if errGroup != nil { + err := errGroup.Wait() + Expect(err).NotTo(HaveOccurred()) + } + }) + + BeforeEach(func() { + var ( + err error + upstreamSvcPort string + ) + + server, upstreamSvcPort = startAndWaitTestUpstream(errGroup, false, false, false) + portNum, err = generateRandomPort() + Expect(err).NotTo(HaveOccurred()) + + proxyAddress = localURI + portNum + + proxyArgs := []string{ + "--discovery-url=" + idpRealmURI, + "--openid-provider-timeout=300s", + "--tls-openid-provider-ca-certificate=" + tlsCaCertificate, + "--tls-openid-provider-client-certificate=" + tlsCertificate, + "--tls-openid-provider-client-private-key=" + tlsPrivateKey, + "--listen=" + allInterfaces + portNum, + "--client-id=" + testClient, + "--client-secret=" + testClientSecret, + "--upstream-url=" + localURI + upstreamSvcPort, + "--no-redirects=false", + "--skip-access-token-clientid-check=true", + "--skip-access-token-issuer-check=true", + "--enable-idp-session-check=false", + "--enable-default-deny=false", + "--resources=uri=/|roles=uma_authorization,offline_access", + "--resources=uri=/api/v1/auth/some*|roles=uma_authorization,offline_access", + "--resources=uri=" + anyURI + "|roles=uma_authorization,offline_access", + "--resources=uri=/../api/v1/%61uth/some|roles=non-existent", + "--openid-provider-retry-count=30", + "--enable-refresh-tokens=true", + "--encryption-key=" + testKey, + "--secure-cookie=false", + "--post-login-redirect-path=" + postLoginRedirectPath, + "--enable-register-handler=true", + "--enable-encrypted-token=false", + "--enable-id-token-claims=true", + "--enable-id-token-cookie=true", + "--enable-user-info-claims=true", + "--add-claims=email_verified", + "--add-claims=email", + "--enable-pkce=false", + "--tls-cert=" + tlsCertificate, + "--tls-private-key=" + tlsPrivateKey, + "--upstream-ca=" + tlsCaCertificate, + "--normalize-path=true", + "--normalize-path-upstream=true", + "--merge-slashes=true", + "--merge-slashes-upstream=true", + "--path-escaped-slashes=false", + "--path-escaped-slashes-upstream=false", + "--verbose=true", + } + + osArgs := make([]string, 0, 1+len(proxyArgs)) + osArgs = append(osArgs, os.Args[0]) + osArgs = append(osArgs, proxyArgs...) + startAndWait(portNum, osArgs) + }) + + When("Performing standard login", func() { + It( + "should login with user/password and logout successfully", + Label("code_flow"), + Label("basic_case"), + Label("normalization_enabled"), + func(_ context.Context) { + var err error + + ctx, cancel := context.WithTimeout(context.Background(), tlsTimeout) + dialer := tls.Dialer{ + Config: &tls.Config{ + ServerName: "localhost", + RootCAs: caPool, + MinVersion: tls.VersionTLS13, + }, + } + + conn, err := dialer.DialContext(ctx, "tcp", ":"+portNum) + Expect(err).NotTo(HaveOccurred()) + + rClient := resty.New() + rClient.SetTLSClientConfig(&tls.Config{RootCAs: caPool, MinVersion: tls.VersionTLS13}) + resp := codeFlowLogin(rClient, proxyAddress, http.StatusOK, testUser, testPass) + Expect(resp.Header().Get("Proxy-Accepted")).To(Equal("true")) + body := resp.Body() + Expect(strings.Contains(string(body), postLoginRedirectPath)).To(BeTrue()) + + jarURI, err := url.Parse(proxyAddress) + Expect(err).NotTo(HaveOccurred()) + + cookiesLogin := rClient.GetClient().Jar.Cookies(jarURI) + + var ( + accessCookieLogin string + idCookieLogin string + ) + + for _, cook := range cookiesLogin { + if cook.Name == constant.AccessCookie { + accessCookieLogin = cook.Value + } + + if cook.Name == constant.IDTokenCookie { + idCookieLogin = cook.Value + } + } + + time.Sleep(testAccessTokenExp) + + tricky := "/.%2e/../%2F/api/v1/%61uth/some" + normalized := "/api/v1/auth/some" + resp, err = rClient.R().Get(proxyAddress + tricky) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.Header().Get("Proxy-Accepted")).To(Equal("true")) + body = resp.Body() + Expect(strings.Contains(string(body), normalized)).To(BeTrue()) + Expect(resp.StatusCode()).To(Equal(http.StatusOK)) + Expect(err).NotTo(HaveOccurred()) + + jarURI, err = url.Parse(proxyAddress + tricky) + Expect(err).NotTo(HaveOccurred()) + + cookiesLogin = rClient.GetClient().Jar.Cookies(jarURI) + + for _, cook := range cookiesLogin { + if cook.Name == constant.AccessCookie { + accessCookieLogin = cook.Value + } + + if cook.Name == constant.IDTokenCookie { + idCookieLogin = cook.Value + } + } + + tricky = "/.%2e/../%2F/api/v1/%61uth/some%" + rawRequest := "GET " + tricky + " HTTP/1.1\r\n" + repeatRaw := "Host: localhost\r\n" + repeatRaw += "Cookie: " + constant.AccessCookie + "=" + accessCookieLogin + "; " + repeatRaw += constant.IDTokenCookie + "=" + idCookieLogin + repeatRaw += "\r\n\r\n" + rawRequest += repeatRaw + + to := time.Now().Add(10 * time.Second) + err = conn.SetDeadline(to) + Expect(err).NotTo(HaveOccurred()) + + _, err = conn.Write([]byte(rawRequest)) + Expect(err).NotTo(HaveOccurred()) + + rawResp := make([]byte, 1024) + _, err = conn.Read(rawResp) + + Expect(err).NotTo(HaveOccurred()) + + cancel() + conn.Close() + + Expect(err).NotTo(HaveOccurred()) + Expect(strings.Contains(string(rawResp), "400")).To(BeTrue()) + + tricky = "/../api/v1/%61uth/some" + resp, err = rClient.R().Get(proxyAddress + tricky) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.Header().Get("Proxy-Accepted")).To(Equal("true")) + body = resp.Body() + Expect(strings.Contains(string(body), normalized)).To(BeTrue()) + Expect(resp.StatusCode()).To(Equal(http.StatusOK)) + Expect(err).NotTo(HaveOccurred()) + + ctx, cancel = context.WithTimeout(context.Background(), tlsTimeout) + conn, err = dialer.DialContext(ctx, "tcp", ":"+portNum) + Expect(err).NotTo(HaveOccurred()) + + tricky = "//really/tricky" + normalized = "/really/tricky" + rawRequest = "GET " + tricky + " HTTP/1.1\r\n" + rawRequest += repeatRaw + + _, err = conn.Write([]byte(rawRequest)) + Expect(err).NotTo(HaveOccurred()) + + rawResp = make([]byte, 1024) + _, err = conn.Read(rawResp) + + cancel() + conn.Close() + + Expect(err).NotTo(HaveOccurred()) + Expect(strings.Contains(string(rawResp), normalized)).To(BeTrue()) + Expect(strings.Contains(string(rawResp), "200")).To(BeTrue()) + + resp, err = rClient.R().Get(proxyAddress + logoutURI) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.StatusCode()).To(Equal(http.StatusOK)) + + rClient.SetRedirectPolicy(resty.NoRedirectPolicy()) + resp, _ = rClient.R().Get(proxyAddress) + Expect(resp.StatusCode()).To(Equal(http.StatusSeeOther)) + }, + ) + }) +}) diff --git a/e2e/e2e_redis_test.go b/e2e/e2e_redis_test.go index 1790f22a..07762903 100644 --- a/e2e/e2e_redis_test.go +++ b/e2e/e2e_redis_test.go @@ -83,7 +83,8 @@ var _ = Describe("Code Flow PKCE login/logout with mTLS REDIS", func() { }) When("Peforming standard login", func() { - It("should login with user/password and logout successfully", + It( + "should login with user/password and logout successfully", Label("code_flow", "pkce", "redis"), func(_ context.Context) { var err error @@ -186,7 +187,8 @@ var _ = Describe("Code Flow PKCE login/logout with mTLS REDIS CLUSTER", func() { }) When("Peforming standard login", func() { - It("should login with user/password and logout successfully", + It( + "should login with user/password and logout successfully", Label("code_flow", "pkce", "redis_cluster"), func(_ context.Context) { var err error @@ -289,7 +291,8 @@ var _ = Describe("Code Flow PKCE login/logout with mTLS REDIS SENTINEL", func() }) When("Peforming standard login", func() { - It("should login with user/password and logout successfully", + It( + "should login with user/password and logout successfully", Label("code_flow", "pkce", "redis_cluster"), func(_ context.Context) { var err error diff --git a/e2e/e2e_root_test.go b/e2e/e2e_root_test.go index 50249757..fb306a08 100644 --- a/e2e/e2e_root_test.go +++ b/e2e/e2e_root_test.go @@ -105,7 +105,8 @@ var _ = Describe("Code Flow login/logout compression and encryption Auth Scheme }) When("Performing standard login", func() { - It("should login with user/password and logout successfully", + It( + "should login with user/password and logout successfully", Label("code_flow"), Label("compression_auth_scheme"), Label("auth_scheme_cookie"), diff --git a/e2e/e2e_test.go b/e2e/e2e_test.go index 7fc99be9..42402da1 100644 --- a/e2e/e2e_test.go +++ b/e2e/e2e_test.go @@ -1,6 +1,5 @@ package e2e_test -// import ( "bytes" "compress/flate" diff --git a/e2e/e2e_uma_test.go b/e2e/e2e_uma_test.go index a360da79..8d04520a 100644 --- a/e2e/e2e_uma_test.go +++ b/e2e/e2e_uma_test.go @@ -256,7 +256,8 @@ var _ = Describe("UMA Code Flow authorization with method scope", func() { rClient.SetRedirectPolicy(resty.NoRedirectPolicy()) resp, _ = rClient.R().Get(proxyAddress + umaAllowedPath) Expect(resp.StatusCode()).To(Equal(http.StatusSeeOther)) - }) + }, + ) }) }) diff --git a/e2e/e2e_websocket_test.go b/e2e/e2e_websocket_test.go index 48d803b6..2224f9c8 100644 --- a/e2e/e2e_websocket_test.go +++ b/e2e/e2e_websocket_test.go @@ -85,7 +85,8 @@ var _ = Describe("NoRedirects Websocket login/logout", func() { }) When("Performing standard login", func() { - It("should login with service account and logout successfully", + It( + "should login with service account and logout successfully", Label("api_flow"), Label("websocket"), func(ctx context.Context) { @@ -98,7 +99,8 @@ var _ = Describe("NoRedirects Websocket login/logout", func() { rClient := resty.New() hClient := rClient.SetTLSClientConfig( - &tls.Config{RootCAs: caPool, MinVersion: tls.VersionTLS13}).GetClient() + &tls.Config{RootCAs: caPool, MinVersion: tls.VersionTLS13}, + ).GetClient() oidcLibCtx := context.WithValue(ctx, oauth2.HTTPClient, hClient) respToken, err := conf.Token(oidcLibCtx) @@ -150,7 +152,8 @@ var _ = Describe("NoRedirects Websocket login/logout", func() { }) When("Performing websocket connection on http backend", func() { - It("websocket upgrade should fail", + It( + "websocket upgrade should fail", Label("api_flow"), Label("websocket_fail"), func(ctx context.Context) { @@ -163,7 +166,8 @@ var _ = Describe("NoRedirects Websocket login/logout", func() { rClient := resty.New() hClient := rClient.SetTLSClientConfig( - &tls.Config{RootCAs: caPool, MinVersion: tls.VersionTLS13}).GetClient() + &tls.Config{RootCAs: caPool, MinVersion: tls.VersionTLS13}, + ).GetClient() oidcLibCtx := context.WithValue(ctx, oauth2.HTTPClient, hClient) respToken, err := conf.Token(oidcLibCtx) @@ -189,7 +193,8 @@ var _ = Describe("NoRedirects Websocket login/logout", func() { rClient.SetTLSClientConfig(&tls.Config{RootCAs: caPool, MinVersion: tls.VersionTLS13}) request := rClient.SetRedirectPolicy( - resty.NoRedirectPolicy()).R().SetAuthToken(respToken.AccessToken) + resty.NoRedirectPolicy(), + ).R().SetAuthToken(respToken.AccessToken) resp, err := request.Get(proxyAddress) Expect(err).NotTo(HaveOccurred()) Expect(resp.StatusCode()).To(Equal(http.StatusOK)) @@ -271,7 +276,8 @@ var _ = Describe("Code Flow websocket login/logout", func() { }) When("Performing standard websocket login", func() { - It("should login with user/password and logout successfully", + It( + "should login with user/password and logout successfully", Label("code_flow"), Label("basic_case"), func(_ context.Context) { @@ -345,7 +351,8 @@ var _ = Describe("Code Flow websocket login/logout", func() { }) When("Performing websocket connection on http backend", func() { - It("websocket upgrade should fail", + It( + "websocket upgrade should fail", Label("code_flow"), Label("websocket"), func(_ context.Context) { diff --git a/pkg/config/core/resource_test.go b/pkg/config/core/resource_test.go index 19deac90..7a90b7c7 100644 --- a/pkg/config/core/resource_test.go +++ b/pkg/config/core/resource_test.go @@ -22,6 +22,7 @@ import ( "github.com/go-chi/chi/v5" "github.com/gogatekeeper/gatekeeper/pkg/config/core" + "github.com/gogatekeeper/gatekeeper/pkg/constant" "github.com/gogatekeeper/gatekeeper/pkg/utils" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -127,7 +128,7 @@ func TestResourceParseOk(t *testing.T) { { Option: "uri=/*|methods=any", Resource: &core.Resource{ - URL: "/*", + URL: constant.AllPath, Methods: utils.AllHTTPMethods, }, Ok: true, @@ -135,7 +136,7 @@ func TestResourceParseOk(t *testing.T) { { Option: "uri=/*|methods=any", Resource: &core.Resource{ - URL: "/*", + URL: constant.AllPath, Methods: utils.AllHTTPMethods, }, Ok: true, @@ -143,7 +144,7 @@ func TestResourceParseOk(t *testing.T) { { Option: "uri=/*|groups=admin,test", Resource: &core.Resource{ - URL: "/*", + URL: constant.AllPath, Methods: utils.AllHTTPMethods, Groups: []string{"admin", "test"}, }, @@ -152,7 +153,7 @@ func TestResourceParseOk(t *testing.T) { { Option: "uri=/*|groups=admin", Resource: &core.Resource{ - URL: "/*", + URL: constant.AllPath, Methods: utils.AllHTTPMethods, Groups: []string{"admin"}, }, @@ -161,7 +162,7 @@ func TestResourceParseOk(t *testing.T) { { Option: "uri=/*|require-any-role=true", Resource: &core.Resource{ - URL: "/*", + URL: constant.AllPath, Methods: utils.AllHTTPMethods, RequireAnyRole: true, }, diff --git a/pkg/constant/constant.go b/pkg/constant/constant.go index 015e53aa..7886222b 100644 --- a/pkg/constant/constant.go +++ b/pkg/constant/constant.go @@ -144,6 +144,8 @@ const ( NegateRegexChar = "!" IdentityHeaderEncoding = "UTF-8" + + DoubleSlash = "//" ) //nolint:gochecknoglobals diff --git a/pkg/keycloak/config/config.go b/pkg/keycloak/config/config.go index b7949b97..b3725d37 100644 --- a/pkg/keycloak/config/config.go +++ b/pkg/keycloak/config/config.go @@ -147,6 +147,12 @@ type Config struct { LogSamplingAfter int `env:"LOG_SAMPLING_AFTER" json:"log-sampling-after" usage:"each n-th number message is logged, after initial messages logged" yaml:"log-sampling-after"` OpenIDProviderRetryCount int `env:"OPENID_PROVIDER_RETRY_COUNT" json:"openid-provider-retry-count,omitempty" usage:"number of retries for retrieving openid configuration" yaml:"openid-provider-retry-count"` OpenIDProviderTimeout time.Duration `env:"OPENID_PROVIDER_TIMEOUT" json:"openid-provider-timeout,omitempty" usage:"timeout for openid configuration on .well-known/openid-configuration" yaml:"openid-provider-timeout"` + NormalizePath bool `env:"NORMALIZE_PATH" json:"normalize-path" usage:"normalizes path according RFC 3986, except slashes, at start of processing chain, used in internal chains" yaml:"normalize-path"` + NormalizePathUpstream bool `env:"NORMALIZE_PATH_UPSTREAM" json:"normalize-path-upstream" usage:"normalizes path for upstream according RFC 3986, except slashes" yaml:"normalize-path-upstream"` + MergeSlashes bool `env:"MERGE_SLASHES" json:"merge-slashes" usage:"merges slashes at start of processing, at start of processing chain, used in internal chains" yaml:"merge-slashes"` + MergeSlashesUpstream bool `env:"MERGE_SLASHES_UPSTREAM" json:"merge-slashes-upstream" usage:"merges slashes for path to upstream" yaml:"merge-slashes-upstream"` + PathEscapedSlashes bool `env:"PATH_ESCAPED_SLASHES" json:"path-escaped-slashes" usage:"escape slashes, means preserve hex encoding, at start of processing chain, used in internal chains" yaml:"path-escaped-slashes"` + PathEscapedSlashesUpstream bool `env:"PATH_ESCAPED_SLASHES_UPSTREAM" json:"path-escaped-slashes-upstream" usage:"escape slashes, means preserve hex encoding for path sent upstream" yaml:"path-escaped-slashes-upstream"` EnableProfiling bool `env:"ENABLE_PROFILING" json:"enable-profiling" usage:"switching on the golang profiling via pprof on /debug/pprof, /debug/pprof/heap etc" yaml:"enable-profiling"` EnableIDPSessionCheck bool `env:"ENABLE_IDP_SESSION_CHECK" json:"enable-idp-session-check" usage:"during token validation it also checks if user session is still present, useful for multiapp logout" yaml:"enable-idp-session-check"` EnabledSelfSignedTLS bool `env:"ENABLE_SELF_SIGNED_TLS" json:"enable-self-signed-tls" usage:"create self signed certificates for the proxy" yaml:"enable-self-signed-tls"` @@ -283,6 +289,12 @@ func NewDefaultConfig() *Config { OpaTimeout: constant.DefaultOpaTimeout, LogSamplingInitial: constant.DefaultLogSamplingInitial, LogSamplingAfter: constant.DefaultLogSamplingAfter, + NormalizePath: true, + NormalizePathUpstream: true, + MergeSlashes: true, + MergeSlashesUpstream: true, + PathEscapedSlashes: false, + PathEscapedSlashesUpstream: false, } } diff --git a/pkg/keycloak/proxy/server.go b/pkg/keycloak/proxy/server.go index 212359f7..aec6ee25 100644 --- a/pkg/keycloak/proxy/server.go +++ b/pkg/keycloak/proxy/server.go @@ -337,7 +337,15 @@ func (r *OauthProxy) useDefaultStack( } // @step: enable the entrypoint middleware - engine.Use(gmiddleware.EntrypointMiddleware(r.Log)) + engine.Use(gmiddleware.EntrypointMiddleware( + r.Log, + r.Config.NormalizePath, + r.Config.NormalizePathUpstream, + r.Config.MergeSlashes, + r.Config.MergeSlashesUpstream, + r.Config.PathEscapedSlashes, + r.Config.PathEscapedSlashesUpstream, + )) if r.Config.NoProxy { engine.Use(gmiddleware.ForwardAuthMiddleware(r.Log, r.Config.OAuthURI)) @@ -726,15 +734,16 @@ func (r *OauthProxy) CreateReverseProxy() error { } eng.Get(constant.CallbackURL, oauthCallbackHand) - eng.Get(constant.ExpiredURL, handlers.ExpirationHandler( - r.Log, - r.Provider, - r.Config.ClientID, - r.Config.SkipAccessTokenClientIDCheck, - r.Config.SkipAccessTokenIssuerCheck, - getIdentity, - r.Config.CookieAccessName, - ), + eng.Get( + constant.ExpiredURL, handlers.ExpirationHandler( + r.Log, + r.Provider, + r.Config.ClientID, + r.Config.SkipAccessTokenClientIDCheck, + r.Config.SkipAccessTokenIssuerCheck, + getIdentity, + r.Config.CookieAccessName, + ), ) if r.Config.EnableLogoutAuth { @@ -887,7 +896,8 @@ func (r *OauthProxy) CreateReverseProxy() error { if r.Config.EnableLoA && res.NoRedirect { r.Log.Warn( "disabling LoA for resource, no-redirect=true for resource", - zap.String("resource", res.URL)) + zap.String("resource", res.URL), + ) } var loAMid func(http.Handler) http.Handler @@ -943,7 +953,8 @@ func (r *OauthProxy) CreateReverseProxy() error { r.Log.Warn( "disabling EnableUma for resource, no-redirect=true for resource", - zap.String("resource", res.URL)) + zap.String("resource", res.URL), + ) } authzMiddleware := authorizationMiddleware( diff --git a/pkg/proxy/cookie/cookies.go b/pkg/proxy/cookie/cookies.go index 8e4d1fd9..7fa04d1b 100644 --- a/pkg/proxy/cookie/cookies.go +++ b/pkg/proxy/cookie/cookies.go @@ -177,7 +177,10 @@ func (cm *Manager) DropStateParameterCookie(req *http.Request, wrt http.Response wrt.WriteHeader(http.StatusInternalServerError) } - requestURI := req.URL.RequestURI() + requestURI := req.URL.RawPath + if req.URL.RawQuery != "" { + requestURI += "?" + req.URL.RawQuery + } if cm.NoProxy && !cm.NoRedirects { xReqURI := req.Header.Get(constant.HeaderXForwardedURI) diff --git a/pkg/proxy/middleware/base.go b/pkg/proxy/middleware/base.go index 0f7d1478..ccb22d3e 100644 --- a/pkg/proxy/middleware/base.go +++ b/pkg/proxy/middleware/base.go @@ -12,7 +12,6 @@ import ( "strings" "time" - "github.com/PuerkitoBio/purell" "github.com/elazarl/goproxy" "github.com/go-chi/chi/v5/middleware" uuid "github.com/gofrs/uuid" @@ -26,30 +25,106 @@ import ( "go.uber.org/zap" ) -const ( - normalizeFlags purell.NormalizationFlags = purell.FlagRemoveDotSegments | purell.FlagRemoveDuplicateSlashes -) - -func EntrypointMiddleware(logger *zap.Logger) func(http.Handler) http.Handler { +//nolint:cyclop +func EntrypointMiddleware( + logger *zap.Logger, + normalizePath bool, + normalizePathUpstream bool, + mergeSlashes bool, + mergeSlashesUpstream bool, + pathEscapedSlashes bool, + pathEscapedSlashesUpstream bool, +) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { + internalEqUpstream := false + isMergeSame := mergeSlashes == mergeSlashesUpstream + isNormalizeSame := normalizePath == normalizePathUpstream + isPathEscapeSame := pathEscapedSlashes == pathEscapedSlashesUpstream + + if isMergeSame && isNormalizeSame && isPathEscapeSame { + internalEqUpstream = true + } + return http.HandlerFunc(func(wrt http.ResponseWriter, req *http.Request) { // @step: create a context for the request scope := &models.RequestScope{} // Save the exact formatting of the incoming request so we can use it later - scope.Path = req.URL.Path - scope.RawPath = req.URL.RawPath + originalPath := req.URL.Path + originalRawPath := req.URL.RawPath + originalOpaque := req.URL.Opaque + normalizedPath := req.URL.RawPath + normalizedPathUpstream := req.URL.RawPath + + if req.URL.RawPath == "" { + normalizedPathUpstream = req.URL.Path + normalizedPath = req.URL.Path + } + scope.Logger = logger - // We want to Normalize the URL so that we can more easily and accurately - // parse it to apply resource protection rules. - purell.NormalizeURL(req.URL, normalizeFlags) + logger.Debug("Original, received path", zap.String("path", originalPath)) + logger.Debug("OriginalRawPath, received raw path", zap.String("path", originalRawPath)) + + normalizedPath, err := utils.NormalizePath( + pathEscapedSlashes, + mergeSlashes, + normalizePath, + normalizedPath, + ) + if err != nil { + logger.Error( + "failed normalizing path", + zap.String("path", normalizedPath), + zap.Error(err), + ) + + return + } + + if internalEqUpstream { + normalizedPathUpstream = normalizedPath + } else { + normalizedPathUpstream, err = utils.NormalizePath( + pathEscapedSlashesUpstream, + mergeSlashesUpstream, + normalizePathUpstream, + normalizedPathUpstream, + ) + if err != nil { + logger.Error( + "failed normalizing upstream path", + zap.String("path", normalizedPathUpstream), + zap.Error(err), + ) - // ensure we have a slash in the url - if !strings.HasPrefix(req.URL.Path, "/") { - req.URL.Path = "/" + req.URL.Path + return + } } - req.URL.RawPath = req.URL.EscapedPath() + if !strings.HasPrefix(normalizedPath, "/") { + normalizedPath = "/" + normalizedPath + } + + if !strings.HasPrefix(normalizedPathUpstream, "/") { + normalizedPathUpstream = "/" + normalizedPathUpstream + } + + scope.Path = normalizedPathUpstream + scope.RawPath = normalizedPathUpstream + scope.Opaque = normalizedPathUpstream + + if strings.HasPrefix(normalizedPathUpstream, constant.DoubleSlash) { + scope.Opaque = "//fakeHost" + normalizedPathUpstream + } + + logger.Debug("Upstream, normalized path", zap.String("path", scope.Path)) + logger.Debug("Upstream, normalized raw path", zap.String("path", scope.RawPath)) + + req.URL.RawPath = normalizedPath + req.URL.Path = normalizedPath + + logger.Debug("Internal, normalized path", zap.String("path", req.URL.Path)) + logger.Debug("Internal, normalized raw path", zap.String("path", req.URL.RawPath)) resp := middleware.NewWrapResponseWriter(wrt, 1) start := time.Now() @@ -62,8 +137,9 @@ func EntrypointMiddleware(logger *zap.Logger) func(http.Handler) http.Handler { metrics.StatusMetric.WithLabelValues(strconv.Itoa(resp.Status()), req.Method).Inc() // place back the original uri for any later consumers - req.URL.Path = scope.Path - req.URL.RawPath = scope.RawPath + req.URL.Path = originalPath + req.URL.RawPath = originalRawPath + req.URL.Opaque = originalOpaque }) } } @@ -400,6 +476,7 @@ func ProxyMiddleware( if scope != nil { req.URL.Path = scope.Path req.URL.RawPath = scope.RawPath + req.URL.Opaque = scope.Opaque } if v := req.Header.Get("Host"); v != "" { @@ -411,7 +488,8 @@ func ProxyMiddleware( if utils.IsUpgradedConnection(req) { clientIP := utils.RealIP(req) - logger.Debug("upgrading the connnection", + logger.Debug( + "upgrading the connnection", zap.String("client_ip", clientIP), zap.String("remote_addr", req.RemoteAddr), ) diff --git a/pkg/proxy/models/models.go b/pkg/proxy/models/models.go index ffe6bf1c..43f72edf 100644 --- a/pkg/proxy/models/models.go +++ b/pkg/proxy/models/models.go @@ -8,6 +8,7 @@ type RequestScope struct { Logger *zap.Logger Path string RawPath string + Opaque string AccessDenied bool NoProxy bool } diff --git a/pkg/testsuite/fake_authserver.go b/pkg/testsuite/fake_authserver.go index dcdcfc4f..9efdf165 100644 --- a/pkg/testsuite/fake_authserver.go +++ b/pkg/testsuite/fake_authserver.go @@ -403,7 +403,7 @@ func (r *fakeAuthServer) ResourceHandler(wrt http.ResponseWriter, _ *http.Reques OwnerManagedAccess: false, Attributes: struct{}{}, ID: "6ef1b62e-0fd4-47f2-81fc-eead97a01c22", - URIS: []string{"/*"}, + URIS: []string{constant.AllPath}, ResourceScopes: []struct { Name string `json:"name"` }{{Name: "test"}}, diff --git a/pkg/testsuite/fake_upstream.go b/pkg/testsuite/fake_upstream.go index 671b231e..06175f7e 100644 --- a/pkg/testsuite/fake_upstream.go +++ b/pkg/testsuite/fake_upstream.go @@ -15,6 +15,7 @@ import ( // FakeUpstreamResponse is the response from fake upstream. type FakeUpstreamResponse struct { URI string `json:"uri"` + RawURI string `json:"raw_uri"` Method string `json:"method"` Address string `json:"address"` Headers http.Header `json:"headers"` @@ -100,11 +101,14 @@ func (f *FakeUpstreamService) ServeHTTP(wrt http.ResponseWriter, req *http.Reque wrt.Header().Set(constant.HeaderContentType, "application/json") wrt.Header().Add("Set-Cookie", "test-cookie=test_value") + uri := req.URL.Path + if req.URL.RawQuery != "" { + uri += "?" + req.URL.RawQuery + } + content, err := json.Marshal(&FakeUpstreamResponse{ - // r.RequestURI is what was received by the proxy. - // r.URL.String() is what is actually sent to the upstream service. - // KEYCLOAK-10864, KEYCLOAK-11276, KEYCLOAK-13315 - URI: req.URL.String(), + URI: uri, + RawURI: req.URL.RawPath, Method: req.Method, Address: req.RemoteAddr, Headers: req.Header, diff --git a/pkg/testsuite/handlers_test.go b/pkg/testsuite/handlers_test.go index 4a2c6ea6..1d0289a9 100644 --- a/pkg/testsuite/handlers_test.go +++ b/pkg/testsuite/handlers_test.go @@ -924,10 +924,10 @@ func TestAuthorizationURL(t *testing.T) { ExpectedCode: http.StatusSeeOther, }, { - URI: "/help/../admin", - Redirects: true, - ExpectedLocation: "/oauth/authorize?state", - ExpectedCode: http.StatusSeeOther, + URI: "/help/../admin", + Redirects: true, + ExpectedProxy: true, + ExpectedCode: http.StatusOK, }, { URI: "/admin?test=yes&test1=test", diff --git a/pkg/testsuite/middleware_test.go b/pkg/testsuite/middleware_test.go index e6776ea2..85212767 100644 --- a/pkg/testsuite/middleware_test.go +++ b/pkg/testsuite/middleware_test.go @@ -49,6 +49,7 @@ import ( "go.uber.org/zap/zapcore" ) +//nolint:goconst func TestMetricsMiddleware(t *testing.T) { cfg := newFakeKeycloakConfig() cfg.EnableMetrics = true @@ -108,6 +109,7 @@ func TestMetricsMiddleware(t *testing.T) { p.RunTests(t, requests) } +//nolint:goconst func TestOauthRequests(t *testing.T) { cfg := newFakeKeycloakConfig() requests := []fakeRequest{ @@ -130,7 +132,7 @@ func TestOauthRequests(t *testing.T) { newFakeProxy(cfg, &fakeAuthConfig{}).RunTests(t, requests) } -//nolint:cyclop +//nolint:cyclop,goconst func TestAdminListener(t *testing.T) { testCases := []struct { Name string @@ -354,21 +356,22 @@ func TestOauthRequestsWithBaseURI(t *testing.T) { func TestMethodExclusions(t *testing.T) { cfg := newFakeKeycloakConfig() cfg.NoRedirects = true + postPath := "/post" cfg.Resources = []*core.Resource{ { - URL: "/post", + URL: postPath, Methods: []string{http.MethodPost, http.MethodPut}, }, } requests := []fakeRequest{ { // we should get a 401 - URI: "/post", + URI: postPath, Method: http.MethodPost, ExpectedCode: http.StatusUnauthorized, Redirects: false, }, { // we should be permitted - URI: "/post", + URI: postPath, Method: http.MethodGet, ExpectedProxy: true, ExpectedCode: http.StatusOK, @@ -378,113 +381,992 @@ func TestMethodExclusions(t *testing.T) { newFakeProxy(cfg, &fakeAuthConfig{}).RunTests(t, requests) } -func TestPreserveURLEncoding(t *testing.T) { +//nolint:funlen,goconst +func TestPathNormalizationRedirects(t *testing.T) { cfg := newFakeKeycloakConfig() cfg.EnableLogging = true - cfg.NoRedirects = true - cfg.Resources = []*core.Resource{ + cfg.NoRedirects = false + + testCases := []struct { + Name string + ProxySettings func(c *config.Config) + ExecutionSettings []fakeRequest + }{ { - URL: "/api/v2/*", - Methods: utils.AllHTTPMethods, - Roles: []string{"dev"}, + Name: "AllNormalizationDisabled", + ProxySettings: func(cfg *config.Config) { + cfg.NormalizePath = false + cfg.NormalizePathUpstream = false + cfg.MergeSlashes = false + cfg.MergeSlashesUpstream = false + cfg.PathEscapedSlashes = true + cfg.PathEscapedSlashesUpstream = true + cfg.Resources = []*core.Resource{ + { + URL: "/.%2e/../%2F/%5c/api/v1/%61uth/some*", + Methods: utils.AllHTTPMethods, + Roles: []string{"dev"}, + }, + { + URL: "/api/v1/auth*", + Methods: utils.AllHTTPMethods, + Roles: []string{"admin"}, + }, + { + URL: constant.AllPath, + Methods: utils.AllHTTPMethods, + Roles: []string{"user"}, + }, + } + }, + ExecutionSettings: []fakeRequest{ + { + URI: "//", + HasToken: true, + Redirects: true, + Roles: []string{"user"}, + ExpectedProxy: true, + ExpectedCode: http.StatusOK, + ExpectedContentContains: `"//"`, + }, + { + URI: "/api/v1/auth/ok", + HasToken: true, + Redirects: true, + Roles: []string{"admin"}, + ExpectedProxy: true, + ExpectedCode: http.StatusOK, + ExpectedContentContains: `"/api/v1/auth/ok"`, + }, + { + URI: "/.%2e/../%2F/%5c/api/v1/%61uth/some?referer=https%3A%2F%2Fwww.example.com%2Fauth", + HasToken: true, + Redirects: true, + Roles: []string{"dev"}, + ExpectedProxy: true, + ExpectedCode: http.StatusOK, + ExpectedContentContains: `"/.%2e/../%2F/%5c/api/v1/%61uth/some?referer=https%3A%2F%2Fwww.example.com%2Fauth"`, + }, + //nolint:lll + { + URI: "/administrativeMonitor/hudson.diagnosis.ReverseProxySetupMonitor/testForReverseProxySetup/https%3A%2F%2Flocalhost%3A6001%2Fmanage/", + HasToken: true, + Redirects: true, + Roles: []string{"user"}, + ExpectedProxy: true, + ExpectedCode: http.StatusOK, + ExpectedContentContains: `"uri":"/administrativeMonitor/hudson.diagnosis.ReverseProxySetupMonitor/testForReverseProxySetup/https%3A%2F%2Flocalhost%3A6001%2Fmanage/"`, + }, + { + URI: "/iiif/2/edepot_local:ST%2F00001%2FST00005_00001.jpg/full/1000,/0/default.png", + HasToken: true, + Redirects: true, + Roles: []string{"user"}, + ExpectedProxy: true, + ExpectedCode: http.StatusOK, + ExpectedContentContains: `"uri":"/iiif/2/edepot_local:ST%2F00001%2FST00005_00001.jpg/full/1000,/0/default.png"`, + }, + { + URI: "/../api/v1/%61uth/some", + HasToken: true, + Redirects: true, + Roles: []string{"admin"}, + ExpectedCode: http.StatusForbidden, + }, + { + URI: "/.%2e/api/v1/%61uth/some", + HasToken: true, + Redirects: true, + Roles: []string{"admin"}, + ExpectedCode: http.StatusForbidden, + }, + { + URI: "/api/v1/%61uth/some", + HasToken: true, + Redirects: true, + Roles: []string{"admin"}, + ExpectedCode: http.StatusForbidden, + }, + { + URI: "/api/v1/%2f/%61uth/some", + HasToken: true, + Redirects: true, + Roles: []string{"admin"}, + ExpectedCode: http.StatusForbidden, + }, + { + URI: "/../%2e/.%2e/auth/%2F/%6akoper", + HasToken: true, + Redirects: true, + ExpectedCode: http.StatusForbidden, + }, + { + URI: "", + HasToken: true, + Redirects: true, + ExpectedCode: http.StatusForbidden, + }, + }, }, { - URL: "/api/v1/auth*", - Methods: utils.AllHTTPMethods, - Roles: []string{"admin"}, + Name: "NormalizePathEnabled", + ProxySettings: func(cfg *config.Config) { + cfg.NormalizePath = true + cfg.NormalizePathUpstream = true + cfg.MergeSlashes = false + cfg.MergeSlashesUpstream = false + cfg.PathEscapedSlashes = true + cfg.PathEscapedSlashesUpstream = true + cfg.Resources = []*core.Resource{ + { + URL: "/%2F/%5C/api/v1/auth/some*", + Methods: utils.AllHTTPMethods, + Roles: []string{"dev"}, + }, + { + URL: "/api/v1/auth*", + Methods: utils.AllHTTPMethods, + Roles: []string{"admin"}, + }, + { + URL: constant.AllPath, + Methods: utils.AllHTTPMethods, + Roles: []string{"user"}, + }, + } + }, + ExecutionSettings: []fakeRequest{ + { + URI: "/api/v1/auth/ok", + HasToken: true, + Redirects: true, + Roles: []string{"admin"}, + ExpectedProxy: true, + ExpectedCode: http.StatusOK, + ExpectedContentContains: `"/api/v1/auth/ok"`, + }, + { + URI: "/.%2e/../%2F/%5C/api/v1/%61uth/some?referer=https%3A%2F%2Fwww.example.com%2Fauth", + HasToken: true, + Redirects: true, + Roles: []string{"dev"}, + ExpectedProxy: true, + ExpectedCode: http.StatusOK, + ExpectedContentContains: `"/%2F/%5C/api/v1/auth/some?referer=https%3A%2F%2Fwww.example.com%2Fauth"`, + }, + { + URI: "/../api/v1/%61uth/some", + HasToken: true, + Redirects: true, + Roles: []string{"admin"}, + ExpectedProxy: true, + ExpectedCode: http.StatusOK, + ExpectedContentContains: `"/api/v1/auth/some"`, + }, + { + URI: "/.%2e/api/v1/%61uth/some", + HasToken: true, + Redirects: true, + Roles: []string{"admin"}, + ExpectedProxy: true, + ExpectedCode: http.StatusOK, + ExpectedContentContains: `"/api/v1/auth/some"`, + }, + { + URI: "/help/../api/v1/%61uth/some", + HasToken: true, + Redirects: true, + Roles: []string{"admin"}, + ExpectedProxy: true, + ExpectedCode: http.StatusOK, + ExpectedContentContains: `"/api/v1/auth/some"`, + }, + { + URI: "/help/.%2e/api/v1/%61uth/some", + HasToken: true, + Redirects: true, + Roles: []string{"admin"}, + ExpectedProxy: true, + ExpectedCode: http.StatusOK, + ExpectedContentContains: `"/api/v1/auth/some"`, + }, + { + URI: "/api/v1/%2F/%61uth/some", + HasToken: true, + Redirects: true, + Roles: []string{"admin"}, + ExpectedCode: http.StatusForbidden, + }, + { + URI: "/../%2e/.%2e/auth/%2F/%6akoper", + HasToken: true, + Redirects: true, + ExpectedCode: http.StatusForbidden, + }, + { + URI: "", + HasToken: true, + Redirects: true, + ExpectedCode: http.StatusForbidden, + }, + }, }, { - URL: "/api/v1/*", - Methods: utils.AllHTTPMethods, - WhiteListed: true, + Name: "MergeSlashesEnabled", + ProxySettings: func(cfg *config.Config) { + cfg.NormalizePath = false + cfg.NormalizePathUpstream = false + cfg.MergeSlashes = true + cfg.MergeSlashesUpstream = true + cfg.PathEscapedSlashes = true + cfg.PathEscapedSlashesUpstream = true + cfg.Resources = []*core.Resource{ + { + URL: "/.%2e/../%2F/api/v1/%61uth/some*", + Methods: utils.AllHTTPMethods, + Roles: []string{"dev"}, + }, + { + URL: "/api/v1/auth*", + Methods: utils.AllHTTPMethods, + Roles: []string{"admin"}, + }, + { + URL: constant.AllPath, + Methods: utils.AllHTTPMethods, + Roles: []string{"user"}, + }, + } + }, + ExecutionSettings: []fakeRequest{ + { + URI: "/api/v1/auth/ok", + HasToken: true, + Redirects: true, + Roles: []string{"admin"}, + ExpectedProxy: true, + ExpectedCode: http.StatusOK, + ExpectedContentContains: `"/api/v1/auth/ok"`, + }, + { + URI: "/.%2e/..///%2F//api/v1/%61uth/some?referer=https%3A%2F%2Fwww.example.com%2Fauth", + HasToken: true, + Redirects: true, + Roles: []string{"dev"}, + ExpectedProxy: true, + ExpectedCode: http.StatusOK, + ExpectedContentContains: `"/.%2e/../%2F/api/v1/%61uth/some?referer=https%3A%2F%2Fwww.example.com%2Fauth"`, + }, + { + URI: "/../api/v1/%61uth/some", + HasToken: true, + Redirects: true, + Roles: []string{"admin"}, + ExpectedCode: http.StatusForbidden, + }, + { + URI: "/.%2e/api/v1/%61uth/some", + HasToken: true, + Redirects: true, + Roles: []string{"admin"}, + ExpectedCode: http.StatusForbidden, + }, + { + URI: "/api/v1/%2F/%61uth/some", + HasToken: true, + Redirects: true, + Roles: []string{"admin"}, + ExpectedCode: http.StatusForbidden, + }, + { + URI: "/../%2e/.%2e/auth/%2F/%6akoper", + HasToken: true, + Redirects: true, + ExpectedCode: http.StatusForbidden, + }, + { + URI: "", + HasToken: true, + Redirects: true, + ExpectedCode: http.StatusForbidden, + }, + }, }, { - URL: "/*", - Methods: utils.AllHTTPMethods, - Roles: []string{"user"}, + Name: "UnescapeSlashesEnabled", + ProxySettings: func(cfg *config.Config) { + cfg.NormalizePath = false + cfg.NormalizePathUpstream = false + cfg.MergeSlashes = false + cfg.MergeSlashesUpstream = false + cfg.PathEscapedSlashes = false + cfg.PathEscapedSlashesUpstream = false + cfg.Resources = []*core.Resource{ + { + URL: "/.%2e/../////api\\/v1/%61uth/some*", + Methods: utils.AllHTTPMethods, + Roles: []string{"dev"}, + }, + { + URL: "/api/v1/auth*", + Methods: utils.AllHTTPMethods, + Roles: []string{"admin"}, + }, + { + URL: constant.AllPath, + Methods: utils.AllHTTPMethods, + Roles: []string{"user"}, + }, + } + }, + ExecutionSettings: []fakeRequest{ + { + URI: "/api/v1/auth/ok", + HasToken: true, + Redirects: true, + Roles: []string{"admin"}, + ExpectedProxy: true, + ExpectedCode: http.StatusOK, + ExpectedContentContains: `"/api/v1/auth/ok"`, + }, + { + URI: "/.%2e/..//%2F//api%5c/v1/%61uth/some?referer=https%3A%2F%2Fwww.example.com%2Fauth", + HasToken: true, + Redirects: true, + Roles: []string{"dev"}, + ExpectedProxy: true, + ExpectedCode: http.StatusOK, + ExpectedContentContains: `"/.%2e/../////api\\/v1/%61uth/some?referer=https%3A%2F%2Fwww.example.com%2Fauth"`, + }, + { + URI: "/../api/v1/%61uth/some", + HasToken: true, + Redirects: true, + Roles: []string{"admin"}, + ExpectedCode: http.StatusForbidden, + }, + { + URI: "/.%2e/api/v1/%61uth/some", + HasToken: true, + Redirects: true, + Roles: []string{"admin"}, + ExpectedCode: http.StatusForbidden, + }, + { + URI: "/api/v1/%2F/%61uth/some", + HasToken: true, + Redirects: true, + Roles: []string{"admin"}, + ExpectedCode: http.StatusForbidden, + }, + { + URI: "/../%2e/.%2e/auth/%2F/%6akoper", + HasToken: true, + Redirects: true, + ExpectedCode: http.StatusForbidden, + }, + { + URI: "", + HasToken: true, + Redirects: true, + ExpectedCode: http.StatusForbidden, + }, + }, + }, + { + Name: "AllNormalizationEnabled", + ProxySettings: func(cfg *config.Config) { + cfg.NormalizePath = true + cfg.NormalizePathUpstream = true + cfg.MergeSlashes = true + cfg.MergeSlashesUpstream = true + cfg.PathEscapedSlashes = false + cfg.PathEscapedSlashesUpstream = false + cfg.Resources = []*core.Resource{ + { + URL: "/api/v1/auth/some*", + Methods: utils.AllHTTPMethods, + Roles: []string{"dev"}, + }, + { + URL: "/api/v1/auth*", + Methods: utils.AllHTTPMethods, + Roles: []string{"admin"}, + }, + { + URL: constant.AllPath, + Methods: utils.AllHTTPMethods, + Roles: []string{"user"}, + }, + } + }, + ExecutionSettings: []fakeRequest{ + { + URI: "/api/v1/auth/ok", + HasToken: true, + Redirects: true, + Roles: []string{"admin"}, + ExpectedProxy: true, + ExpectedCode: http.StatusOK, + ExpectedContentContains: `"/api/v1/auth/ok"`, + }, + { + URI: "/api/v1/auth/ok", + HasToken: true, + Redirects: true, + Roles: []string{"admin"}, + ExpectedProxy: true, + ExpectedCode: http.StatusOK, + ExpectedContentContains: `"/api/v1/auth/ok"`, + }, + { + URI: "/.%2e/..//%2F%2e.%2F//api//v1/%61uth/some?referer=https%3A%2F%2Fwww.example.com%2Fauth", + HasToken: true, + Redirects: true, + Roles: []string{"dev"}, + ExpectedProxy: true, + ExpectedCode: http.StatusOK, + ExpectedContentContains: `"/api/v1/auth/some?referer=https%3A%2F%2Fwww.example.com%2Fauth"`, + }, + { + URI: "/a//..//%2F%2e.%2F//api//v1/%61uth/some", + HasToken: true, + Redirects: true, + Roles: []string{"dev"}, + ExpectedProxy: true, + ExpectedCode: http.StatusOK, + ExpectedContentContains: `"/api/v1/auth/some"`, + }, + { + URI: "/../api/v1/%61uth/some", + HasToken: true, + Redirects: true, + Roles: []string{"admin"}, + ExpectedCode: http.StatusForbidden, + }, + { + URI: "/.%2e/api/v1/%61uth/some", + HasToken: true, + Redirects: true, + Roles: []string{"admin"}, + ExpectedCode: http.StatusForbidden, + }, + { + URI: "/api/v1/%2F/%61uth/some", + HasToken: true, + Redirects: true, + Roles: []string{"admin"}, + ExpectedCode: http.StatusForbidden, + }, + { + URI: "/../%2e/.%2e/auth/%2F/%6akoper", + HasToken: true, + Redirects: true, + ExpectedCode: http.StatusForbidden, + }, + { + URI: "", + HasToken: true, + Redirects: true, + ExpectedCode: http.StatusForbidden, + }, + }, }, } - requests := []fakeRequest{ + for _, testCase := range testCases { + cfg := *cfg + + t.Run( + testCase.Name, + func(t *testing.T) { + testCase.ProxySettings(&cfg) + newFakeProxy(&cfg, &fakeAuthConfig{}).RunTests(t, testCase.ExecutionSettings) + }, + ) + } +} + +//nolint:funlen +func TestPathNormalizationNoRedirects(t *testing.T) { + cfg := newFakeKeycloakConfig() + cfg.EnableLogging = true + cfg.NoRedirects = true + + testCases := []struct { + Name string + ProxySettings func(c *config.Config) + ExecutionSettings []fakeRequest + }{ { - URI: FakeTestURL, - HasToken: true, - Roles: []string{"nothing"}, - ExpectedCode: http.StatusForbidden, - Redirects: false, + Name: "AllNormalizationDisabled", + ProxySettings: func(cfg *config.Config) { + cfg.NormalizePath = false + cfg.NormalizePathUpstream = false + cfg.MergeSlashes = false + cfg.MergeSlashesUpstream = false + cfg.PathEscapedSlashes = true + cfg.PathEscapedSlashesUpstream = true + cfg.Resources = []*core.Resource{ + { + URL: "/.%2e/../%2F/api/v1/%61uth/some*", + Methods: utils.AllHTTPMethods, + Roles: []string{"dev"}, + }, + { + URL: "/api/v1/auth*", + Methods: utils.AllHTTPMethods, + Roles: []string{"admin"}, + }, + { + URL: constant.AllPath, + Methods: utils.AllHTTPMethods, + Roles: []string{"user"}, + }, + } + }, + ExecutionSettings: []fakeRequest{ + { + URI: "/api/v1/auth/ok", + HasToken: true, + Redirects: true, + Roles: []string{"admin"}, + ExpectedProxy: true, + ExpectedCode: http.StatusOK, + ExpectedContentContains: `"/api/v1/auth/ok"`, + }, + { + URI: "/.%2e/../%2F/api/v1/%61uth/some?referer=https%3A%2F%2Fwww.example.com%2Fauth", + HasToken: true, + Redirects: true, + Roles: []string{"dev"}, + ExpectedProxy: true, + ExpectedCode: http.StatusOK, + ExpectedContentContains: `"/.%2e/../%2F/api/v1/%61uth/some?referer=https%3A%2F%2Fwww.example.com%2Fauth"`, + }, + //nolint:lll + { + URI: "/administrativeMonitor/hudson.diagnosis.ReverseProxySetupMonitor/testForReverseProxySetup/https%3A%2F%2Flocalhost%3A6001%2Fmanage/", + HasToken: true, + Redirects: true, + Roles: []string{"user"}, + ExpectedProxy: true, + ExpectedCode: http.StatusOK, + ExpectedContentContains: `"uri":"/administrativeMonitor/hudson.diagnosis.ReverseProxySetupMonitor/testForReverseProxySetup/https%3A%2F%2Flocalhost%3A6001%2Fmanage/"`, + }, + { + URI: "/iiif/2/edepot_local:ST%2F00001%2FST00005_00001.jpg/full/1000,/0/default.png", + HasToken: true, + Redirects: true, + Roles: []string{"user"}, + ExpectedProxy: true, + ExpectedCode: http.StatusOK, + ExpectedContentContains: `"uri":"/iiif/2/edepot_local:ST%2F00001%2FST00005_00001.jpg/full/1000,/0/default.png"`, + }, + { + URI: "/../api/v1/%61uth/some", + HasToken: true, + Redirects: true, + Roles: []string{"admin"}, + ExpectedCode: http.StatusForbidden, + }, + { + URI: "/.%2e/api/v1/%61uth/some", + HasToken: true, + Redirects: true, + Roles: []string{"admin"}, + ExpectedCode: http.StatusForbidden, + }, + { + URI: "/api/v1/%61uth/some", + HasToken: true, + Redirects: true, + Roles: []string{"admin"}, + ExpectedCode: http.StatusForbidden, + }, + { + URI: "/api/v1/%2F/%61uth/some", + HasToken: true, + Redirects: true, + Roles: []string{"admin"}, + ExpectedCode: http.StatusForbidden, + }, + { + URI: "/../%2e/.%2e/auth/%2F/%6akoper", + HasToken: true, + Redirects: true, + ExpectedCode: http.StatusForbidden, + }, + { + URI: "", + HasToken: true, + Redirects: true, + ExpectedCode: http.StatusForbidden, + }, + }, }, { - URI: "/", - ExpectedCode: http.StatusUnauthorized, - Redirects: false, - }, - { // See KEYCLOAK-10864 - //nolint:lll - URI: "/administrativeMonitor/hudson.diagnosis.ReverseProxySetupMonitor/testForReverseProxySetup/https%3A%2F%2Flocalhost%3A6001%2Fmanage/", - //nolint:lll - ExpectedContentContains: `"uri":"/administrativeMonitor/hudson.diagnosis.ReverseProxySetupMonitor/testForReverseProxySetup/https%3A%2F%2Flocalhost%3A6001%2Fmanage/"`, - HasToken: true, - Roles: []string{"user"}, - ExpectedProxy: true, - ExpectedCode: http.StatusOK, - Redirects: false, - }, - { // See KEYCLOAK-11276 - URI: "/iiif/2/edepot_local:ST%2F00001%2FST00005_00001.jpg/full/1000,/0/default.png", - ExpectedContentContains: `"uri":"/iiif/2/edepot_local:ST%2F00001%2FST00005_00001.jpg/full/1000,/0/default.png"`, - HasToken: true, - Roles: []string{"user"}, - ExpectedProxy: true, - ExpectedCode: http.StatusOK, - Redirects: false, - }, - { // See KEYCLOAK-13315 - URI: "/rabbitmqui/%2F/replicate-to-central", - ExpectedContentContains: `"uri":"/rabbitmqui/%2F/replicate-to-central"`, - HasToken: true, - Roles: []string{"user"}, - ExpectedProxy: true, - ExpectedCode: http.StatusOK, - Redirects: false, - }, - { // should work - URI: "/api/v1/auth", - HasToken: true, - Roles: []string{"admin"}, - ExpectedProxy: true, - ExpectedCode: http.StatusOK, - Redirects: false, + Name: "NormalizePathEnabled", + ProxySettings: func(cfg *config.Config) { + cfg.NormalizePath = true + cfg.NormalizePathUpstream = true + cfg.MergeSlashes = false + cfg.MergeSlashesUpstream = false + cfg.PathEscapedSlashes = true + cfg.PathEscapedSlashesUpstream = true + cfg.Resources = []*core.Resource{ + { + URL: "/%2F/api/v1/auth/some*", + Methods: utils.AllHTTPMethods, + Roles: []string{"dev"}, + }, + { + URL: "/api/v1/auth*", + Methods: utils.AllHTTPMethods, + Roles: []string{"admin"}, + }, + { + URL: constant.AllPath, + Methods: utils.AllHTTPMethods, + Roles: []string{"user"}, + }, + } + }, + ExecutionSettings: []fakeRequest{ + { + URI: "/api/v1/auth/ok", + HasToken: true, + Redirects: true, + Roles: []string{"admin"}, + ExpectedProxy: true, + ExpectedCode: http.StatusOK, + ExpectedContentContains: `"/api/v1/auth/ok"`, + }, + { + URI: "/.%2e/../%2F/api/v1/%61uth/some?referer=https%3A%2F%2Fwww.example.com%2Fauth", + HasToken: true, + Redirects: true, + Roles: []string{"dev"}, + ExpectedProxy: true, + ExpectedCode: http.StatusOK, + ExpectedContentContains: `"/%2F/api/v1/auth/some?referer=https%3A%2F%2Fwww.example.com%2Fauth"`, + }, + { + URI: "/../api/v1/%61uth/some", + HasToken: true, + Redirects: true, + Roles: []string{"admin"}, + ExpectedProxy: true, + ExpectedCode: http.StatusOK, + ExpectedContentContains: `"/api/v1/auth/some"`, + }, + { + URI: "/.%2e/api/v1/%61uth/some", + HasToken: true, + Redirects: true, + Roles: []string{"admin"}, + ExpectedProxy: true, + ExpectedCode: http.StatusOK, + ExpectedContentContains: `"/api/v1/auth/some"`, + }, + { + URI: "/help/../api/v1/%61uth/some", + HasToken: true, + Redirects: true, + Roles: []string{"admin"}, + ExpectedProxy: true, + ExpectedCode: http.StatusOK, + ExpectedContentContains: `"/api/v1/auth/some"`, + }, + { + URI: "/help/.%2e/api/v1/%61uth/some", + HasToken: true, + Redirects: true, + Roles: []string{"admin"}, + ExpectedProxy: true, + ExpectedCode: http.StatusOK, + ExpectedContentContains: `"/api/v1/auth/some"`, + }, + { + URI: "/api/v1/%2F/%61uth/some", + HasToken: true, + Redirects: true, + Roles: []string{"admin"}, + ExpectedCode: http.StatusForbidden, + }, + { + URI: "/../%2e/.%2e/auth/%2F/%6akoper", + HasToken: true, + Redirects: true, + ExpectedCode: http.StatusForbidden, + }, + { + URI: "", + HasToken: true, + Redirects: true, + ExpectedCode: http.StatusForbidden, + }, + }, }, - { // should work - URI: "/api/v1/auth?referer=https%3A%2F%2Fwww.example.com%2Fauth", - ExpectedContentContains: `"uri":"/api/v1/auth?referer=https%3A%2F%2Fwww.example.com%2Fauth"`, - HasToken: true, - Roles: []string{"admin"}, - ExpectedProxy: true, - ExpectedCode: http.StatusOK, - Redirects: false, + { + Name: "MergeSlashesEnabled", + ProxySettings: func(cfg *config.Config) { + cfg.NormalizePath = false + cfg.NormalizePathUpstream = false + cfg.MergeSlashes = true + cfg.MergeSlashesUpstream = true + cfg.PathEscapedSlashes = true + cfg.PathEscapedSlashesUpstream = true + cfg.Resources = []*core.Resource{ + { + URL: "/.%2e/../%2F/api/v1/%61uth/some*", + Methods: utils.AllHTTPMethods, + Roles: []string{"dev"}, + }, + { + URL: "/api/v1/auth*", + Methods: utils.AllHTTPMethods, + Roles: []string{"admin"}, + }, + { + URL: constant.AllPath, + Methods: utils.AllHTTPMethods, + Roles: []string{"user"}, + }, + } + }, + ExecutionSettings: []fakeRequest{ + { + URI: "/api/v1/auth/ok", + HasToken: true, + Redirects: true, + Roles: []string{"admin"}, + ExpectedProxy: true, + ExpectedCode: http.StatusOK, + ExpectedContentContains: `"/api/v1/auth/ok"`, + }, + { + URI: "/.%2e/..///%2F//api/v1/%61uth/some?referer=https%3A%2F%2Fwww.example.com%2Fauth", + HasToken: true, + Redirects: true, + Roles: []string{"dev"}, + ExpectedProxy: true, + ExpectedCode: http.StatusOK, + ExpectedContentContains: `"/.%2e/../%2F/api/v1/%61uth/some?referer=https%3A%2F%2Fwww.example.com%2Fauth"`, + }, + { + URI: "/../api/v1/%61uth/some", + HasToken: true, + Redirects: true, + Roles: []string{"admin"}, + ExpectedCode: http.StatusForbidden, + }, + { + URI: "/.%2e/api/v1/%61uth/some", + HasToken: true, + Redirects: true, + Roles: []string{"admin"}, + ExpectedCode: http.StatusForbidden, + }, + { + URI: "/api/v1/%2F/%61uth/some", + HasToken: true, + Redirects: true, + Roles: []string{"admin"}, + ExpectedCode: http.StatusForbidden, + }, + { + URI: "/../%2e/.%2e/auth/%2F/%6akoper", + HasToken: true, + Redirects: true, + ExpectedCode: http.StatusForbidden, + }, + { + URI: "", + HasToken: true, + Redirects: true, + ExpectedCode: http.StatusForbidden, + }, + }, }, { - URI: "/api/v1/auth?referer=https%3A%2F%2Fwww.example.com%2Fauth", - HasToken: true, - Roles: []string{"user"}, - ExpectedCode: http.StatusForbidden, - Redirects: false, + Name: "UnescapeSlashesEnabled", + ProxySettings: func(cfg *config.Config) { + cfg.NormalizePath = false + cfg.NormalizePathUpstream = false + cfg.MergeSlashes = false + cfg.MergeSlashesUpstream = false + cfg.PathEscapedSlashes = false + cfg.PathEscapedSlashesUpstream = false + cfg.Resources = []*core.Resource{ + { + URL: "/.%2e/../////api//v1/%61uth/some*", + Methods: utils.AllHTTPMethods, + Roles: []string{"dev"}, + }, + { + URL: "/api/v1/auth*", + Methods: utils.AllHTTPMethods, + Roles: []string{"admin"}, + }, + { + URL: constant.AllPath, + Methods: utils.AllHTTPMethods, + Roles: []string{"user"}, + }, + } + }, + ExecutionSettings: []fakeRequest{ + { + URI: "/api/v1/auth/ok", + HasToken: true, + Redirects: true, + Roles: []string{"admin"}, + ExpectedProxy: true, + ExpectedCode: http.StatusOK, + ExpectedContentContains: `"/api/v1/auth/ok"`, + }, + { + URI: "/.%2e/..//%2F//api//v1/%61uth/some?referer=https%3A%2F%2Fwww.example.com%2Fauth", + HasToken: true, + Redirects: true, + Roles: []string{"dev"}, + ExpectedProxy: true, + ExpectedCode: http.StatusOK, + ExpectedContentContains: `"/.%2e/../////api//v1/%61uth/some?referer=https%3A%2F%2Fwww.example.com%2Fauth"`, + }, + { + URI: "/../api/v1/%61uth/some", + HasToken: true, + Redirects: true, + Roles: []string{"admin"}, + ExpectedCode: http.StatusForbidden, + }, + { + URI: "/.%2e/api/v1/%61uth/some", + HasToken: true, + Redirects: true, + Roles: []string{"admin"}, + ExpectedCode: http.StatusForbidden, + }, + { + URI: "/api/v1/%2F/%61uth/some", + HasToken: true, + Redirects: true, + Roles: []string{"admin"}, + ExpectedCode: http.StatusForbidden, + }, + { + URI: "/../%2e/.%2e/auth/%2F/%6akoper", + HasToken: true, + Redirects: true, + ExpectedCode: http.StatusForbidden, + }, + { + URI: "", + HasToken: true, + Redirects: true, + ExpectedCode: http.StatusForbidden, + }, + }, }, - { // should work - URI: "/api/v3/auth?referer=https%3A%2F%2Fwww.example.com%2Fauth", - ExpectedContentContains: `"uri":"/api/v3/auth?referer=https%3A%2F%2Fwww.example.com%2Fauth"`, - HasToken: true, - Roles: []string{"user"}, - ExpectedProxy: true, - ExpectedCode: http.StatusOK, - Redirects: false, + { + Name: "AllNormalizationEnabled", + ProxySettings: func(cfg *config.Config) { + cfg.NormalizePath = true + cfg.NormalizePathUpstream = true + cfg.MergeSlashes = true + cfg.MergeSlashesUpstream = true + cfg.PathEscapedSlashes = false + cfg.PathEscapedSlashesUpstream = false + cfg.Resources = []*core.Resource{ + { + URL: "/api/v1/auth/some*", + Methods: utils.AllHTTPMethods, + Roles: []string{"dev"}, + }, + { + URL: "/api/v1/auth*", + Methods: utils.AllHTTPMethods, + Roles: []string{"admin"}, + }, + { + URL: constant.AllPath, + Methods: utils.AllHTTPMethods, + Roles: []string{"user"}, + }, + } + }, + ExecutionSettings: []fakeRequest{ + { + URI: "/api/v1/auth/ok", + HasToken: true, + Redirects: true, + Roles: []string{"admin"}, + ExpectedProxy: true, + ExpectedCode: http.StatusOK, + ExpectedContentContains: `"/api/v1/auth/ok"`, + }, + { + URI: "/.%2e/..//%2F//api//v1/%61uth/some?referer=https%3A%2F%2Fwww.example.com%2Fauth", + HasToken: true, + Redirects: true, + Roles: []string{"dev"}, + ExpectedProxy: true, + ExpectedCode: http.StatusOK, + ExpectedContentContains: `"/api/v1/auth/some?referer=https%3A%2F%2Fwww.example.com%2Fauth"`, + }, + { + URI: "/../api/v1/%61uth/some", + HasToken: true, + Redirects: true, + Roles: []string{"admin"}, + ExpectedCode: http.StatusForbidden, + }, + { + URI: "/.%2e/api/v1/%61uth/some", + HasToken: true, + Redirects: true, + Roles: []string{"admin"}, + ExpectedCode: http.StatusForbidden, + }, + { + URI: "/api/v1/%2F/%61uth/some", + HasToken: true, + Redirects: true, + Roles: []string{"admin"}, + ExpectedCode: http.StatusForbidden, + }, + { + URI: "/../%2e/.%2e/auth/%2F/%6akoper", + HasToken: true, + Redirects: true, + ExpectedCode: http.StatusForbidden, + }, + { + URI: "", + HasToken: true, + Redirects: true, + ExpectedCode: http.StatusForbidden, + }, + }, }, } - newFakeProxy(cfg, &fakeAuthConfig{}).RunTests(t, requests) + for _, testCase := range testCases { + cfg := *cfg + + t.Run( + testCase.Name, + func(t *testing.T) { + testCase.ProxySettings(&cfg) + newFakeProxy(&cfg, &fakeAuthConfig{}).RunTests(t, testCase.ExecutionSettings) + }, + ) + } } +//nolint:goconst func TestStrangeRoutingError(t *testing.T) { cfg := newFakeKeycloakConfig() cfg.Resources = []*core.Resource{ @@ -504,7 +1386,7 @@ func TestStrangeRoutingError(t *testing.T) { Roles: []string{"auditor", "dev"}, }, { - URL: "/*", + URL: constant.AllPath, Methods: utils.AllHTTPMethods, Roles: []string{"dev"}, }, @@ -530,19 +1412,19 @@ func TestStrangeRoutingError(t *testing.T) { ExpectedCode: http.StatusOK, }, { // this should fail with no roles - hits catch all - URI: "/api/v1/event/1000", + URI: "/api/v1/event/1001", Redirects: false, ExpectedCode: http.StatusUnauthorized, }, { // this should fail with bad role - hits catch all - URI: "/api/v1/event/1000", + URI: "/api/v1/event/1002", Redirects: false, HasToken: true, Roles: []string{"bad"}, ExpectedCode: http.StatusForbidden, }, { // should work with catch-all - URI: "/api/v1/event/1000", + URI: "/api/v1/event/1003", Redirects: false, HasToken: true, Roles: []string{"dev"}, @@ -590,147 +1472,14 @@ func TestStrangeRoutingError(t *testing.T) { } } -func TestNoProxyingRequests(t *testing.T) { - cfg := newFakeKeycloakConfig() - cfg.Resources = []*core.Resource{ - { - URL: "/*", - Methods: utils.AllHTTPMethods, - }, - } - requests := []fakeRequest{ - { // check for escaping - URI: "/.%2e/.%2e/.%2e/.%2e/.%2e/.%2e/.%2e/etc/passwd", - Redirects: true, - ExpectedCode: http.StatusSeeOther, - }, - { // check for escaping - URI: "/.%2e/.%2e/.%2e/.%2e/.%2e/.%2e/.%2e/", - Redirects: true, - ExpectedCode: http.StatusSeeOther, - }, - { // check for escaping - URI: "/../%2e", - Redirects: true, - ExpectedCode: http.StatusSeeOther, - }, - { // check for escaping - URI: "", - Redirects: true, - ExpectedCode: http.StatusSeeOther, - }, - } - newFakeProxy(cfg, &fakeAuthConfig{}).RunTests(t, requests) -} - const testAdminURI = "/admin/test" -func TestStrangeAdminRequests(t *testing.T) { - cfg := newFakeKeycloakConfig() - cfg.Resources = []*core.Resource{ - { - URL: "/admin*", - Methods: utils.AllHTTPMethods, - Roles: []string{FakeAdminRole}, - }, - } - - testCases := []struct { - Name string - ProxySettings func(c *config.Config) - ExecutionSettings []fakeRequest - }{ - { - Name: "TestNoRedirects", - ProxySettings: func(conf *config.Config) { - conf.NoRedirects = true - }, - ExecutionSettings: []fakeRequest{ - { // check for double slashs no redirects - URI: "/admin//test", - Redirects: false, - HasToken: true, - ExpectedCode: http.StatusForbidden, - }, - { - URI: "/help/../admin/test/21", - Redirects: false, - ExpectedCode: http.StatusUnauthorized, - }, - }, - }, - { - Name: "TestRedirects", - ProxySettings: func(conf *config.Config) { - conf.NoRedirects = false - }, - ExecutionSettings: []fakeRequest{ - { // check for escaping - URI: "//admin%2Ftest", - Redirects: true, - ExpectedCode: http.StatusSeeOther, - }, - { // check for escaping - URI: "///admin/../admin//%2Ftest", - Redirects: true, - ExpectedCode: http.StatusSeeOther, - }, - { // check for escaping - URI: "/admin%2Ftest", - Redirects: true, - ExpectedCode: http.StatusSeeOther, - }, - { // check for prefix slashs - URI: "/" + testAdminURI, - Redirects: true, - ExpectedCode: http.StatusSeeOther, - }, - { // check for double slashs - URI: testAdminURI, - Redirects: true, - ExpectedCode: http.StatusSeeOther, - }, - { // check for dodgy url - URI: "//admin/.." + testAdminURI, - Redirects: true, - ExpectedCode: http.StatusSeeOther, - }, - { // check for it works - URI: "/" + testAdminURI, - HasToken: true, - Roles: []string{FakeAdminRole}, - ExpectedProxy: true, - ExpectedCode: http.StatusOK, - }, - { // check for is doens't work - URI: "//admin//test", - HasToken: true, - Roles: []string{"bad"}, - ExpectedCode: http.StatusForbidden, - }, - }, - }, - } - - for _, testCase := range testCases { - cfg := *cfg - - t.Run( - testCase.Name, - func(t *testing.T) { - testCase.ProxySettings(&cfg) - newFakeProxy(&cfg, &fakeAuthConfig{}).RunTests(t, testCase.ExecutionSettings) - }, - ) - } -} - -//nolint:funlen +//nolint:funlen,goconst func TestWhiteListedRequests(t *testing.T) { cfg := newFakeKeycloakConfig() cfg.Resources = []*core.Resource{ { - URL: "/*", + URL: constant.AllPath, Methods: utils.AllHTTPMethods, Roles: []string{"default"}, // this role is in our fakeauth server }, @@ -981,9 +1730,11 @@ func TestWhiteListedRequests(t *testing.T) { func TestRequireAnyRoles(t *testing.T) { cfg := newFakeKeycloakConfig() cfg.NoRedirects = true + reqAnyRolePath := "/require_any_role" + cfg.Resources = []*core.Resource{ { - URL: "/require_any_role/*", + URL: reqAnyRolePath + "/*", Methods: utils.AllHTTPMethods, RequireAnyRole: true, Roles: []string{"admin", "guest"}, @@ -991,12 +1742,12 @@ func TestRequireAnyRoles(t *testing.T) { } requests := []fakeRequest{ { - URI: "/require_any_role/test", + URI: reqAnyRolePath + "/test", ExpectedCode: http.StatusUnauthorized, Redirects: false, }, { - URI: "/require_any_role/test", + URI: reqAnyRolePath + "/test", HasToken: true, Roles: []string{"guest"}, ExpectedCode: http.StatusOK, @@ -1004,7 +1755,7 @@ func TestRequireAnyRoles(t *testing.T) { Redirects: false, }, { - URI: "/require_any_role/test", + URI: reqAnyRolePath + "/test", HasToken: true, Roles: []string{"guest1"}, ExpectedCode: http.StatusForbidden, @@ -1014,7 +1765,7 @@ func TestRequireAnyRoles(t *testing.T) { newFakeProxy(cfg, &fakeAuthConfig{}).RunTests(t, requests) } -//nolint:funlen +//nolint:funlen,goconst func TestHeaderPermissionsMiddleware(t *testing.T) { cfg := newFakeKeycloakConfig() @@ -1246,6 +1997,7 @@ func TestHeaderPermissionsMiddleware(t *testing.T) { } } +//nolint:goconst func TestGroupPermissionsMiddleware(t *testing.T) { cfg := newFakeKeycloakConfig() cfg.NoRedirects = true @@ -1267,7 +2019,7 @@ func TestGroupPermissionsMiddleware(t *testing.T) { Groups: []string{"admin", "user", "tester"}, }, { - URL: "/*", + URL: constant.AllPath, Methods: utils.AllHTTPMethods, Roles: []string{"user"}, }, @@ -1372,7 +2124,7 @@ func TestGroupPermissionsMiddleware(t *testing.T) { newFakeProxy(cfg, &fakeAuthConfig{}).RunTests(t, requests) } -//nolint:funlen +//nolint:funlen,goconst func TestRolePermissionsMiddleware(t *testing.T) { cfg := newFakeKeycloakConfig() cfg.Resources = []*core.Resource{ @@ -1407,7 +2159,7 @@ func TestRolePermissionsMiddleware(t *testing.T) { Roles: []string{}, }, { - URL: "/*", + URL: constant.AllPath, Methods: utils.AllHTTPMethods, Roles: []string{FakeTestRole}, }, @@ -1528,23 +2280,24 @@ func TestRolePermissionsMiddleware(t *testing.T) { Redirects: false, HasToken: true, Roles: []string{FakeAdminRole}, - ExpectedCode: http.StatusOK, - ExpectedProxy: true, + ExpectedCode: http.StatusForbidden, + ExpectedProxy: false, }, { // strange url, token, but good token URI: "/test/../admin", Redirects: false, HasToken: true, Roles: []string{FakeAdminRole}, - ExpectedCode: http.StatusOK, - ExpectedProxy: true, + ExpectedCode: http.StatusForbidden, + ExpectedProxy: false, }, { // strange url, token, wrong roles - URI: "/test/../admin", - Redirects: false, - HasToken: true, - Roles: []string{FakeTestRole}, - ExpectedCode: http.StatusForbidden, + URI: "/test/../admin", + Redirects: false, + HasToken: true, + Roles: []string{FakeTestRole}, + ExpectedCode: http.StatusOK, + ExpectedProxy: true, }, { // check with a token admin test role URI: "/test_admin_role", @@ -1627,6 +2380,7 @@ func TestRolePermissionsMiddleware(t *testing.T) { } } +//nolint:goconst func TestCrossSiteHandler(t *testing.T) { cases := []struct { Cors cors.Options @@ -2016,6 +2770,7 @@ func TestAccessTokenEncryption(t *testing.T) { } } +//nolint:goconst func TestCustomHeadersHandler(t *testing.T) { requests := []struct { Match []string @@ -2216,6 +2971,8 @@ func TestAdmissionHandlerRoles(t *testing.T) { } // check to see if custom headers are hitting the upstream. +// +//nolint:goconst func TestCustomHeaders(t *testing.T) { requests := []struct { Headers map[string]string @@ -2270,6 +3027,7 @@ func TestCustomHeaders(t *testing.T) { } } +//nolint:goconst func TestRolesAdmissionHandlerClaims(t *testing.T) { requests := []struct { Matches map[string]string @@ -2461,6 +3219,7 @@ func TestRolesAdmissionHandlerClaims(t *testing.T) { } } +//nolint:goconst func TestGzipCompression(t *testing.T) { cfg := newFakeKeycloakConfig() server := httptest.NewServer(&FakeUpstreamService{}) @@ -2580,6 +3339,7 @@ func TestGzipCompression(t *testing.T) { } } +//nolint:goconst func TestEnableUma(t *testing.T) { cfg := newFakeKeycloakConfig() @@ -2785,6 +3545,7 @@ func TestEnableUma(t *testing.T) { } } +//nolint:goconst func TestLogRealIP(t *testing.T) { testCases := []struct { Headers map[string]string @@ -2865,7 +3626,7 @@ func TestLogRealIP(t *testing.T) { } } -//nolint:funlen +//nolint:funlen,goconst func TestEnableOpa(t *testing.T) { upstreamService := httptest.NewServer(&FakeUpstreamService{}) upstreamURL := upstreamService.URL diff --git a/pkg/testsuite/server_test.go b/pkg/testsuite/server_test.go index a3d36de1..65fcefdc 100644 --- a/pkg/testsuite/server_test.go +++ b/pkg/testsuite/server_test.go @@ -762,7 +762,7 @@ func TestForbiddenTemplate(t *testing.T) { cfg.ForbiddenPage = ForbiddenPagePath cfg.Resources = []*core.Resource{ { - URL: "/*", + URL: constant.AllPath, Methods: utils.AllHTTPMethods, Roles: []string{FakeAdminRole}, }, @@ -1452,7 +1452,7 @@ func TestNoProxy(t *testing.T) { c.NoProxy = true c.Resources = []*core.Resource{ { - URL: "/*", + URL: constant.AllPath, Methods: utils.AllHTTPMethods, Roles: []string{"user"}, }, @@ -1524,7 +1524,7 @@ func TestAuthorizationTemplate(t *testing.T) { uri := utils.WithOAuthURI(cfg.BaseURI, cfg.OAuthURI)(constant.AuthorizationURL) cfg.Resources = []*core.Resource{ { - URL: "/*", + URL: constant.AllPath, Methods: utils.AllHTTPMethods, Roles: []string{FakeAdminRole}, }, diff --git a/pkg/utils/utils.go b/pkg/utils/utils.go index 46d94e7f..ea398acd 100644 --- a/pkg/utils/utils.go +++ b/pkg/utils/utils.go @@ -34,6 +34,7 @@ import ( "os" "regexp" "slices" + "strconv" "strings" "sync" "sync/atomic" @@ -66,8 +67,39 @@ var ( http.MethodTrace, } symbolsFilter = regexp.MustCompilePOSIX("[_$><\\[\\].,\\+-/'%^&*()!\\\\]+") + + hexCharsTable = [256]bool{ + '0': true, + '1': true, + '2': true, + '3': true, + '4': true, + '5': true, + '6': true, + '7': true, + '8': true, + '9': true, + 'A': true, + 'B': true, + 'C': true, + 'D': true, + 'E': true, + 'F': true, + 'a': true, + 'b': true, + 'c': true, + 'd': true, + 'e': true, + 'f': true, + } ) +type UnescapeError string + +func (e UnescapeError) Error() string { + return "problem unescaping string: " + strconv.Quote(string(e)) +} + func GetRequestHostURL(req *http.Request) string { scheme := constant.UnsecureScheme @@ -812,3 +844,255 @@ func LoadX509KeyPairFromRoot(fileRoot, certFile, keyFile string) (tls.Certificat return tls.X509KeyPair(certPEMBlock, keyPEMBlock) } + +type UnescapeMode int + +const ( + SlashOmit UnescapeMode = iota + SlashOnly UnescapeMode = iota +) + +//nolint:cyclop +func UnescapePath(path string, mode UnescapeMode) (string, error) { + // Count %, check that they're well-formed. + escapeCount := 0 + escapeSeqLen := 3 + slashCount := 0 + + for idx := 0; idx < len(path); { + switch path[idx] { + case '%': + escapeCount++ + + invalidHex := idx+2 >= len(path) || !ishex(path[idx+1]) || !ishex(path[idx+2]) + if invalidHex { + path = path[idx:] + if len(path) > escapeSeqLen { + path = path[:escapeSeqLen] + } + + return "", UnescapeError(path) + } + + isSlash := path[idx+1] == '2' && (path[idx+2] == 'F' || path[idx+2] == 'f') + isBackSlash := path[idx+1] == '5' && (path[idx+2] == 'C' || path[idx+2] == 'c') + + if isSlash || isBackSlash { + slashCount++ + } + + idx += escapeSeqLen + case '+': + idx++ + default: + idx++ + } + } + + if escapeCount == 0 || (mode == SlashOmit && escapeCount == slashCount) { + return path, nil + } + + var unescapedPlusSign byte = '+' + + var out strings.Builder + out.Grow(escapeCount) + + for idx := 0; idx < len(path); idx++ { + switch path[idx] { + case '%': + hexSeq := path[idx : idx+3] + isSlash := path[idx+1] == '2' && (path[idx+2] == 'F' || path[idx+2] == 'f') + isBackSlash := path[idx+1] == '5' && (path[idx+2] == 'C' || path[idx+2] == 'c') + + switch mode { + case SlashOmit: + if isSlash || isBackSlash { + out.WriteString(hexSeq) + } else { + out.WriteByte(unhex(path[idx+1])<<4 | unhex(path[idx+2])) + } + case SlashOnly: + switch { + case isSlash: + out.WriteByte('/') + case isBackSlash: + out.WriteByte('\\') + default: + out.WriteString(hexSeq) + } + } + + idx += 2 + case '+': + out.WriteByte(unescapedPlusSign) + default: + out.WriteByte(path[idx]) + } + } + + return out.String(), nil +} + +func ishex(c byte) bool { + return hexCharsTable[c] +} + +// Precondition: ishex(c) is true. +// +//nolint:mnd +func unhex(c byte) byte { + return 9*(c>>6) + (c & 15) +} + +// func RemovePathDotSegmentsNew(path string) string { +// if len(path) > 0 { +// var out strings.Builder +// out.Grow(len(path)) + +// const defaultCapacity = 10 + +// numDots := 0 +// dots := make([]byte, 0, defaultCapacity) +// previousSection := make([]byte, 0, defaultCapacity) +// currentSection := make([]byte, 0, defaultCapacity) + +// for idx, char := range path { +// switch char { +// case '.': +// dots = append(dots, path[idx]) +// numDots++ +// case '/': +// if numDots != 2 { +// out.Write(previousSection) + +// if numDots != 1 { +// out.WriteByte(path[idx]) +// } +// } + +// previousSection = currentSection +// currentSection = currentSection[:0] +// numDots = 0 +// dots = dots[:0] +// default: +// currentSection = append(currentSection, path[idx]) + +// numDots = 0 +// dots = dots[:0] +// } +// } + +// if numDots > 0 { +// out.Write(dots) +// out.WriteByte('/') +// } + +// path = out.String() +// if path[0] != '/' { +// path = "/" + path +// } +// } + +// return path +// } + +func RemovePathDotSegments(path string) string { + if len(path) > 0 { + var ( + dotFree []string + lastIsDot bool + ) + + const ( + dot = "." + twoDot = ".." + ) + + for section := range strings.SplitSeq(path, "/") { + if section == twoDot { + if len(dotFree) > 0 { + dotFree = dotFree[:len(dotFree)-1] + } + } else if section != "." { + dotFree = append(dotFree, section) + } + + lastIsDot = (section == dot || section == twoDot) + } + + path = strings.Join(dotFree, "/") + if path[0] != '/' { + path = "/" + path + } + + // Special case if the last segment was a dot, make sure the path ends with a slash + if lastIsDot && path[len(path)-1] != '/' { + path += "/" + } + } + + return path +} + +func NormalizePath( + pathEscapedSlashes bool, + mergeSlashes bool, + normalizePath bool, + path string, +) (string, error) { + var err error + + if len(path) > 0 { + if !pathEscapedSlashes { + path, err = UnescapePath(path, SlashOnly) + if err != nil { + return "", err + } + } + + if mergeSlashes { + path = ReplaceDuplicateChar(path, '/') + } + + if normalizePath { + path, err = UnescapePath(path, SlashOmit) + if err != nil { + return "", err + } + + path = RemovePathDotSegments(path) + } + } + + return path, nil +} + +func ReplaceDuplicateChar(path string, replacedChar byte) string { + var out strings.Builder + out.Grow(len(path)) + + tmp := 0 + + for idx := range path { + switch path[idx] { + case replacedChar: + tmp++ + continue + default: + if tmp > 0 { + out.WriteByte(replacedChar) + + tmp = 0 + } + + out.WriteByte(path[idx]) + } + } + + if tmp > 0 { + out.WriteByte(replacedChar) + } + + return out.String() +} diff --git a/pkg/utils/utils_test.go b/pkg/utils/utils_test.go index afd69e12..c223df8a 100644 --- a/pkg/utils/utils_test.go +++ b/pkg/utils/utils_test.go @@ -20,6 +20,7 @@ package utils_test import ( "bytes" "crypto/tls" + "errors" "fmt" "net/http" "net/url" @@ -552,3 +553,268 @@ func BenchmarkMaxSize(bench *testing.B) { _ = utils.CheckMaxSize(reader, 900) } } + +func BenchmarkUnascapePath(bench *testing.B) { + data := "/f%5B%2f%56%2F%7C%5c%5C/b" + for bench.Loop() { + _, _ = utils.UnescapePath(data, utils.SlashOmit) + } +} + +func TestUnascapePath(t *testing.T) { + tests := []struct { + Name string + Path string + ExpectedPath string + Mode utils.UnescapeMode + ExpectedErr error + }{ + { + Name: "LastPercentChar", + Mode: utils.SlashOmit, + ExpectedErr: utils.UnescapeError("%"), + Path: "/a/b/%", + ExpectedPath: "", + }, + { + Name: "LastInvalidHex", + Mode: utils.SlashOmit, + ExpectedErr: utils.UnescapeError("%2"), + Path: "/a/b/%2", + ExpectedPath: "", + }, + { + Name: "InnderInvalidHex", + Mode: utils.SlashOmit, + ExpectedErr: utils.UnescapeError("%6/"), + Path: "/a/%6/%2", + ExpectedPath: "", + }, + { + Name: "FirstPercentChar", + Mode: utils.SlashOmit, + ExpectedErr: utils.UnescapeError("%/a"), + Path: "%/a/b/", + ExpectedPath: "", + }, + { + Name: "EncodedCharsLast", + Mode: utils.SlashOmit, + ExpectedErr: nil, + Path: "/a/b%5B%56%7C", + ExpectedPath: "/a/b[V|", + }, + { + Name: "EncodedCharsFirst", + Mode: utils.SlashOmit, + ExpectedErr: nil, + Path: "%5B%56%7C/a/b", + ExpectedPath: "[V|/a/b", + }, + { + Name: "OmitSlashes", + Mode: utils.SlashOmit, + ExpectedErr: nil, + Path: "/a%5B%2f%56%2F%7C%5c%5C/b", + ExpectedPath: "/a[%2fV%2F|%5c%5C/b", + }, + { + Name: "SlashOnly", + Mode: utils.SlashOnly, + ExpectedErr: nil, + Path: "/ca%5B%2f%56%2F%7C%5c%5C/b", + ExpectedPath: `/ca%5B/%56/%7C\\/b`, + }, + } + + for _, testCase := range tests { + path, err := utils.UnescapePath(testCase.Path, testCase.Mode) + if testCase.ExpectedErr != nil && !errors.Is(err, testCase.ExpectedErr) { + t.Fatalf("testcase: %s, expected error, got: %v", testCase.Name, err) + } + + if testCase.ExpectedErr == nil && err != nil { + t.Fatalf("testcase: %s, didn't expect error, got: %v", testCase.Name, err) + } + + if testCase.ExpectedPath != "" { + require.NoError(t, err, "Expected no error, testcase: %s", testCase.Name) + assert.Equal( + t, + testCase.ExpectedPath, + path, + "Expected path: %s, got: %s", + testCase.ExpectedPath, + path, + ) + } + } +} + +func BenchmarkNormalizePath(bench *testing.B) { + data := "/af%5B%2f%56%2F%7C%5c%5C/b" + + for bench.Loop() { + _, _ = utils.NormalizePath( + false, + true, + true, + data, + ) + } +} + +func BenchmarkRemovePathDotSegments(bench *testing.B) { + data := "/a/../b/./../c/d/../f/g/h" + + for bench.Loop() { + _ = utils.RemovePathDotSegments(data) + } +} + +// func TestRemovePathDotSegment(t *testing.T) { +// tests := []struct { +// Name string +// Input string +// ExpectedOutput string +// }{ +// { +// Name: "NotDots", +// Input: "/a/b//c/d///", //nolint:goconst +// ExpectedOutput: "/a/b//c/d///", +// }, +// { +// Name: "OneDot", +// Input: "/a/b/./c/d", +// ExpectedOutput: "/a/b/c/d", +// }, +// { +// Name: "OneDotWithChar", +// Input: "/a/b/.c/d", +// ExpectedOutput: "/a/b/.c/d", +// }, +// { +// Name: "TwoDot", +// Input: "/a/b/../c/d", +// ExpectedOutput: "/a/c/d", +// }, +// { +// Name: "TwoDotWithChar", +// Input: "/a/b/..c/d", +// ExpectedOutput: "/a/b/..c/d", +// }, +// { +// Name: "MultipleOneDots", +// Input: "/a/b/./c/./d", +// ExpectedOutput: "/a/b/c/d", +// }, +// { +// Name: "MultipleTwoDots", +// Input: "/a/../b/c/../d/e", +// ExpectedOutput: "/d/e", +// }, +// { +// Name: "OneDotFirst", +// Input: "./a/b/c", +// ExpectedOutput: "/a/b/c", +// }, +// { +// Name: "OneDotLast", +// Input: "/a/b/c/.", +// ExpectedOutput: "/a/b/c/./", +// }, +// { +// Name: "TowDotFirst", +// Input: "../a/b/c", +// ExpectedOutput: "/a/b/c", +// }, +// { +// Name: "TwoDotLast", +// Input: "/a/b/c/..", +// ExpectedOutput: "/a/b/c/", +// }, +// } + +// for _, testCase := range tests { +// output := utils.RemovePathDotSegmentsNew(testCase.Input) +// assert.Equal( +// t, +// testCase.ExpectedOutput, +// output, +// "Case: %s, expected output: %s, got: %s", +// testCase.Name, +// testCase.ExpectedOutput, +// output, +// ) +// } +// } + +func TestReplaceDuplicateChar(t *testing.T) { + tests := []struct { + Name string + ReplaceChar byte + Input string + ExpectedOutput string + }{ + { + Name: "OnlyOneSlash", + ReplaceChar: '/', + Input: "test/onlyoneslash", //nolint:goconst + ExpectedOutput: "test/onlyoneslash", + }, + { + Name: "MultipleOneSlash", + ReplaceChar: '/', + Input: "test/multiple/one/slash", //nolint:goconst + ExpectedOutput: "test/multiple/one/slash", + }, + { + Name: "OneSlashAtStartAndEnd", + ReplaceChar: '/', + Input: "/test/start/one/slash/end/", //nolint:goconst + ExpectedOutput: "/test/start/one/slash/end/", + }, + { + Name: "DoubleOneSlash", + ReplaceChar: '/', + Input: "test//onlyoneslash", + ExpectedOutput: "test/onlyoneslash", + }, + { + Name: "TripleOneSlash", + ReplaceChar: '/', + Input: "test///onlyoneslash", + ExpectedOutput: "test/onlyoneslash", + }, + { + Name: "MultipleDoubleSlash", + ReplaceChar: '/', + Input: "test//multiple//one//slash", + ExpectedOutput: "test/multiple/one/slash", + }, + { + Name: "MultipleSlashAtStartAndEnd", + ReplaceChar: '/', + Input: "///test/start/one/slash/end//", + ExpectedOutput: "/test/start/one/slash/end/", + }, + { + Name: "MixedMultipleSlash", + ReplaceChar: '/', + Input: "///test//start/one///slash/end/", + ExpectedOutput: "/test/start/one/slash/end/", + }, + } + + for _, testCase := range tests { + output := utils.ReplaceDuplicateChar(testCase.Input, testCase.ReplaceChar) + assert.Equal( + t, + testCase.ExpectedOutput, + output, + "Expected output: %s, got: %s", + testCase.ExpectedOutput, + output, + ) + } +}