mirror of
https://github.com/fatedier/frp.git
synced 2026-11-05 05:21:35 +08:00
refactor(validation): inline annotation qualified name check (#5601)
Replace the single use of k8s.io/apimachinery/pkg/util/validation .IsQualifiedName (annotation key validation in pkg/config/v1/validation) with a local implementation in pkg/util/validation. Newer apimachinery versions link klog only for IP/CIDR warning helpers, which grows frpc and frps, and this was the only frp import of that package. The local IsQualifiedName keeps the exact behavior and error messages of apimachinery v0.28.8, including the qualified-name (label key) format and the DNS-1123 subdomain prefix rules. Annotation keys are still lowercased before validation, so the accepted set is unchanged. Verified against apimachinery v0.28.8 on 228,220 inputs (exhaustive short strings, boundary lengths and seeded random data, including lowercased variants): every error message matches. No dependency versions change; go.mod and go.sum are untouched.
This commit is contained in:
@@ -20,9 +20,8 @@ import (
|
|||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"k8s.io/apimachinery/pkg/util/validation"
|
|
||||||
|
|
||||||
v1 "github.com/fatedier/frp/pkg/config/v1"
|
v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||||
|
utilvalidation "github.com/fatedier/frp/pkg/util/validation"
|
||||||
)
|
)
|
||||||
|
|
||||||
func validateProxyBaseConfigForClient(c *v1.ProxyBaseConfig) error {
|
func validateProxyBaseConfigForClient(c *v1.ProxyBaseConfig) error {
|
||||||
@@ -248,7 +247,7 @@ func ValidateAnnotations(annotations map[string]string) error {
|
|||||||
|
|
||||||
var errs error
|
var errs error
|
||||||
for k := range annotations {
|
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))
|
errs = AppendError(errs, fmt.Errorf("annotation key %s is invalid: %s", k, msg))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
})
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user