From e4763ca86772c5f726d118728ee3fb57ce396c06 Mon Sep 17 00:00:00 2001 From: stonezdj Date: Thu, 9 Jul 2026 16:26:21 +0800 Subject: [PATCH] feat(backend): implement proxy cache repository filter API Introduce repository filter pattern and kind on proxy cache projects to explicitly allow selected repositories. Requests that do not match the filter are denied before Harbor proxies content from upstream. Fixes #13231 Signed-off-by: stonezdj --- api/v2.0/swagger.yaml | 8 + src/lib/pattern/matcher.go | 117 ++++++ src/lib/pattern/matcher_test.go | 346 ++++++++++++++++++ src/pkg/project/models/pro_meta.go | 2 + src/server/middleware/repoproxy/proxy.go | 27 ++ src/server/middleware/repoproxy/proxy_test.go | 126 +++++++ src/server/v2.0/handler/model/project.go | 6 + src/server/v2.0/handler/project.go | 37 +- src/server/v2.0/handler/project_metadata.go | 15 + 9 files changed, 682 insertions(+), 2 deletions(-) create mode 100644 src/lib/pattern/matcher.go create mode 100644 src/lib/pattern/matcher_test.go diff --git a/api/v2.0/swagger.yaml b/api/v2.0/swagger.yaml index 0489f222fab..ac987f2cabd 100644 --- a/api/v2.0/swagger.yaml +++ b/api/v2.0/swagger.yaml @@ -7533,6 +7533,14 @@ definitions: type: string description: 'Whether the proxy cache project should proxy OCI 1.1 referrer API requests to the upstream registry. The valid values are "true", "false".' x-nullable: true + proxy_cache_filter_pattern: + type: string + description: 'Repository filter pattern for proxy cache project. Only repositories matching the pattern will be proxied. Empty means allow all. Only has value when the current project is a proxy cache project.' + x-nullable: true + proxy_cache_filter_kind: + type: string + description: 'Matching mode for proxy_cache_filter_pattern: "doublestar" (default, glob-style with ** support) or "regex". Only has value when the current project is a proxy cache project.' + x-nullable: true ProjectSummary: type: object properties: diff --git a/src/lib/pattern/matcher.go b/src/lib/pattern/matcher.go new file mode 100644 index 00000000000..0135509480e --- /dev/null +++ b/src/lib/pattern/matcher.go @@ -0,0 +1,117 @@ +// 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 pattern + +import ( + "regexp" + "strings" + + "github.com/bmatcuk/doublestar" + + "github.com/goharbor/harbor/src/lib/errors" +) + +const ( + // KindRegex indicates regular expression pattern matching + KindRegex = "regex" + // KindDoublestar indicates doublestar (glob) pattern matching + KindDoublestar = "doublestar" +) + +// RepositoryFilter represents a repository filter configuration with pattern and kind +type RepositoryFilter struct { + // Filter is the pattern expression to match against + Filter string + // Kind is the type of pattern matching: "regex" or "doublestar" + Kind string +} + +// NewRepositoryFilter creates a RepositoryFilter from separate pattern and kind strings. +// If kind is empty, it defaults to KindDoublestar. +func NewRepositoryFilter(filterPattern, kind string) *RepositoryFilter { + return &RepositoryFilter{ + Filter: strings.TrimSpace(filterPattern), + Kind: strings.TrimSpace(kind), + } +} + +// Match returns true if the value matches the filter pattern. +// Returns true if the filter is empty. +func (rf *RepositoryFilter) Match(value string) (bool, error) { + if rf == nil || rf.Filter == "" { + return true, nil + } + matcher := NewMatcher(rf.Kind) + return matcher.Match(value, rf.Filter) +} + +// ValidateRepositoryFilter validates the repository filter kind and pattern. +// Empty pattern is valid and means all repositories are allowed. +func ValidateRepositoryFilter(filterPattern, kind string) error { + rf := NewRepositoryFilter(filterPattern, kind) + if rf.Kind != "" && rf.Kind != KindRegex && rf.Kind != KindDoublestar { + return errors.Errorf("unsupported repository filter kind %q", kind) + } + _, err := rf.Match("") + return err +} + +// Matcher is an interface for matching strings against patterns +type Matcher interface { + // Match returns true if the value matches the pattern + Match(value, pattern string) (bool, error) +} + +// RegexMatcher implements Matcher using regular expressions +type RegexMatcher struct{} + +// Match matches value against a regular expression pattern +func (r *RegexMatcher) Match(value, pattern string) (bool, error) { + pattern = strings.TrimSpace(pattern) + if pattern == "" { + return true, nil + } + re, err := regexp.Compile(pattern) + if err != nil { + return false, err + } + match := re.FindStringIndex(value) + return match != nil && match[0] == 0 && match[1] == len(value), nil +} + +// DoublestarMatcher implements Matcher using doublestar (glob) patterns +type DoublestarMatcher struct{} + +// Match matches value against a doublestar (glob) pattern +func (d *DoublestarMatcher) Match(value, pattern string) (bool, error) { + pattern = strings.TrimSpace(pattern) + if pattern == "" { + return true, nil + } + return doublestar.Match(pattern, value) +} + +// NewMatcher creates a new Matcher based on the specified kind. +// Defaults to KindDoublestar when kind is empty or unrecognised. +func NewMatcher(kind string) Matcher { + switch kind { + case KindRegex: + return &RegexMatcher{} + case KindDoublestar: + fallthrough + default: + return &DoublestarMatcher{} + } +} diff --git a/src/lib/pattern/matcher_test.go b/src/lib/pattern/matcher_test.go new file mode 100644 index 00000000000..ae13506b958 --- /dev/null +++ b/src/lib/pattern/matcher_test.go @@ -0,0 +1,346 @@ +// 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 pattern + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewRepositoryFilter(t *testing.T) { + tests := []struct { + name string + pattern string + kind string + want *RepositoryFilter + }{ + { + name: "empty pattern and kind", + pattern: "", + kind: "", + want: &RepositoryFilter{Filter: "", Kind: ""}, + }, + { + name: "regex filter", + pattern: "^library/.*", + kind: KindRegex, + want: &RepositoryFilter{Filter: "^library/.*", Kind: KindRegex}, + }, + { + name: "doublestar filter", + pattern: "library/**", + kind: KindDoublestar, + want: &RepositoryFilter{Filter: "library/**", Kind: KindDoublestar}, + }, + { + name: "pattern with whitespace is trimmed", + pattern: " nginx ", + kind: "", + want: &RepositoryFilter{Filter: "nginx", Kind: ""}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := NewRepositoryFilter(tt.pattern, tt.kind) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestRepositoryFilter_Match(t *testing.T) { + tests := []struct { + name string + rf *RepositoryFilter + value string + want bool + wantErr bool + }{ + { + name: "nil filter matches all", + rf: nil, + value: "library/nginx", + want: true, + }, + { + name: "empty filter matches all", + rf: &RepositoryFilter{}, + value: "library/nginx", + want: true, + }, + { + name: "regex prefix is anchored", + rf: &RepositoryFilter{Filter: "^library/", Kind: KindRegex}, + value: "library/nginx", + want: false, + }, + { + name: "regex wildcard match", + rf: &RepositoryFilter{Filter: "^library/.*", Kind: KindRegex}, + value: "library/nginx", + want: true, + }, + { + name: "regex no match", + rf: &RepositoryFilter{Filter: "^other/", Kind: KindRegex}, + value: "library/nginx", + want: false, + }, + { + name: "doublestar match", + rf: &RepositoryFilter{Filter: "library/**", Kind: KindDoublestar}, + value: "library/nginx", + want: true, + }, + { + name: "doublestar no match", + rf: &RepositoryFilter{Filter: "other/**", Kind: KindDoublestar}, + value: "library/nginx", + want: false, + }, + { + name: "empty kind defaults to doublestar", + rf: &RepositoryFilter{Filter: "nginx"}, + value: "nginx", + want: true, + }, + { + name: "invalid regex", + rf: &RepositoryFilter{Filter: "[invalid", Kind: KindRegex}, + value: "library/nginx", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := tt.rf.Match(tt.value) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestRegexMatcher(t *testing.T) { + m := &RegexMatcher{} + + tests := []struct { + name string + value string + pattern string + want bool + wantErr bool + }{ + { + name: "empty pattern matches all", + value: "library/nginx", + pattern: "", + want: true, + }, + { + name: "whitespace pattern matches all", + value: "library/nginx", + pattern: " ", + want: true, + }, + { + name: "exact match", + value: "library/nginx", + pattern: "^library/nginx$", + want: true, + }, + { + name: "partial match is anchored", + value: "library/nginx", + pattern: "nginx", + want: false, + }, + { + name: "prefix match is anchored", + value: "library/nginx", + pattern: "^library/", + want: false, + }, + { + name: "no match", + value: "library/nginx", + pattern: "^other/", + want: false, + }, + { + name: "wildcard match", + value: "library/nginx", + pattern: "library/.*", + want: true, + }, + { + name: "invalid regex returns error", + value: "library/nginx", + pattern: "[invalid", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := m.Match(tt.value, tt.pattern) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestDoublestarMatcher(t *testing.T) { + m := &DoublestarMatcher{} + + tests := []struct { + name string + value string + pattern string + want bool + wantErr bool + }{ + { + name: "empty pattern matches all", + value: "library/nginx", + pattern: "", + want: true, + }, + { + name: "whitespace pattern matches all", + value: "library/nginx", + pattern: " ", + want: true, + }, + { + name: "exact match", + value: "library/nginx", + pattern: "library/nginx", + want: true, + }, + { + name: "single star matches within path segment", + value: "library/nginx", + pattern: "library/*", + want: true, + }, + { + name: "double star matches across path segments", + value: "org/team/repo", + pattern: "org/**", + want: true, + }, + { + name: "double star matches everything", + value: "library/nginx", + pattern: "**", + want: true, + }, + { + name: "double star in middle", + value: "a/b/c/d", + pattern: "a/**/d", + want: true, + }, + { + name: "no match", + value: "library/nginx", + pattern: "other/*", + want: false, + }, + { + name: "question mark wildcard", + value: "library/nginx1", + pattern: "library/nginx?", + want: true, + }, + { + name: "character class", + value: "library/nginx1", + pattern: "library/nginx[0-9]", + want: true, + }, + { + name: "alternation", + value: "library/nginx", + pattern: "library/{nginx,alpine}", + want: true, + }, + { + name: "invalid pattern returns error", + value: "library/nginx", + pattern: "[invalid", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := m.Match(tt.value, tt.pattern) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestNewMatcher(t *testing.T) { + tests := []struct { + name string + kind string + want Matcher + }{ + { + name: "regex kind", + kind: KindRegex, + want: &RegexMatcher{}, + }, + { + name: "doublestar kind", + kind: KindDoublestar, + want: &DoublestarMatcher{}, + }, + { + name: "empty kind defaults to doublestar", + kind: "", + want: &DoublestarMatcher{}, + }, + { + name: "unknown kind defaults to doublestar", + kind: "unknown", + want: &DoublestarMatcher{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := NewMatcher(tt.kind) + assert.IsType(t, tt.want, got) + }) + } +} diff --git a/src/pkg/project/models/pro_meta.go b/src/pkg/project/models/pro_meta.go index 4f6f0f8845c..631f6767130 100644 --- a/src/pkg/project/models/pro_meta.go +++ b/src/pkg/project/models/pro_meta.go @@ -26,6 +26,8 @@ const ( ProMetaAutoSBOMGen = "auto_sbom_generation" ProMetaProxySpeed = "proxy_speed_kb" ProMetaMaxUpstreamConn = "max_upstream_conn" + ProMetaProxyCacheFilterPattern = "proxy_cache_filter_pattern" // plain string pattern for proxy cache repository filter + ProMetaProxyCacheFilterKind = "proxy_cache_filter_kind" // "doublestar" (default) or "regex" ProMetaProxyReferrerAPI = "proxy_referrer_api" ProMetaProxyCacheLocalOnNotFound = "proxy_cache_local_on_not_found" ) diff --git a/src/server/middleware/repoproxy/proxy.go b/src/server/middleware/repoproxy/proxy.go index 85d4dc5fef2..93ffd6985fc 100644 --- a/src/server/middleware/repoproxy/proxy.go +++ b/src/server/middleware/repoproxy/proxy.go @@ -34,6 +34,7 @@ import ( httpLib "github.com/goharbor/harbor/src/lib/http" "github.com/goharbor/harbor/src/lib/log" "github.com/goharbor/harbor/src/lib/orm" + "github.com/goharbor/harbor/src/lib/pattern" "github.com/goharbor/harbor/src/lib/redis" proModels "github.com/goharbor/harbor/src/pkg/project/models" "github.com/goharbor/harbor/src/pkg/proxy/connection" @@ -193,6 +194,22 @@ func defaultBlobURL(projectName string, name string, digest string) string { return fmt.Sprintf("/v2/%s/library/%s/blobs/%s", projectName, name, digest) } +// matchRepositoryFilter returns true if repository matches the filter. +// filterPattern is the plain pattern string; filterKind is "doublestar" or "regex" (defaults to doublestar when empty). +// If filterPattern is empty, all repositories are allowed. +// An invalid pattern is treated as no match. +func matchRepositoryFilter(repository, filterPattern, filterKind string) bool { + log.Debugf("matching repository %q against filter pattern: %q (kind: %s)", repository, filterPattern, filterKind) + rf := pattern.NewRepositoryFilter(filterPattern, filterKind) + matched, err := rf.Match(repository) + if err != nil { + log.Warningf("invalid proxy_cache_filter_pattern %q (kind: %s): %v", rf.Filter, rf.Kind, err) + return false + } + log.Debugf("repository %q match result: %v (filter: %q, kind: %s)", repository, matched, rf.Filter, rf.Kind) + return matched +} + // upstreamRegistryConnectionKey get upstream registry connection key func upstreamRegistryConnectionKey(art lib.ArtifactInfo) string { limitOnProject := os.Getenv(upstreamRegistryLimitOnProject) @@ -219,6 +236,16 @@ func handleManifest(w http.ResponseWriter, r *http.Request, next http.Handler) e return nil } + // Apply repository filter: if proxy_cache_filter_pattern is set, the repository must match + if filterPattern, ok := p.GetMetadata(proModels.ProMetaProxyCacheFilterPattern); ok && filterPattern != "" { + filterKind, _ := p.GetMetadata(proModels.ProMetaProxyCacheFilterKind) + remoteRepo := strings.TrimPrefix(art.Repository, art.ProjectName+"/") + if !matchRepositoryFilter(remoteRepo, filterPattern, filterKind) { + log.Infof("blocked proxy cache pull for project %q repository %q: repository does not match filter %q (kind: %s)", p.Name, remoteRepo, filterPattern, filterKind) + return errors.NotFoundError(fmt.Errorf("repository %q does not match project repository filter %q (kind: %s)", remoteRepo, filterPattern, filterKind)) + } + } + if !canProxy(r.Context(), p) { next.ServeHTTP(w, r) return nil diff --git a/src/server/middleware/repoproxy/proxy_test.go b/src/server/middleware/repoproxy/proxy_test.go index c47f20db040..e325cefa9e7 100644 --- a/src/server/middleware/repoproxy/proxy_test.go +++ b/src/server/middleware/repoproxy/proxy_test.go @@ -80,3 +80,129 @@ func TestIsProxySession(t *testing.T) { }) } } + +func TestMatchRepositoryFilter(t *testing.T) { + cases := []struct { + name string + repository string + filterPattern string + filterKind string + want bool + }{ + { + name: "empty pattern matches all", + repository: "library/nginx", + filterPattern: "", + filterKind: "", + want: true, + }, + { + name: "regex exact match", + repository: "library/nginx", + filterPattern: "^library/nginx$", + filterKind: "regex", + want: true, + }, + { + name: "regex partial match is anchored", + repository: "library/nginx", + filterPattern: "nginx", + filterKind: "regex", + want: false, + }, + { + name: "regex prefix match is anchored", + repository: "library/nginx", + filterPattern: "^library/", + filterKind: "regex", + want: false, + }, + { + name: "regex wildcard match", + repository: "library/nginx", + filterPattern: "^library/.*", + filterKind: "regex", + want: true, + }, + { + name: "regex no match", + repository: "library/nginx", + filterPattern: "^other/", + filterKind: "regex", + want: false, + }, + { + name: "regex invalid pattern treated as no match", + repository: "library/nginx", + filterPattern: "[invalid", + filterKind: "regex", + want: false, + }, + { + name: "doublestar exact match", + repository: "library/nginx", + filterPattern: "library/nginx", + filterKind: "doublestar", + want: true, + }, + { + name: "doublestar single star", + repository: "library/nginx", + filterPattern: "library/*", + filterKind: "doublestar", + want: true, + }, + { + name: "doublestar double star", + repository: "org/team/repo", + filterPattern: "org/**", + filterKind: "doublestar", + want: true, + }, + { + name: "doublestar match all", + repository: "library/nginx", + filterPattern: "**", + filterKind: "doublestar", + want: true, + }, + { + name: "doublestar no match", + repository: "library/nginx", + filterPattern: "other/*", + filterKind: "doublestar", + want: false, + }, + { + name: "doublestar alternation", + repository: "library/nginx", + filterPattern: "library/{nginx,alpine}", + filterKind: "doublestar", + want: true, + }, + { + name: "empty kind defaults to doublestar", + repository: "library/nginx", + filterPattern: "library/**", + filterKind: "", + want: true, + }, + { + name: "empty pattern with kind set matches all", + repository: "library/nginx", + filterPattern: "", + filterKind: "regex", + want: true, + }, + } + + for _, tt := range cases { + t.Run(tt.name, func(t *testing.T) { + got := matchRepositoryFilter(tt.repository, tt.filterPattern, tt.filterKind) + if got != tt.want { + t.Errorf("matchRepositoryFilter(%q, %q, %q) = %v; want %v", + tt.repository, tt.filterPattern, tt.filterKind, got, tt.want) + } + }) + } +} diff --git a/src/server/v2.0/handler/model/project.go b/src/server/v2.0/handler/model/project.go index d821e8c9001..8599fe13bd6 100644 --- a/src/server/v2.0/handler/model/project.go +++ b/src/server/v2.0/handler/model/project.go @@ -52,6 +52,12 @@ func (p *Project) ToSwagger() *models.Project { m.Severity = &severity } + // proxy_cache_filter_pattern and proxy_cache_filter_kind are only for proxy cache projects + if !p.IsProxy() { + m.ProxyCacheFilterPattern = nil + m.ProxyCacheFilterKind = nil + } + md = &m } diff --git a/src/server/v2.0/handler/project.go b/src/server/v2.0/handler/project.go index de75072b8c7..797b900a621 100644 --- a/src/server/v2.0/handler/project.go +++ b/src/server/v2.0/handler/project.go @@ -44,6 +44,7 @@ import ( "github.com/goharbor/harbor/src/lib/errors" "github.com/goharbor/harbor/src/lib/log" "github.com/goharbor/harbor/src/lib/orm" + "github.com/goharbor/harbor/src/lib/pattern" "github.com/goharbor/harbor/src/lib/q" "github.com/goharbor/harbor/src/pkg" "github.com/goharbor/harbor/src/pkg/audit" @@ -163,10 +164,12 @@ func (a *projectAPI) CreateProject(ctx context.Context, params operation.CreateP } } - // ignore metadata.proxy_speed_kb and metadata.max_upstream_conn for non-proxy-cache project + // ignore metadata.proxy_speed_kb, metadata.max_upstream_conn, proxy_cache_filter_pattern and proxy_cache_filter_kind for non-proxy-cache project if req.RegistryID == nil { req.Metadata.ProxySpeedKb = nil req.Metadata.MaxUpstreamConn = nil + req.Metadata.ProxyCacheFilterPattern = nil + req.Metadata.ProxyCacheFilterKind = nil } // ignore enable_content_trust metadata for proxy cache project @@ -567,16 +570,21 @@ func (a *projectAPI) UpdateProject(ctx context.Context, params operation.UpdateP } } - // ignore metadata.proxy_speed_kb and metadata.max_upstream_conn for non-proxy-cache project + // ignore metadata.proxy_speed_kb, metadata.max_upstream_conn, proxy_cache_filter_pattern and proxy_cache_filter_kind for non-proxy-cache project if params.Project.Metadata != nil && !p.IsProxy() { params.Project.Metadata.ProxySpeedKb = nil params.Project.Metadata.MaxUpstreamConn = nil + params.Project.Metadata.ProxyCacheFilterPattern = nil + params.Project.Metadata.ProxyCacheFilterKind = nil } // ignore enable_content_trust metadata for proxy cache project // see https://github.com/goharbor/harbor/issues/12940 to get more info if params.Project.Metadata != nil && p.IsProxy() { params.Project.Metadata.EnableContentTrust = nil + if err := validateProxyCacheRepositoryFilter(params.Project.Metadata); err != nil { + return a.SendError(ctx, err) + } } if err := lib.JSONCopy(&p.Metadata, params.Project.Metadata); err != nil { log.Warningf("failed to call JSONCopy on project metadata when UpdateProject, error: %v", err) @@ -826,6 +834,10 @@ func (a *projectAPI) validateProjectReq(ctx context.Context, req *models.Project return errors.BadRequestError(nil).WithMessagef("metadata.max_upstream_conn should be an int, but got '%s', err: %s", *cnt, err) } } + + if err := validateProxyCacheRepositoryFilter(req.Metadata); err != nil { + return err + } } if req.StorageLimit != nil { @@ -838,6 +850,27 @@ func (a *projectAPI) validateProjectReq(ctx context.Context, req *models.Project return nil } +func validateProxyCacheRepositoryFilter(metadata *models.ProjectMetadata) error { + if metadata == nil { + return nil + } + + filterKind := lib.StringValue(metadata.ProxyCacheFilterKind) + if filterKind == "" { + filterKind = pattern.KindDoublestar + } + if filterKind != pattern.KindRegex && filterKind != pattern.KindDoublestar { + return errors.BadRequestError(nil). + WithMessagef("metadata.proxy_cache_filter_kind should be %q or %q, but got: %q", pattern.KindDoublestar, pattern.KindRegex, filterKind) + } + + if err := pattern.ValidateRepositoryFilter(lib.StringValue(metadata.ProxyCacheFilterPattern), filterKind); err != nil { + return errors.BadRequestError(nil). + WithMessagef("metadata.proxy_cache_filter_pattern is invalid for kind %q: %v", filterKind, err) + } + return nil +} + func (a *projectAPI) populateProperties(ctx context.Context, p *project.Project) error { if secCtx, ok := security.FromContext(ctx); ok { if sc, ok := secCtx.(*local.SecurityContext); ok { diff --git a/src/server/v2.0/handler/project_metadata.go b/src/server/v2.0/handler/project_metadata.go index 831907a0e13..80685e0682e 100644 --- a/src/server/v2.0/handler/project_metadata.go +++ b/src/server/v2.0/handler/project_metadata.go @@ -25,6 +25,7 @@ import ( "github.com/goharbor/harbor/src/controller/project" "github.com/goharbor/harbor/src/controller/project/metadata" "github.com/goharbor/harbor/src/lib/errors" + "github.com/goharbor/harbor/src/lib/pattern" proModels "github.com/goharbor/harbor/src/pkg/project/models" "github.com/goharbor/harbor/src/pkg/scan/vuln" operation "github.com/goharbor/harbor/src/server/v2.0/restapi/operations/project_metadata" @@ -168,6 +169,20 @@ func (p *projectMetadataAPI) validate(metas map[string]string) (map[string]strin return nil, errors.New(nil).WithCode(errors.BadRequestCode).WithMessagef("invalid value: %s", value) } metas[proModels.ProMetaMaxUpstreamConn] = strconv.FormatInt(v, 10) + case proModels.ProMetaProxyCacheFilterPattern: + // plain pattern string; empty means match all — no further validation needed at metadata level + if err := pattern.ValidateRepositoryFilter(value, pattern.KindDoublestar); err != nil { + return nil, errors.New(nil).WithCode(errors.BadRequestCode). + WithMessagef("invalid proxy_cache_filter_pattern: %q, err: %v", value, err) + } + metas[proModels.ProMetaProxyCacheFilterPattern] = value + case proModels.ProMetaProxyCacheFilterKind: + // must be "doublestar" or "regex" when specified + if value != "" && value != pattern.KindRegex && value != pattern.KindDoublestar { + return nil, errors.New(nil).WithCode(errors.BadRequestCode). + WithMessagef("invalid proxy_cache_filter_kind: %q, must be %q or %q", value, pattern.KindDoublestar, pattern.KindRegex) + } + metas[proModels.ProMetaProxyCacheFilterKind] = value default: return nil, errors.New(nil).WithCode(errors.BadRequestCode).WithMessagef("invalid key: %s", key) }