diff --git a/src/core/middlewares/contenttrust/handler.go b/src/core/middlewares/contenttrust/handler.go index ba6ed37cad03..d7c9e3c64917 100644 --- a/src/core/middlewares/contenttrust/handler.go +++ b/src/core/middlewares/contenttrust/handler.go @@ -62,13 +62,13 @@ func (cth contentTrustHandler) ServeHTTP(rw http.ResponseWriter, req *http.Reque util.CopyResp(rec, rw) } -func validate(req *http.Request) (bool, util.ImageInfo) { - var img util.ImageInfo - imgRaw := req.Context().Value(util.ImageInfoCtxKey) +func validate(req *http.Request) (bool, util.ArtifactInfo) { + var img util.ArtifactInfo + imgRaw := req.Context().Value(util.ArtifactInfoCtxKey) if imgRaw == nil || !config.WithNotary() { return false, img } - img, _ = req.Context().Value(util.ImageInfoCtxKey).(util.ImageInfo) + img, _ = req.Context().Value(util.ArtifactInfoCtxKey).(util.ArtifactInfo) if img.Digest == "" { return false, img } @@ -81,7 +81,7 @@ func validate(req *http.Request) (bool, util.ImageInfo) { return true, img } -func matchNotaryDigest(img util.ImageInfo) (bool, error) { +func matchNotaryDigest(img util.ArtifactInfo) (bool, error) { if NotaryEndpoint == "" { NotaryEndpoint = config.InternalNotaryEndpoint() } diff --git a/src/core/middlewares/contenttrust/handler_test.go b/src/core/middlewares/contenttrust/handler_test.go index 910f6f00b299..08f9b45005e2 100644 --- a/src/core/middlewares/contenttrust/handler_test.go +++ b/src/core/middlewares/contenttrust/handler_test.go @@ -49,8 +49,8 @@ func TestMain(m *testing.M) { func TestMatchNotaryDigest(t *testing.T) { assert := assert.New(t) // The data from common/utils/notary/helper_test.go - img1 := util.ImageInfo{Repository: "notary-demo/busybox", Reference: "1.0", ProjectName: "notary-demo", Digest: "sha256:1359608115b94599e5641638bac5aef1ddfaa79bb96057ebf41ebc8d33acf8a7"} - img2 := util.ImageInfo{Repository: "notary-demo/busybox", Reference: "2.0", ProjectName: "notary-demo", Digest: "sha256:12345678"} + img1 := util.ArtifactInfo{Repository: "notary-demo/busybox", Reference: "1.0", ProjectName: "notary-demo", Digest: "sha256:1359608115b94599e5641638bac5aef1ddfaa79bb96057ebf41ebc8d33acf8a7"} + img2 := util.ArtifactInfo{Repository: "notary-demo/busybox", Reference: "2.0", ProjectName: "notary-demo", Digest: "sha256:12345678"} res1, err := matchNotaryDigest(img1) assert.Nil(err, "Unexpected error: %v, image: %#v", err, img1) diff --git a/src/core/middlewares/regtoken/handler.go b/src/core/middlewares/regtoken/handler.go index 0225c0338900..2cb1c9af4201 100644 --- a/src/core/middlewares/regtoken/handler.go +++ b/src/core/middlewares/regtoken/handler.go @@ -27,12 +27,12 @@ func New(next http.Handler) http.Handler { // ServeHTTP ... func (r *regTokenHandler) ServeHTTP(rw http.ResponseWriter, req *http.Request) { - imgRaw := req.Context().Value(util.ImageInfoCtxKey) + imgRaw := req.Context().Value(util.ArtifactInfoCtxKey) if imgRaw == nil { r.next.ServeHTTP(rw, req) return } - img, _ := req.Context().Value(util.ImageInfoCtxKey).(util.ImageInfo) + img, _ := req.Context().Value(util.ArtifactInfoCtxKey).(util.ArtifactInfo) if img.Digest == "" { r.next.ServeHTTP(rw, req) return diff --git a/src/core/middlewares/url/handler.go b/src/core/middlewares/url/handler.go index 07e1a0f3fd9b..211dbc39bf7a 100644 --- a/src/core/middlewares/url/handler.go +++ b/src/core/middlewares/url/handler.go @@ -20,10 +20,19 @@ import ( "github.com/goharbor/harbor/src/common/utils/log" "github.com/goharbor/harbor/src/core/middlewares/util" coreutils "github.com/goharbor/harbor/src/core/utils" + "github.com/opencontainers/go-digest" "net/http" + "regexp" "strings" ) +var ( + urlPatterns = []*regexp.Regexp{ + util.ManifestURLRe, util.TagListURLRe, util.BlobURLRe, util.BlobUploadURLRe, + } +) + +// urlHandler extracts the artifact info from the url of request to V2 handler and propagates it to context type urlHandler struct { next http.Handler } @@ -37,38 +46,65 @@ func New(next http.Handler) http.Handler { // ServeHTTP ... func (uh urlHandler) ServeHTTP(rw http.ResponseWriter, req *http.Request) { - log.Debugf("in url handler, path: %s", req.URL.Path) - flag, repository, reference := util.MatchPullManifest(req) - if flag { - components := strings.SplitN(repository, "/", 2) - if len(components) < 2 { - http.Error(rw, util.MarshalError("PROJECT_POLICY_VIOLATION", fmt.Sprintf("Bad repository name: %s", repository)), http.StatusBadRequest) - return - } + path := req.URL.Path + log.Debugf("in url handler, path: %s", path) + m, ok := parse(path) + if !ok { + uh.next.ServeHTTP(rw, req) + } + repo := m[util.RepositorySubexp] + components := strings.SplitN(repo, "/", 2) + if len(components) < 2 { + http.Error(rw, util.MarshalError("PROJECT_POLICY_VIOLATION", fmt.Sprintf("Bad repository name: %s", repo)), http.StatusBadRequest) + return + } + art := util.ArtifactInfo{ + Repository: repo, + ProjectName: components[0], + } + if digest, ok := m[util.DigestSubexp]; ok { + art.Digest = digest + } + if ref, ok := m[util.ReferenceSubexp]; ok { + art.Reference = ref + } - client, err := coreutils.NewRepositoryClientForUI(util.TokenUsername, repository) + if util.ManifestURLRe.MatchString(path) && req.Method == http.MethodGet { // Request for pulling manifest + client, err := coreutils.NewRepositoryClientForUI(util.TokenUsername, art.Repository) if err != nil { log.Errorf("Error creating repository Client: %v", err) http.Error(rw, util.MarshalError("PROJECT_POLICY_VIOLATION", fmt.Sprintf("Failed due to internal Error: %v", err)), http.StatusInternalServerError) return } - digest, _, err := client.ManifestExist(reference) + digest, _, err := client.ManifestExist(art.Reference) if err != nil { - log.Errorf("Failed to get digest for reference: %s, error: %v", reference, err) + log.Errorf("Failed to get digest for reference: %s, error: %v", art.Reference, err) http.Error(rw, util.MarshalError("PROJECT_POLICY_VIOLATION", fmt.Sprintf("Failed due to internal Error: %v", err)), http.StatusInternalServerError) return } - img := util.ImageInfo{ - Repository: repository, - Reference: reference, - ProjectName: components[0], - Digest: digest, - } - - log.Debugf("image info of the request: %#v", img) - ctx := context.WithValue(req.Context(), util.ImageInfoCtxKey, img) + art.Digest = digest + log.Debugf("artifact info of the request: %#v", art) + ctx := context.WithValue(req.Context(), util.ArtifactInfoCtxKey, art) req = req.WithContext(ctx) } uh.next.ServeHTTP(rw, req) } + +func parse(urlPath string) (map[string]string, bool) { + m := make(map[string]string) + match := false + for _, re := range urlPatterns { + l := re.FindStringSubmatch(urlPath) + if len(l) > 0 { + match = true + for i := 1; i < len(l); i++ { + m[re.SubexpNames()[i]] = l[i] + } + } + } + if digest.DigestRegexp.MatchString(m[util.ReferenceSubexp]) { + m[util.DigestSubexp] = m[util.ReferenceSubexp] + } + return m, match +} diff --git a/src/core/middlewares/url/handler_test.go b/src/core/middlewares/url/handler_test.go new file mode 100644 index 000000000000..524baa6774f9 --- /dev/null +++ b/src/core/middlewares/url/handler_test.go @@ -0,0 +1,101 @@ +// 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 url + +import ( + "github.com/goharbor/harbor/src/core/middlewares/util" + "github.com/stretchr/testify/assert" + "os" + "testing" +) + +func TestMain(m *testing.M) { + if result := m.Run(); result != 0 { + os.Exit(result) + } +} + +func TestParseURL(t *testing.T) { + cases := []struct { + input string + expect map[string]string + match bool + }{ + { + input: "/api/projects", + expect: map[string]string{}, + match: false, + }, + { + input: "/v2/_catalog", + expect: map[string]string{}, + match: false, + }, + { + input: "/v2/no-project-repo/tags/list", + expect: map[string]string{ + util.RepositorySubexp: "no-project-repo", + }, + match: true, + }, + { + input: "/v2/development/golang/manifests/sha256:08e4a417ff4e3913d8723a05cc34055db01c2fd165b588e049c5bad16ce6094f", + expect: map[string]string{ + util.RepositorySubexp: "development/golang", + util.ReferenceSubexp: "sha256:08e4a417ff4e3913d8723a05cc34055db01c2fd165b588e049c5bad16ce6094f", + util.DigestSubexp: "sha256:08e4a417ff4e3913d8723a05cc34055db01c2fd165b588e049c5bad16ce6094f", + }, + match: true, + }, + { + input: "/v2/development/golang/manifests/shaxxx:xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + expect: map[string]string{}, + match: false, + }, + { + input: "/v2/multi/sector/repository/blobs/sha256:08e4a417ff4e3913d8723a05cc34055db01c2fd165b588e049c5bad16ce6094f", + expect: map[string]string{ + util.RepositorySubexp: "multi/sector/repository", + util.DigestSubexp: "sha256:08e4a417ff4e3913d8723a05cc34055db01c2fd165b588e049c5bad16ce6094f", + }, + match: true, + }, + { + input: "/v2/blobs/uploads", + expect: map[string]string{}, + match: false, + }, + { + input: "/v2/library/ubuntu/blobs/uploads", + expect: map[string]string{ + util.RepositorySubexp: "library/ubuntu", + }, + match: true, + }, + { + input: "/v2/library/centos/blobs/uploads/u-12345", + expect: map[string]string{ + util.RepositorySubexp: "library/centos", + }, + match: true, + }, + } + + for _, c := range cases { + e, m := parse(c.input) + assert.Equal(t, c.match, m) + assert.Equal(t, c.expect, e) + } +} diff --git a/src/core/middlewares/util/util.go b/src/core/middlewares/util/util.go index 80ccacb7e6dd..f90f21f4fee7 100644 --- a/src/core/middlewares/util/util.go +++ b/src/core/middlewares/util/util.go @@ -31,6 +31,8 @@ import ( "github.com/docker/distribution" "github.com/docker/distribution/manifest/schema1" "github.com/docker/distribution/manifest/schema2" + "github.com/docker/distribution/reference" + "github.com/garyburd/redigo/redis" "github.com/goharbor/harbor/src/common/dao" "github.com/goharbor/harbor/src/common/models" @@ -49,8 +51,15 @@ import ( type contextKey string const ( - // ImageInfoCtxKey the context key for image information - ImageInfoCtxKey = contextKey("ImageInfo") + // RepositorySubexp is the name for sub regex that maps to repository name in the url + RepositorySubexp = "repository" + // ReferenceSubexp is the name for sub regex that maps to reference (tag or digest) url + ReferenceSubexp = "reference" + // DigestSubexp is the name for sub regex that maps to digest in the url + DigestSubexp = "digest" + + // ArtifactInfoCtxKey the context key for artifact information + ArtifactInfoCtxKey = contextKey("ArtifactInfo") // ScannerPullCtxKey the context key for robot account to bypass the pull policy check. ScannerPullCtxKey = contextKey("ScannerPullCheck") // TokenUsername ... @@ -73,7 +82,16 @@ const ( ) var ( - manifestURLRe = regexp.MustCompile(`^/v2/((?:[a-z0-9]+(?:[._-][a-z0-9]+)*/)+)manifests/([\w][\w.:-]{0,127})`) + // ManifestURLRe is the regular expression for matching request v2 handler to view/delete manifest + ManifestURLRe = regexp.MustCompile(fmt.Sprintf(`^/v2/(?P<%s>%s)/manifests/(?P<%s>%s|%s)$`, RepositorySubexp, reference.NameRegexp.String(), ReferenceSubexp, reference.TagRegexp.String(), digest.DigestRegexp.String())) + // TagListURLRe is the regular expression for matching request to v2 handler to list tags + TagListURLRe = regexp.MustCompile(fmt.Sprintf(`^/v2/(?P<%s>%s)/tags/list`, RepositorySubexp, reference.NameRegexp.String())) + // BlobURLRe is the regular expression for matching request to v2 handler to retrieve delete a blob + BlobURLRe = regexp.MustCompile(fmt.Sprintf(`^/v2/(?P<%s>%s)/blobs/(?P<%s>%s)$`, RepositorySubexp, reference.NameRegexp.String(), DigestSubexp, digest.DigestRegexp.String())) + // BlobUploadURLRe is the regular expression for matching the request to v2 handler to upload a blob, the upload uuid currently is not put into a group + BlobUploadURLRe = regexp.MustCompile(fmt.Sprintf(`^/v2/(?P<%s>%s)/blobs/uploads[/a-zA-Z0-9\-_\.=]*$`, RepositorySubexp, reference.NameRegexp.String())) + // CatalogURLRe is the regular expression for mathing the request to v2 handler to list catalog + CatalogURLRe = regexp.MustCompile(`^/v2/_catalog$`) ) // ChartVersionInfo ... @@ -91,8 +109,8 @@ func (info *ChartVersionInfo) MutexKey(suffix ...string) string { return strings.Join(append(a, suffix...), ":") } -// ImageInfo ... -type ImageInfo struct { +// ArtifactInfo ... +type ArtifactInfo struct { Repository string Reference string ProjectName string @@ -281,7 +299,7 @@ func MarshalError(code, msg string) string { // MatchManifestURL ... func MatchManifestURL(req *http.Request) (bool, string, string) { - s := manifestURLRe.FindStringSubmatch(req.URL.Path) + s := ManifestURLRe.FindStringSubmatch(req.URL.Path) if len(s) == 3 { s[1] = strings.TrimSuffix(s[1], "/") return true, s[1], s[2] @@ -437,8 +455,8 @@ func ChartVersionInfoFromContext(ctx context.Context) (*ChartVersionInfo, bool) } // ImageInfoFromContext returns image info from context -func ImageInfoFromContext(ctx context.Context) (*ImageInfo, bool) { - info, ok := ctx.Value(ImageInfoCtxKey).(*ImageInfo) +func ImageInfoFromContext(ctx context.Context) (*ArtifactInfo, bool) { + info, ok := ctx.Value(ArtifactInfoCtxKey).(*ArtifactInfo) return info, ok } @@ -470,8 +488,8 @@ func NewChartVersionInfoContext(ctx context.Context, info *ChartVersionInfo) con } // NewImageInfoContext returns context with image info -func NewImageInfoContext(ctx context.Context, info *ImageInfo) context.Context { - return context.WithValue(ctx, ImageInfoCtxKey, info) +func NewImageInfoContext(ctx context.Context, info *ArtifactInfo) context.Context { + return context.WithValue(ctx, ArtifactInfoCtxKey, info) } // NewManifestInfoContext returns context with manifest info diff --git a/src/core/middlewares/vulnerable/handler.go b/src/core/middlewares/vulnerable/handler.go index 5af111df30c2..6051221362c7 100644 --- a/src/core/middlewares/vulnerable/handler.go +++ b/src/core/middlewares/vulnerable/handler.go @@ -108,17 +108,17 @@ func (vh vulnerableHandler) ServeHTTP(rw http.ResponseWriter, req *http.Request) util.CopyResp(rec, rw) } -func validate(req *http.Request) (bool, util.ImageInfo, vuln.Severity, models.CVEWhitelist) { +func validate(req *http.Request) (bool, util.ArtifactInfo, vuln.Severity, models.CVEWhitelist) { var vs vuln.Severity var wl models.CVEWhitelist - var img util.ImageInfo - imgRaw := req.Context().Value(util.ImageInfoCtxKey) + var img util.ArtifactInfo + imgRaw := req.Context().Value(util.ArtifactInfoCtxKey) if imgRaw == nil { return false, img, vs, wl } // Expected artifact specified? - img, ok := imgRaw.(util.ImageInfo) + img, ok := imgRaw.(util.ArtifactInfo) if !ok || len(img.Digest) == 0 { return false, img, vs, wl } diff --git a/src/server/middleware/artifactinfo/artifact_info.go b/src/server/middleware/artifactinfo/artifact_info.go new file mode 100644 index 000000000000..0a1823573b64 --- /dev/null +++ b/src/server/middleware/artifactinfo/artifact_info.go @@ -0,0 +1,127 @@ +// 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 artifactinfo + +import ( + "context" + "fmt" + "net/http" + "net/url" + "regexp" + "strings" + + "github.com/goharbor/harbor/src/common/utils/log" + ierror "github.com/goharbor/harbor/src/internal/error" + "github.com/goharbor/harbor/src/server/middleware" + reg_err "github.com/goharbor/harbor/src/server/registry/error" + "github.com/opencontainers/go-digest" +) + +const ( + blobMountQuery = "mount" + blobFromQuery = "from" + blobMountDigest = "blob_mount_digest" + blobMountRepo = "blob_mount_repo" +) + +var ( + urlPatterns = map[string]*regexp.Regexp{ + "manifest": middleware.V2ManifestURLRe, + "tag_list": middleware.V2TagListURLRe, + "blob_upload": middleware.V2BlobUploadURLRe, + "blob": middleware.V2BlobURLRe, + } +) + +// Middleware gets the information of artifact via url of the request and inject it into the context +func Middleware() func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + + path := req.URL.Path + req.URL.String() + log.Debugf("in artifact info middleware, path: %s", path) + m, ok := parse(req.URL) + if !ok { + next.ServeHTTP(rw, req) + return + } + repo := m[middleware.RepositorySubexp] + pn, err := projectNameFromRepo(repo) + if err != nil { + reg_err.Handle(rw, req, ierror.BadRequestError(err)) + return + } + art := &middleware.ArtifactInfo{ + Repository: repo, + ProjectName: pn, + } + if d, ok := m[middleware.DigestSubexp]; ok { + art.Digest = d + } + if ref, ok := m[middleware.ReferenceSubexp]; ok { + art.Reference = ref + } + + if bmr, ok := m[blobMountRepo]; ok { + // Fail early for now, though in docker registry an invalid may return 202 + // it's not clear in OCI spec how to handle invalid from parm + bmp, err := projectNameFromRepo(bmr) + if err != nil { + reg_err.Handle(rw, req, ierror.BadRequestError(err)) + return + } + art.BlobMountDigest = m[blobMountDigest] + art.BlobMountProjectName = bmp + art.BlobMountRepository = bmr + } + ctx := context.WithValue(req.Context(), middleware.ArtifactInfoKey, art) + next.ServeHTTP(rw, req.WithContext(ctx)) + }) + } +} + +func projectNameFromRepo(repo string) (string, error) { + components := strings.SplitN(repo, "/", 2) + if len(components) < 2 { + return "", fmt.Errorf("invalid repository name: %s", repo) + } + return components[0], nil +} + +func parse(url *url.URL) (map[string]string, bool) { + path := url.Path + query := url.Query() + m := make(map[string]string) + match := false + for key, re := range urlPatterns { + l := re.FindStringSubmatch(path) + if len(l) > 0 { + match = true + for i := 1; i < len(l); i++ { + m[re.SubexpNames()[i]] = l[i] + } + if key == "blob_upload" && len(query.Get(blobFromQuery)) > 0 { + m[blobMountDigest] = query.Get(blobMountQuery) + m[blobMountRepo] = query.Get(blobFromQuery) + } + break + } + } + if digest.DigestRegexp.MatchString(m[middleware.ReferenceSubexp]) { + m[middleware.DigestSubexp] = m[middleware.ReferenceSubexp] + } + return m, match +} diff --git a/src/server/middleware/artifactinfo/artifact_info_test.go b/src/server/middleware/artifactinfo/artifact_info_test.go new file mode 100644 index 000000000000..f09ccdd3c22c --- /dev/null +++ b/src/server/middleware/artifactinfo/artifact_info_test.go @@ -0,0 +1,182 @@ +// 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 artifactinfo + +import ( + "context" + "github.com/goharbor/harbor/src/server/middleware" + "github.com/stretchr/testify/assert" + "net/http" + "net/http/httptest" + "net/url" + "testing" +) + +func TestParseURL(t *testing.T) { + cases := []struct { + input string + expect map[string]string + match bool + }{ + { + input: "/api/projects", + expect: map[string]string{}, + match: false, + }, + { + input: "/v2/_catalog", + expect: map[string]string{}, + match: false, + }, + { + input: "/v2/no-project-repo/tags/list", + expect: map[string]string{ + middleware.RepositorySubexp: "no-project-repo", + }, + match: true, + }, + { + input: "/v2/development/golang/manifests/sha256:08e4a417ff4e3913d8723a05cc34055db01c2fd165b588e049c5bad16ce6094f", + expect: map[string]string{ + middleware.RepositorySubexp: "development/golang", + middleware.ReferenceSubexp: "sha256:08e4a417ff4e3913d8723a05cc34055db01c2fd165b588e049c5bad16ce6094f", + middleware.DigestSubexp: "sha256:08e4a417ff4e3913d8723a05cc34055db01c2fd165b588e049c5bad16ce6094f", + }, + match: true, + }, + { + input: "/v2/development/golang/manifests/shaxxx:xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + expect: map[string]string{}, + match: false, + }, + { + input: "/v2/multi/sector/repository/blobs/sha256:08e4a417ff4e3913d8723a05cc34055db01c2fd165b588e049c5bad16ce6094f", + expect: map[string]string{ + middleware.RepositorySubexp: "multi/sector/repository", + middleware.DigestSubexp: "sha256:08e4a417ff4e3913d8723a05cc34055db01c2fd165b588e049c5bad16ce6094f", + }, + match: true, + }, + { + input: "/v2/blobs/uploads", + expect: map[string]string{}, + match: false, + }, + { + input: "/v2/library/ubuntu/blobs/uploads", + expect: map[string]string{ + middleware.RepositorySubexp: "library/ubuntu", + }, + match: true, + }, + { + input: "/v2/library/ubuntu/blobs/uploads/?mount=sha256:08e4a417ff4e3913d8723a05cc34055db01c2fd165b588e049c5bad16ce6094f&from=old/ubuntu", + expect: map[string]string{ + middleware.RepositorySubexp: "library/ubuntu", + blobMountDigest: "sha256:08e4a417ff4e3913d8723a05cc34055db01c2fd165b588e049c5bad16ce6094f", + blobMountRepo: "old/ubuntu", + }, + match: true, + }, + { + input: "/v2/library/centos/blobs/uploads/u-12345", + expect: map[string]string{ + middleware.RepositorySubexp: "library/centos", + }, + match: true, + }, + } + + for _, c := range cases { + url, err := url.Parse(c.input) + if err != nil { + panic(err) + } + e, m := parse(url) + assert.Equal(t, c.match, m) + assert.Equal(t, c.expect, e) + } +} + +type handler struct { + ctx context.Context +} + +func (h *handler) ServeHTTP(w http.ResponseWriter, req *http.Request) { + w.WriteHeader(http.StatusOK) + h.ctx = req.Context() +} +func TestPopulateArtifactInfo(t *testing.T) { + + cases := []struct { + req *http.Request + sc int + art *middleware.ArtifactInfo + }{ + { + req: httptest.NewRequest(http.MethodDelete, "/v2/hello-world/manifests/latest", nil), + sc: http.StatusBadRequest, + art: nil, + }, + { + req: httptest.NewRequest(http.MethodDelete, "/v2/library/hello-world/manifests/latest", nil), + sc: http.StatusOK, + art: &middleware.ArtifactInfo{ + Repository: "library/hello-world", + Reference: "latest", + ProjectName: "library", + }, + }, + { + req: httptest.NewRequest(http.MethodPost, "/v2/library/ubuntu/blobs/uploads/?mount=sha256:08e4a417ff4e3913d8723a05cc34055db01c2fd165b588e049c5bad16ce6094f&from=no-project", nil), + sc: http.StatusBadRequest, + art: nil, + }, + { + req: httptest.NewRequest(http.MethodPost, "/v2/library/ubuntu/blobs/uploads/?from=old/ubuntu&mount=sha256:08e4a417ff4e3913d8723a05cc34055db01c2fd165b588e049c5bad16ce6094f", nil), + sc: http.StatusOK, + art: &middleware.ArtifactInfo{ + Repository: "library/ubuntu", + ProjectName: "library", + BlobMountRepository: "old/ubuntu", + BlobMountDigest: "sha256:08e4a417ff4e3913d8723a05cc34055db01c2fd165b588e049c5bad16ce6094f", + BlobMountProjectName: "old", + }, + }, + { + req: httptest.NewRequest(http.MethodDelete, "/v2/library/hello-world/manifests/sha256:08e4a417ff4e3913d8723a05cc34055db01c2fd165b588e049c5bad16ce6094f", nil), + sc: http.StatusOK, + art: &middleware.ArtifactInfo{ + Repository: "library/hello-world", + Reference: "sha256:08e4a417ff4e3913d8723a05cc34055db01c2fd165b588e049c5bad16ce6094f", + Digest: "sha256:08e4a417ff4e3913d8723a05cc34055db01c2fd165b588e049c5bad16ce6094f", + ProjectName: "library", + }, + }, + } + next := &handler{} + + for _, tt := range cases { + rec := httptest.NewRecorder() + + Middleware()(next).ServeHTTP(rec, tt.req) + assert.Equal(t, tt.sc, rec.Code) + if tt.art != nil { + a, ok := middleware.ArtifactInfoFromContext(next.ctx) + assert.True(t, ok) + assert.Equal(t, *tt.art, *a) + } + } +} diff --git a/src/server/middleware/util.go b/src/server/middleware/util.go index 4ff140c0e0ea..de111a11f6b4 100644 --- a/src/server/middleware/util.go +++ b/src/server/middleware/util.go @@ -2,17 +2,42 @@ package middleware import ( "context" + "fmt" + "github.com/docker/distribution/reference" + "github.com/opencontainers/go-digest" + "regexp" ) type contextKey string const ( + // RepositorySubexp is the name for sub regex that maps to repository name in the url + RepositorySubexp = "repository" + // ReferenceSubexp is the name for sub regex that maps to reference (tag or digest) url + ReferenceSubexp = "reference" + // DigestSubexp is the name for sub regex that maps to digest in the url + DigestSubexp = "digest" + // ArtifactInfoKey the context key for artifact info + ArtifactInfoKey = contextKey("artifactInfo") // manifestInfoKey the context key for manifest info manifestInfoKey = contextKey("ManifestInfo") // ScannerPullCtxKey the context key for robot account to bypass the pull policy check. ScannerPullCtxKey = contextKey("ScannerPullCheck") ) +var ( + // V2ManifestURLRe is the regular expression for matching request v2 handler to view/delete manifest + V2ManifestURLRe = regexp.MustCompile(fmt.Sprintf(`^/v2/(?P<%s>%s)/manifests/(?P<%s>%s|%s)$`, RepositorySubexp, reference.NameRegexp.String(), ReferenceSubexp, reference.TagRegexp.String(), digest.DigestRegexp.String())) + // V2TagListURLRe is the regular expression for matching request to v2 handler to list tags + V2TagListURLRe = regexp.MustCompile(fmt.Sprintf(`^/v2/(?P<%s>%s)/tags/list`, RepositorySubexp, reference.NameRegexp.String())) + // V2BlobURLRe is the regular expression for matching request to v2 handler to retrieve delete a blob + V2BlobURLRe = regexp.MustCompile(fmt.Sprintf(`^/v2/(?P<%s>%s)/blobs/(?P<%s>%s)$`, RepositorySubexp, reference.NameRegexp.String(), DigestSubexp, digest.DigestRegexp.String())) + // V2BlobUploadURLRe is the regular expression for matching the request to v2 handler to upload a blob, the upload uuid currently is not put into a group + V2BlobUploadURLRe = regexp.MustCompile(fmt.Sprintf(`^/v2/(?P<%s>%s)/blobs/uploads[/a-zA-Z0-9\-_\.=]*$`, RepositorySubexp, reference.NameRegexp.String())) + // V2CatalogURLRe is the regular expression for mathing the request to v2 handler to list catalog + V2CatalogURLRe = regexp.MustCompile(`^/v2/_catalog$`) +) + // ManifestInfo ... type ManifestInfo struct { ProjectID int64 @@ -21,6 +46,23 @@ type ManifestInfo struct { Digest string } +// ArtifactInfo ... +type ArtifactInfo struct { + Repository string + Reference string + ProjectName string + Digest string + BlobMountRepository string + BlobMountProjectName string + BlobMountDigest string +} + +// ArtifactInfoFromContext returns the artifact info from context +func ArtifactInfoFromContext(ctx context.Context) (*ArtifactInfo, bool) { + info, ok := ctx.Value(ArtifactInfoKey).(*ArtifactInfo) + return info, ok +} + // NewManifestInfoContext returns context with manifest info func NewManifestInfoContext(ctx context.Context, info *ManifestInfo) context.Context { return context.WithValue(ctx, manifestInfoKey, info) diff --git a/src/server/middleware/v2authz/authz.go b/src/server/middleware/v2authz/authz.go new file mode 100644 index 000000000000..8254cc651ad9 --- /dev/null +++ b/src/server/middleware/v2authz/authz.go @@ -0,0 +1,116 @@ +// 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 authz + +import ( + "fmt" + "github.com/goharbor/harbor/src/common/rbac" + "github.com/goharbor/harbor/src/common/utils/log" + "github.com/goharbor/harbor/src/core/config" + "github.com/goharbor/harbor/src/core/filter" + "github.com/goharbor/harbor/src/core/promgr" + ierror "github.com/goharbor/harbor/src/internal/error" + "github.com/goharbor/harbor/src/server/middleware" + reg_err "github.com/goharbor/harbor/src/server/registry/error" + "net/http" +) + +type reqChecker struct { + pm promgr.ProjectManager +} + +func (rc *reqChecker) check(req *http.Request) error { + securityCtx, err := filter.GetSecurityContext(req) + if err != nil { + return err + } + if a, ok := middleware.ArtifactInfoFromContext(req.Context()); ok { + action := getAction(req) + if action == "" { + return nil + } + log.Debugf("action: %s, repository: %s", action, a.Repository) + pid, err := rc.projectID(a.ProjectName) + if err != nil { + return err + } + resource := rbac.NewProjectNamespace(pid).Resource(rbac.ResourceRepository) + if !securityCtx.Can(action, resource) { + return fmt.Errorf("unauthorized to access repository: %s, action: %s", a.Repository, action) + } + if req.Method == http.MethodPost && a.BlobMountProjectName != "" { // check permission for the source of blob mount + p, err := rc.pm.Get(a.BlobMountProjectName) + if err != nil { + return err + } + resource := rbac.NewProjectNamespace(p.ProjectID).Resource(rbac.ResourceRepository) + if !securityCtx.Can(rbac.ActionPull, resource) { + return fmt.Errorf("unauthorized to access repository from which to mount blob: %s, action: %s", a.BlobMountRepository, rbac.ActionPull) + } + } + } else if len(middleware.V2CatalogURLRe.FindStringSubmatch(req.URL.Path)) == 1 && !securityCtx.IsSysAdmin() { + return fmt.Errorf("unauthorized to list catalog") + } + return nil +} + +func (rc *reqChecker) projectID(name string) (int64, error) { + p, err := rc.pm.Get(name) + if err != nil { + return 0, err + } + if p == nil { + return 0, fmt.Errorf("project not found, name: %s", name) + } + return p.ProjectID, nil +} + +func getAction(req *http.Request) rbac.Action { + pushActions := map[string]struct{}{ + http.MethodPost: {}, + http.MethodDelete: {}, + http.MethodPatch: {}, + http.MethodPut: {}, + } + pullActions := map[string]struct{}{ + http.MethodGet: {}, + http.MethodHead: {}, + } + if _, ok := pushActions[req.Method]; ok { + return rbac.ActionPush + } + if _, ok := pullActions[req.Method]; ok { + return rbac.ActionPull + } + return "" + +} + +var checker = reqChecker{ + pm: config.GlobalProjectMgr, +} + +// Middleware checks the permission of the request to access the artifact +func Middleware() func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + if err := checker.check(req); err != nil { + reg_err.Handle(rw, req, ierror.UnauthorizedError(err)) + return + } + next.ServeHTTP(rw, req) + }) + } +} diff --git a/src/server/middleware/v2authz/authz_test.go b/src/server/middleware/v2authz/authz_test.go new file mode 100644 index 000000000000..a5b268dabb47 --- /dev/null +++ b/src/server/middleware/v2authz/authz_test.go @@ -0,0 +1,213 @@ +// 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 authz + +import ( + "github.com/goharbor/harbor/src/common/models" + "github.com/goharbor/harbor/src/common/rbac" + "github.com/goharbor/harbor/src/core/filter" + "github.com/goharbor/harbor/src/core/promgr/metamgr" + "github.com/goharbor/harbor/src/server/middleware" + "github.com/stretchr/testify/assert" + "golang.org/x/net/context" + "net/http" + "net/http/httptest" + "os" + "strconv" + "strings" + "testing" +) + +type mockPM struct{} + +func (mockPM) Get(projectIDOrName interface{}) (*models.Project, error) { + name := projectIDOrName.(string) + id, _ := strconv.Atoi(strings.TrimPrefix(name, "project_")) + if id == 0 { + return nil, nil + } + return &models.Project{ + ProjectID: int64(id), + Name: name, + }, nil +} + +func (mockPM) Create(*models.Project) (int64, error) { + panic("implement me") +} + +func (mockPM) Delete(projectIDOrName interface{}) error { + panic("implement me") +} + +func (mockPM) Update(projectIDOrName interface{}, project *models.Project) error { + panic("implement me") +} + +func (mockPM) List(query *models.ProjectQueryParam) (*models.ProjectQueryResult, error) { + panic("implement me") +} + +func (mockPM) IsPublic(projectIDOrName interface{}) (bool, error) { + return false, nil +} + +func (mockPM) Exists(projectIDOrName interface{}) (bool, error) { + panic("implement me") +} + +func (mockPM) GetPublic() ([]*models.Project, error) { + panic("implement me") +} + +func (mockPM) GetMetadataManager() metamgr.ProjectMetadataManager { + panic("implement me") +} + +type mockSC struct{} + +func (mockSC) IsAuthenticated() bool { + return true +} + +func (mockSC) GetUsername() string { + return "mock" +} + +func (mockSC) IsSysAdmin() bool { + return false +} + +func (mockSC) IsSolutionUser() bool { + return false +} + +func (mockSC) GetMyProjects() ([]*models.Project, error) { + panic("implement me") +} + +func (mockSC) GetProjectRoles(projectIDOrName interface{}) []int { + panic("implement me") +} + +func (mockSC) Can(action rbac.Action, resource rbac.Resource) bool { + ns, _ := resource.GetNamespace() + perms := map[int64]map[rbac.Action]struct{}{ + 1: { + rbac.ActionPull: {}, + rbac.ActionPush: {}, + }, + 2: { + rbac.ActionPull: {}, + }, + } + pid := ns.Identity().(int64) + m, ok := perms[pid] + if !ok { + return false + } + _, ok = m[action] + return ok + +} + +func TestMain(m *testing.M) { + checker = reqChecker{ + pm: mockPM{}, + } + if rc := m.Run(); rc != 0 { + os.Exit(rc) + } +} + +func TestMiddleware(t *testing.T) { + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + }) + + baseCtx := context.WithValue(context.Background(), filter.SecurCtxKey, mockSC{}) + ar1 := &middleware.ArtifactInfo{ + Repository: "project_1/hello-world", + Reference: "v1", + ProjectName: "project_1", + } + ar2 := &middleware.ArtifactInfo{ + Repository: "library/ubuntu", + Reference: "14.04", + ProjectName: "library", + } + ar3 := &middleware.ArtifactInfo{ + Repository: "project_1/ubuntu", + Reference: "14.04", + ProjectName: "project_1", + BlobMountRepository: "project_2/ubuntu", + BlobMountProjectName: "project_2", + BlobMountDigest: "sha256:08e4a417ff4e3913d8723a05cc34055db01c2fd165b588e049c5bad16ce6094f", + } + ar4 := &middleware.ArtifactInfo{ + Repository: "project_1/ubuntu", + Reference: "14.04", + ProjectName: "project_1", + BlobMountRepository: "project_3/ubuntu", + BlobMountProjectName: "project_3", + BlobMountDigest: "sha256:08e4a417ff4e3913d8723a05cc34055db01c2fd165b588e049c5bad16ce6094f", + } + ctx1 := context.WithValue(baseCtx, middleware.ArtifactInfoKey, ar1) + ctx2 := context.WithValue(baseCtx, middleware.ArtifactInfoKey, ar2) + ctx3 := context.WithValue(baseCtx, middleware.ArtifactInfoKey, ar3) + ctx4 := context.WithValue(baseCtx, middleware.ArtifactInfoKey, ar4) + req1a, _ := http.NewRequest(http.MethodGet, "/v2/project_1/hello-world/manifest/v1", nil) + req1b, _ := http.NewRequest(http.MethodDelete, "/v2/project_1/hello-world/manifest/v1", nil) + req2, _ := http.NewRequest(http.MethodGet, "/v2/library/ubuntu/manifest/14.04", nil) + req3, _ := http.NewRequest(http.MethodGet, "/v2/_catalog", nil) + req4, _ := http.NewRequest(http.MethodPost, "/v2/project_1/ubuntu/blobs/uploads/mount=?mount=sha256:08e4a417ff4e3913d8723a05cc34055db01c2fd165b588e049c5bad16ce6094f&from=project_2/ubuntu", nil) + req5, _ := http.NewRequest(http.MethodPost, "/v2/project_1/ubuntu/blobs/uploads/mount=?mount=sha256:08e4a417ff4e3913d8723a05cc34055db01c2fd165b588e049c5bad16ce6094f&from=project_3/ubuntu", nil) + + cases := []struct { + input *http.Request + status int + }{ + { + input: req1a.WithContext(ctx1), + status: http.StatusOK, + }, + { + input: req1b.WithContext(ctx1), + status: http.StatusOK, + }, + { + input: req2.WithContext(ctx2), + status: http.StatusUnauthorized, + }, + { + input: req3.WithContext(baseCtx), + status: http.StatusUnauthorized, + }, + { + input: req4.WithContext(ctx3), + status: http.StatusOK, + }, + { + input: req5.WithContext(ctx4), + status: http.StatusUnauthorized, + }, + } + for _, c := range cases { + rec := httptest.NewRecorder() + t.Logf("req : %s, %s", c.input.Method, c.input.URL) + Middleware()(next).ServeHTTP(rec, c.input) + assert.Equal(t, c.status, rec.Result().StatusCode) + } +} diff --git a/src/server/registry/error/error.go b/src/server/registry/error/error.go index f0fb53a4b96a..a4d38a53aaab 100644 --- a/src/server/registry/error/error.go +++ b/src/server/registry/error/error.go @@ -22,7 +22,7 @@ import ( // Handle generates the HTTP status code and error payload and writes them to the response func Handle(w http.ResponseWriter, req *http.Request, err error) { - log.Errorf("failed to handle the request %s: %v", req.URL.Path, err) + log.Errorf("failed to handle the request %s: %v", req.URL, err) statusCode, payload := serror.APIError(err) w.WriteHeader(statusCode) w.Write([]byte(payload)) diff --git a/tests/apitests/python/test_project_quota.py b/tests/apitests/python/test_project_quota.py index 8205684115e7..0586be2bfaaf 100644 --- a/tests/apitests/python/test_project_quota.py +++ b/tests/apitests/python/test_project_quota.py @@ -73,7 +73,7 @@ def testProjectQuota(self): #5. Get project quota quota = self.system.get_project_quota("project", TestProjects.project_test_quota_id, **ADMIN_CLIENT) self.assertEqual(quota[0].used["count"], 1) - self.assertEqual(quota[0].used["storage"], 2789174) + self.assertEqual(quota[0].used["storage"], 2789002) #6. Delete repository(RA) by user(UA); self.repo.delete_repoitory(TestProjects.repo_name, **ADMIN_CLIENT)