diff --git a/pkg/config/v1/validation/proxy.go b/pkg/config/v1/validation/proxy.go index 5d430e6b..0eca2663 100644 --- a/pkg/config/v1/validation/proxy.go +++ b/pkg/config/v1/validation/proxy.go @@ -20,9 +20,8 @@ import ( "slices" "strings" - "k8s.io/apimachinery/pkg/util/validation" - v1 "github.com/fatedier/frp/pkg/config/v1" + utilvalidation "github.com/fatedier/frp/pkg/util/validation" ) func validateProxyBaseConfigForClient(c *v1.ProxyBaseConfig) error { @@ -248,7 +247,7 @@ func ValidateAnnotations(annotations map[string]string) error { var errs error for k := range annotations { - for _, msg := range validation.IsQualifiedName(strings.ToLower(k)) { + for _, msg := range utilvalidation.IsQualifiedName(strings.ToLower(k)) { errs = AppendError(errs, fmt.Errorf("annotation key %s is invalid: %s", k, msg)) } } diff --git a/pkg/config/v1/validation/proxy_test.go b/pkg/config/v1/validation/proxy_test.go index b9411dc8..56bdc72c 100644 --- a/pkg/config/v1/validation/proxy_test.go +++ b/pkg/config/v1/validation/proxy_test.go @@ -74,3 +74,18 @@ func TestValidateDomainConfigForServerRejectsSubdomainHostCaseInsensitively(t *t }) } } + +func TestValidateAnnotationsQualifiedName(t *testing.T) { + require.NoError(t, ValidateAnnotations(map[string]string{ + "frp.e2e.test/foo": "value1", + "app.kubernetes.io/name": "value2", + })) + // The call site lowercases keys before validation. + require.NoError(t, ValidateAnnotations(map[string]string{"Example.COM/MyName": "value"})) + + err := ValidateAnnotations(map[string]string{"bad key": "value"}) + require.ErrorContains(t, err, "annotation key bad key is invalid: name part must consist of alphanumeric characters") + + err = ValidateAnnotations(map[string]string{"example.com/": "value"}) + require.ErrorContains(t, err, "name part must be non-empty") +} diff --git a/pkg/util/validation/qualified_name.go b/pkg/util/validation/qualified_name.go new file mode 100644 index 00000000..b8fd95a8 --- /dev/null +++ b/pkg/util/validation/qualified_name.go @@ -0,0 +1,123 @@ +// Copyright 2026 The frp 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 validation + +import ( + "fmt" + "regexp" + "strings" +) + +// Annotation keys follow the Kubernetes "qualified name" (label key) format. +// The constants below mirror k8s.io/apimachinery/pkg/util/validation's +// qualified-name validation (v0.28.8 semantics and messages) so that frp does +// not need to import that package; newer versions of the package link klog. +const ( + qualifiedNameCharFmt = "[A-Za-z0-9]" + qualifiedNameExtCharFmt = "[-A-Za-z0-9_.]" + qualifiedNameFmt = "(" + qualifiedNameCharFmt + qualifiedNameExtCharFmt + "*)?" + qualifiedNameCharFmt + qualifiedNameErrMsg = "must consist of alphanumeric characters, '-', '_' or '.', and must start and end with an alphanumeric character" + qualifiedNameMaxLength = 63 + + dns1123LabelFmt = "[a-z0-9]([-a-z0-9]*[a-z0-9])?" + dns1123SubdomainFmt = dns1123LabelFmt + `(\.` + dns1123LabelFmt + ")*" + dns1123SubdomainErrorMsg = "a lowercase RFC 1123 subdomain must consist of lower case " + + "alphanumeric characters, '-' or '.', and must start and end with an alphanumeric character" + dns1123SubdomainMaxLength = 253 +) + +var ( + qualifiedNameRegexp = regexp.MustCompile("^" + qualifiedNameFmt + "$") + dns1123SubdomainRegexp = regexp.MustCompile("^" + dns1123SubdomainFmt + "$") +) + +// IsQualifiedName reports whether value is a valid Kubernetes qualified name +// (label key), the format used by frp for proxy annotation keys. If value is +// invalid, it returns one message per violation; an empty result means valid. +// +// The function does not normalize case: callers that accept case-insensitive +// keys must lowercase value themselves (frp's annotation validation passes +// strings.ToLower(key)). +func IsQualifiedName(value string) []string { + var errs []string + parts := strings.Split(value, "/") + var name string + switch len(parts) { + case 1: + name = parts[0] + case 2: + var prefix string + prefix, name = parts[0], parts[1] + if len(prefix) == 0 { + errs = append(errs, "prefix part "+emptyError()) + } else if msgs := dns1123SubdomainErrors(prefix); len(msgs) != 0 { + errs = append(errs, prefixEach(msgs, "prefix part ")...) + } + default: + return append(errs, "a qualified name "+regexError(qualifiedNameErrMsg, qualifiedNameFmt, "MyName", "my.name", "123-abc")+ + " with an optional DNS subdomain prefix and '/' (e.g. 'example.com/MyName')") + } + + if len(name) == 0 { + errs = append(errs, "name part "+emptyError()) + } else if len(name) > qualifiedNameMaxLength { + errs = append(errs, "name part "+maxLenError(qualifiedNameMaxLength)) + } + if !qualifiedNameRegexp.MatchString(name) { + errs = append(errs, "name part "+regexError(qualifiedNameErrMsg, qualifiedNameFmt, "MyName", "my.name", "123-abc")) + } + return errs +} + +func dns1123SubdomainErrors(value string) []string { + var errs []string + if len(value) > dns1123SubdomainMaxLength { + errs = append(errs, maxLenError(dns1123SubdomainMaxLength)) + } + if !dns1123SubdomainRegexp.MatchString(value) { + errs = append(errs, regexError(dns1123SubdomainErrorMsg, dns1123SubdomainFmt, "example.com")) + } + return errs +} + +func maxLenError(length int) string { + return fmt.Sprintf("must be no more than %d characters", length) +} + +func emptyError() string { + return "must be non-empty" +} + +func regexError(msg string, re string, examples ...string) string { + if len(examples) == 0 { + return msg + " (regex used for validation is '" + re + "')" + } + msg += " (e.g. " + for i := range examples { + if i > 0 { + msg += " or " + } + msg += "'" + examples[i] + "', " + } + msg += "regex used for validation is '" + re + "')" + return msg +} + +func prefixEach(msgs []string, prefix string) []string { + for i := range msgs { + msgs[i] = prefix + msgs[i] + } + return msgs +} diff --git a/pkg/util/validation/qualified_name_test.go b/pkg/util/validation/qualified_name_test.go new file mode 100644 index 00000000..0bb6e7ce --- /dev/null +++ b/pkg/util/validation/qualified_name_test.go @@ -0,0 +1,102 @@ +// Copyright 2026 The frp 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 validation + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestIsQualifiedNameValidity(t *testing.T) { + tests := []struct { + name string + value string + valid bool + }{ + {name: "single letter", value: "a", valid: true}, + {name: "single digit", value: "0", valid: true}, + {name: "uppercase name", value: "MyName", valid: true}, + {name: "allowed punctuation", value: "a-._b", valid: true}, + {name: "name at max length", value: strings.Repeat("a", 63), valid: true}, + {name: "name over max length", value: strings.Repeat("a", 64), valid: false}, + {name: "prefix and name", value: "example.com/MyName", valid: true}, + {name: "prefix at max length", value: strings.Repeat("a", 253) + "/key", valid: true}, + {name: "prefix over max length", value: strings.Repeat("a", 254) + "/key", valid: false}, + {name: "name at max length with prefix", value: "example.com/" + strings.Repeat("a", 63), valid: true}, + {name: "name over max length with prefix", value: "example.com/" + strings.Repeat("a", 64), valid: false}, + {name: "empty", value: "", valid: false}, + {name: "leading hyphen", value: "-a", valid: false}, + {name: "trailing hyphen", value: "a-", valid: false}, + {name: "leading underscore", value: "_a", valid: false}, + {name: "trailing dot", value: "a.", valid: false}, + {name: "only dot", value: ".", valid: false}, + {name: "double dot in name", value: "a..b", valid: true}, + {name: "space", value: "a b", valid: false}, + {name: "tab", value: "a\tb", valid: false}, + {name: "newline", value: "a\nb", valid: false}, + {name: "nul byte", value: "a\x00b", valid: false}, + {name: "non ascii name", value: "é", valid: false}, + {name: "non ascii prefix", value: "é/key", valid: false}, + {name: "empty prefix", value: "/key", valid: false}, + {name: "empty name", value: "example.com/", valid: false}, + {name: "two slashes", value: "a//b", valid: false}, + {name: "three segments", value: "a/b/c", valid: false}, + {name: "uppercase prefix", value: "Example.com/key", valid: false}, + {name: "prefix with empty label", value: "example..com/key", valid: false}, + {name: "prefix with leading hyphen", value: "-example.com/key", valid: false}, + {name: "prefix with trailing dot", value: "example.com./key", valid: false}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + errs := IsQualifiedName(tc.value) + if tc.valid { + require.Empty(t, errs) + } else { + require.NotEmpty(t, errs) + } + }) + } +} + +func TestIsQualifiedNameMessages(t *testing.T) { + t.Run("name too long", func(t *testing.T) { + errs := IsQualifiedName(strings.Repeat("a", 64)) + require.Contains(t, errs, "name part must be no more than 63 characters") + }) + t.Run("prefix too long", func(t *testing.T) { + errs := IsQualifiedName(strings.Repeat("a", 254) + "/key") + require.Contains(t, errs, "prefix part must be no more than 253 characters") + }) + t.Run("too many segments", func(t *testing.T) { + errs := IsQualifiedName("a/b/c") + require.Len(t, errs, 1) + require.Contains(t, errs[0], "a qualified name must consist of alphanumeric characters") + require.Contains(t, errs[0], "with an optional DNS subdomain prefix and '/'") + }) + t.Run("invalid character", func(t *testing.T) { + errs := IsQualifiedName("a b") + require.Contains(t, errs[0], "name part must consist of alphanumeric characters") + }) + t.Run("empty prefix", func(t *testing.T) { + errs := IsQualifiedName("/key") + require.Equal(t, []string{"prefix part must be non-empty"}, errs) + }) + t.Run("empty name", func(t *testing.T) { + errs := IsQualifiedName("example.com/") + require.Contains(t, errs[0], "name part must be non-empty") + }) +}