diff --git a/src/server/middleware/path/path.go b/src/server/middleware/path/path.go new file mode 100644 index 000000000..525df0e6c --- /dev/null +++ b/src/server/middleware/path/path.go @@ -0,0 +1,100 @@ +// Copyright Project Harbor Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package path + +import ( + "net/http" + "net/url" + "regexp" + + "github.com/goharbor/harbor/src/common/api" + "github.com/goharbor/harbor/src/server/middleware" +) + +var ( + defaultRegexps = []*regexp.Regexp{ + regexp.MustCompile(`^/api/` + api.APIVersion + `/projects/.*/repositories/(.*)/artifacts/?$`), + regexp.MustCompile(`^/api/` + api.APIVersion + `/projects/.*/repositories/(.*)/artifacts/.*$`), + regexp.MustCompile(`^/api/` + api.APIVersion + `/projects/.*/repositories/(.*)/?$`), + } +) + +// EscapeMiddleware middleware which escape path parameters for swagger APIs +func EscapeMiddleware() func(http.Handler) http.Handler { + return middleware.New(func(w http.ResponseWriter, r *http.Request, next http.Handler) { + for _, re := range defaultRegexps { + if re.MatchString(r.URL.Path) { + r.URL.Path = escape(re, r.URL.Path) + break + } + } + + next.ServeHTTP(w, r) + }) +} + +func escape(re *regexp.Regexp, path string) string { + return replaceAllSubmatchFunc(re, path, func(groups []string) []string { + var results []string + for _, group := range groups { + results = append(results, url.PathEscape(group)) + } + return results + }, -1) +} + +func replaceAllSubmatchFunc(re *regexp.Regexp, src string, repl func([]string) []string, n int) string { + var result string + + last := 0 + for _, match := range re.FindAllSubmatchIndex([]byte(src), n) { + // Append string between our last match and this one (i.e. non-matched string). + matchStart := match[0] + matchEnd := match[1] + result = result + src[last:matchStart] + last = matchEnd + + // Determine the groups / submatch string and indices. + groups := []string{} + indices := [][2]int{} + for i := 2; i < len(match); i += 2 { + start := match[i] + end := match[i+1] + groups = append(groups, src[start:end]) + indices = append(indices, [2]int{start, end}) + } + + // Replace the groups + groups = repl(groups) + + // Append match data. + lastGroup := matchStart + for i, newValue := range groups { + // Append string between our last group match and this one (i.e. non-group-matched string) + groupStart := indices[i][0] + groupEnd := indices[i][1] + result = result + src[lastGroup:groupStart] + lastGroup = groupEnd + + // Append the new group value. + result = result + newValue + } + result = result + src[lastGroup:matchEnd] // remaining + } + + result = result + src[last:] // remaining + + return result +} diff --git a/src/server/middleware/path/path_test.go b/src/server/middleware/path/path_test.go new file mode 100644 index 000000000..4853f980d --- /dev/null +++ b/src/server/middleware/path/path_test.go @@ -0,0 +1,84 @@ +// Copyright Project Harbor Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package path + +import ( + "net/http" + "net/http/httptest" + "regexp" + "testing" +) + +func Test_escape(t *testing.T) { + re := regexp.MustCompile(`/api/v2.0/projects/.*/repositories/(.*)/artifacts`) + + type args struct { + re *regexp.Regexp + path string + } + tests := []struct { + name string + args args + want string + }{ + { + "/api/v2.0/projects/library/repositories/photon/artifacts", + args{re, "/api/v2.0/projects/library/repositories/photon/artifacts"}, + "/api/v2.0/projects/library/repositories/photon/artifacts", + }, + { + "/api/v2.0/projects/library/repositories/photon/hello-world/artifacts", + args{re, "/api/v2.0/projects/library/repositories/photon/hello-world/artifacts"}, + "/api/v2.0/projects/library/repositories/photon%2Fhello-world/artifacts", + }, + { + "/api/v2.0/projects/library/repositories/photon/hello-world/artifacts/digest/scan", + args{re, "/api/v2.0/projects/library/repositories/photon/hello-world/artifacts/digest/scan"}, + "/api/v2.0/projects/library/repositories/photon%2Fhello-world/artifacts/digest/scan", + }, + + { + "/api/v2.0/projects/library/repositories", + args{re, "/api/v2.0/projects/library/repositories"}, + "/api/v2.0/projects/library/repositories", + }, + { + "/api/v2.0/projects/library/repositories/hello/mariadb", + args{regexp.MustCompile(`^/api/v2.0/projects/.*/repositories/(.*)`), "/api/v2.0/projects/library/repositories/hello/mariadb"}, + "/api/v2.0/projects/library/repositories/hello%2Fmariadb", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := escape(tt.args.re, tt.args.path); got != tt.want { + t.Errorf("escape() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestEscapeMiddleware(t *testing.T) { + r := httptest.NewRequest(http.MethodGet, "/api/v2.0/projects/library/repositories/hello/mariadb", nil) + w := httptest.NewRecorder() + + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v2.0/projects/library/repositories/hello%2Fmariadb" { + t.Errorf("escape middleware failed") + } + w.WriteHeader(http.StatusOK) + }) + + EscapeMiddleware()(next).ServeHTTP(w, r) +} diff --git a/src/server/v2.0/handler/artifact.go b/src/server/v2.0/handler/artifact.go index bc5968a2a..c15687881 100644 --- a/src/server/v2.0/handler/artifact.go +++ b/src/server/v2.0/handler/artifact.go @@ -60,6 +60,14 @@ type artifactAPI struct { tagCtl tag.Controller } +func (a *artifactAPI) Prepare(ctx context.Context, operation string, params interface{}) middleware.Responder { + if err := unescapePathParams(params, "RepositoryName"); err != nil { + a.SendError(ctx, err) + } + + return nil +} + func (a *artifactAPI) ListArtifacts(ctx context.Context, params operation.ListArtifactsParams) middleware.Responder { if err := a.RequireProjectAccess(ctx, params.ProjectName, rbac.ActionList, rbac.ResourceArtifact); err != nil { return a.SendError(ctx, err) diff --git a/src/server/v2.0/handler/handler.go b/src/server/v2.0/handler/handler.go index 86dfc1345..cf094e9a3 100644 --- a/src/server/v2.0/handler/handler.go +++ b/src/server/v2.0/handler/handler.go @@ -15,10 +15,12 @@ package handler import ( - serror "github.com/goharbor/harbor/src/server/error" - "github.com/goharbor/harbor/src/server/v2.0/restapi" "log" "net/http" + + serror "github.com/goharbor/harbor/src/server/error" + "github.com/goharbor/harbor/src/server/middleware/path" + "github.com/goharbor/harbor/src/server/v2.0/restapi" ) // New returns http handler for API V2.0 @@ -34,7 +36,9 @@ func New() http.Handler { api.ServeError = serveError - return h + // HACK: Use path.EscapeMiddleware to escape same patterns of the URL before the swagger handler + // eg /api/v2.0/projects/library/repositories/hello/world/artifacts to /api/v2.0/projects/library/repositories/hello%2Fworld/artifacts + return path.EscapeMiddleware()(h) } // Before executing operation handler, go-swagger will bind a parameters object to a request and validate the request, diff --git a/src/server/v2.0/handler/repository.go b/src/server/v2.0/handler/repository.go index 47a94f26d..36543f514 100644 --- a/src/server/v2.0/handler/repository.go +++ b/src/server/v2.0/handler/repository.go @@ -17,6 +17,7 @@ package handler import ( "context" "fmt" + "github.com/go-openapi/runtime/middleware" "github.com/goharbor/harbor/src/api/artifact" "github.com/goharbor/harbor/src/api/project" @@ -44,6 +45,14 @@ type repositoryAPI struct { artCtl artifact.Controller } +func (r *repositoryAPI) Prepare(ctx context.Context, operation string, params interface{}) middleware.Responder { + if err := unescapePathParams(params, "RepositoryName"); err != nil { + r.SendError(ctx, err) + } + + return nil +} + func (r *repositoryAPI) ListRepositories(ctx context.Context, params operation.ListRepositoriesParams) middleware.Responder { if err := r.RequireProjectAccess(ctx, params.ProjectName, rbac.ActionList, rbac.ResourceRepository); err != nil { return r.SendError(ctx, err) diff --git a/src/server/v2.0/handler/util.go b/src/server/v2.0/handler/util.go index 28bddfc7d..db8f29156 100644 --- a/src/server/v2.0/handler/util.go +++ b/src/server/v2.0/handler/util.go @@ -17,10 +17,14 @@ package handler import ( "context" "encoding/json" + "fmt" + "net/url" + "reflect" "github.com/goharbor/harbor/src/api/artifact" "github.com/goharbor/harbor/src/api/artifact/abstractor/resolver" "github.com/goharbor/harbor/src/api/scan" + "github.com/goharbor/harbor/src/common/utils/log" "github.com/goharbor/harbor/src/pkg/scan/report" v1 "github.com/goharbor/harbor/src/pkg/scan/rest/v1" ) @@ -68,3 +72,41 @@ func resolveVulnerabilitiesAddition(ctx context.Context, artifact *artifact.Arti ContentType: "application/json", }, nil } + +func unescapePathParams(params interface{}, fieldNames ...string) error { + val := reflect.ValueOf(params) + if val.Kind() != reflect.Ptr { + return fmt.Errorf("params must be ptr") + } + + val = val.Elem() + if val.Kind() != reflect.Struct { + return fmt.Errorf("params must be struct") + } + + for _, name := range fieldNames { + field := val.FieldByName(name) + if !field.IsValid() { + log.Warningf("field %s not found in params %v", name, params) + continue + } + + if !field.CanSet() { + log.Warningf("field %s can not be changed in params %v", name, params) + continue + } + + switch field.Type().Kind() { + case reflect.String: + v, err := url.PathUnescape(field.String()) + if err != nil { + return err + } + field.SetString(v) + default: + log.Warningf("field %s can not be unescaped in params %v", name, params) + } + } + + return nil +} diff --git a/src/server/v2.0/handler/util_test.go b/src/server/v2.0/handler/util_test.go new file mode 100644 index 000000000..f737a87ed --- /dev/null +++ b/src/server/v2.0/handler/util_test.go @@ -0,0 +1,57 @@ +// Copyright Project Harbor Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package handler + +import ( + "testing" +) + +func Test_unescapePathParams(t *testing.T) { + type Params struct { + ProjectName string + RepositoryName string + } + + str := "params" + + type args struct { + params interface{} + fieldNames []string + } + tests := []struct { + name string + args args + wantErr bool + }{ + {"non ptr", args{str, []string{"RepositoryName"}}, true}, + {"non struct", args{&str, []string{"RepositoryName"}}, true}, + {"ptr of struct", args{&Params{}, []string{"RepositoryName"}}, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := unescapePathParams(tt.args.params, tt.args.fieldNames...); (err != nil) != tt.wantErr { + t.Errorf("unescapePathParams() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } + + t.Run("ok", func(t *testing.T) { + params := Params{ProjectName: "library", RepositoryName: "hello%2Fworld"} + unescapePathParams(¶ms, "RepositoryName") + if params.RepositoryName != "hello/world" { + t.Errorf("unescapePathParams() not unescape RepositoryName field") + } + }) +}