forked from Mxmilu666/frp
Merge remote-tracking branch 'upstream/dev' into dev
This commit is contained in:
43
pkg/config/v1/validation/auth.go
Normal file
43
pkg/config/v1/validation/auth.go
Normal file
@@ -0,0 +1,43 @@
|
||||
// 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"
|
||||
|
||||
v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||
"github.com/fatedier/frp/pkg/policy/security"
|
||||
)
|
||||
|
||||
func (v *ConfigValidator) validateAuthTokenSource(token string, tokenSource *v1.ValueSource) error {
|
||||
var errs error
|
||||
// Preserve the previous client/server validation order for joined errors.
|
||||
if token != "" && tokenSource != nil {
|
||||
errs = AppendError(errs, fmt.Errorf("cannot specify both auth.token and auth.tokenSource"))
|
||||
}
|
||||
if tokenSource == nil {
|
||||
return errs
|
||||
}
|
||||
|
||||
if tokenSource.Type == "exec" {
|
||||
if err := v.ValidateUnsafeFeature(security.TokenSourceExec); err != nil {
|
||||
errs = AppendError(errs, err)
|
||||
}
|
||||
}
|
||||
if err := tokenSource.Validate(); err != nil {
|
||||
errs = AppendError(errs, fmt.Errorf("invalid auth.tokenSource: %v", err))
|
||||
}
|
||||
return errs
|
||||
}
|
||||
228
pkg/config/v1/validation/auth_test.go
Normal file
228
pkg/config/v1/validation/auth_test.go
Normal file
@@ -0,0 +1,228 @@
|
||||
// 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 (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||
"github.com/fatedier/frp/pkg/policy/security"
|
||||
)
|
||||
|
||||
const (
|
||||
tokenSourceConflictErr = "cannot specify both auth.token and auth.tokenSource"
|
||||
tokenSourceExecErr = "unsafe feature \"TokenSourceExec\" is not enabled. To enable it, ensure it is allowed in the configuration or command line flags"
|
||||
invalidFileSourceErr = "invalid auth.tokenSource: file configuration is required when type is 'file'"
|
||||
unsupportedSourceErr = "invalid auth.tokenSource: unsupported value source type: env (only 'file' and 'exec' are supported)"
|
||||
)
|
||||
|
||||
func TestValidateAuthTokenSource(t *testing.T) {
|
||||
for _, tc := range authTokenSourceTestCases() {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
validator := newAuthTokenSourceValidator(tc.unsafeAllowed)
|
||||
err := validator.validateAuthTokenSource(tc.token, tc.tokenSource())
|
||||
requireValidationErrors(t, err, tc.wantErrs)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateClientAuthTokenSource(t *testing.T) {
|
||||
for _, tc := range authTokenSourceTestCases() {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
auth := v1.AuthClientConfig{
|
||||
Method: v1.AuthMethodToken,
|
||||
Token: tc.token,
|
||||
TokenSource: tc.tokenSource(),
|
||||
}
|
||||
validator := newAuthTokenSourceValidator(tc.unsafeAllowed)
|
||||
_, err := validator.ValidateClientCommonConfig(validClientConfigWithAuth(auth))
|
||||
requireValidationErrors(t, err, tc.wantErrs)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateServerAuthTokenSource(t *testing.T) {
|
||||
for _, tc := range authTokenSourceTestCases() {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
auth := v1.AuthServerConfig{
|
||||
Method: v1.AuthMethodToken,
|
||||
Token: tc.token,
|
||||
TokenSource: tc.tokenSource(),
|
||||
}
|
||||
validator := newAuthTokenSourceValidator(tc.unsafeAllowed)
|
||||
_, err := validator.ValidateServerConfig(validServerConfigWithAuth(auth))
|
||||
requireValidationErrors(t, err, tc.wantErrs)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type authTokenSourceTestCase struct {
|
||||
name string
|
||||
token string
|
||||
tokenSource func() *v1.ValueSource
|
||||
unsafeAllowed bool
|
||||
wantErrs []string
|
||||
}
|
||||
|
||||
func authTokenSourceTestCases() []authTokenSourceTestCase {
|
||||
return []authTokenSourceTestCase{
|
||||
{
|
||||
name: "empty token config",
|
||||
tokenSource: nilTokenSource,
|
||||
},
|
||||
{
|
||||
name: "valid file tokenSource",
|
||||
tokenSource: validFileTokenSource,
|
||||
},
|
||||
{
|
||||
name: "literal token without tokenSource",
|
||||
token: "token",
|
||||
tokenSource: nilTokenSource,
|
||||
},
|
||||
{
|
||||
name: "literal token conflicts with file tokenSource",
|
||||
token: "token",
|
||||
tokenSource: validFileTokenSource,
|
||||
wantErrs: []string{tokenSourceConflictErr},
|
||||
},
|
||||
{
|
||||
name: "exec tokenSource requires unsafe feature",
|
||||
tokenSource: validExecTokenSource,
|
||||
wantErrs: []string{tokenSourceExecErr},
|
||||
},
|
||||
{
|
||||
name: "exec tokenSource with unsafe feature allowed",
|
||||
tokenSource: validExecTokenSource,
|
||||
unsafeAllowed: true,
|
||||
},
|
||||
{
|
||||
name: "literal token conflicts with exec tokenSource and unsafe feature disabled",
|
||||
token: "token",
|
||||
tokenSource: validExecTokenSource,
|
||||
wantErrs: []string{
|
||||
tokenSourceConflictErr,
|
||||
tokenSourceExecErr,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "literal token conflicts with exec tokenSource and unsafe feature allowed",
|
||||
token: "token",
|
||||
tokenSource: validExecTokenSource,
|
||||
unsafeAllowed: true,
|
||||
wantErrs: []string{tokenSourceConflictErr},
|
||||
},
|
||||
{
|
||||
name: "invalid file tokenSource is wrapped",
|
||||
tokenSource: invalidFileTokenSource,
|
||||
wantErrs: []string{invalidFileSourceErr},
|
||||
},
|
||||
{
|
||||
name: "unsupported tokenSource type is wrapped",
|
||||
tokenSource: unsupportedTokenSource,
|
||||
wantErrs: []string{unsupportedSourceErr},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func newAuthTokenSourceValidator(unsafeAllowed bool) *ConfigValidator {
|
||||
if !unsafeAllowed {
|
||||
return NewConfigValidator(nil)
|
||||
}
|
||||
return NewConfigValidator(security.NewUnsafeFeatures([]string{security.TokenSourceExec}))
|
||||
}
|
||||
|
||||
func requireValidationErrors(t *testing.T, err error, wantErrs []string) {
|
||||
t.Helper()
|
||||
if len(wantErrs) == 0 {
|
||||
require.NoError(t, err)
|
||||
return
|
||||
}
|
||||
require.Error(t, err)
|
||||
// Client/server validators may wrap joined errors in another join layer; compare leaf errors.
|
||||
gotErrs := unwrapValidationErrors(err)
|
||||
require.Len(t, gotErrs, len(wantErrs))
|
||||
for i, wantErr := range wantErrs {
|
||||
require.EqualError(t, gotErrs[i], wantErr)
|
||||
}
|
||||
}
|
||||
|
||||
func unwrapValidationErrors(err error) []error {
|
||||
type joinedError interface {
|
||||
Unwrap() []error
|
||||
}
|
||||
joined, ok := err.(joinedError)
|
||||
if !ok {
|
||||
return []error{err}
|
||||
}
|
||||
|
||||
var errs []error
|
||||
for _, err := range joined.Unwrap() {
|
||||
errs = append(errs, unwrapValidationErrors(err)...)
|
||||
}
|
||||
return errs
|
||||
}
|
||||
|
||||
// nilTokenSource keeps the shared table shape uniform for cases without a tokenSource.
|
||||
func nilTokenSource() *v1.ValueSource {
|
||||
return nil
|
||||
}
|
||||
|
||||
func validFileTokenSource() *v1.ValueSource {
|
||||
return &v1.ValueSource{
|
||||
Type: "file",
|
||||
File: &v1.FileSource{Path: "token.txt"},
|
||||
}
|
||||
}
|
||||
|
||||
func validExecTokenSource() *v1.ValueSource {
|
||||
return &v1.ValueSource{
|
||||
Type: "exec",
|
||||
Exec: &v1.ExecSource{Command: "print-token"},
|
||||
}
|
||||
}
|
||||
|
||||
func invalidFileTokenSource() *v1.ValueSource {
|
||||
return &v1.ValueSource{
|
||||
Type: "file",
|
||||
}
|
||||
}
|
||||
|
||||
func unsupportedTokenSource() *v1.ValueSource {
|
||||
return &v1.ValueSource{Type: "env"}
|
||||
}
|
||||
|
||||
func validClientConfigWithAuth(auth v1.AuthClientConfig) *v1.ClientCommonConfig {
|
||||
return &v1.ClientCommonConfig{
|
||||
Auth: auth,
|
||||
Log: v1.LogConfig{
|
||||
Level: "info",
|
||||
},
|
||||
Transport: v1.ClientTransportConfig{
|
||||
Protocol: "tcp",
|
||||
WireProtocol: "v1",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func validServerConfigWithAuth(auth v1.AuthServerConfig) *v1.ServerConfig {
|
||||
return &v1.ServerConfig{
|
||||
Auth: auth,
|
||||
Log: v1.LogConfig{
|
||||
Level: "info",
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -68,26 +68,16 @@ func (v *ConfigValidator) validateAuthConfig(c *v1.AuthClientConfig) (Warning, e
|
||||
errs = AppendError(errs, fmt.Errorf("invalid auth additional scopes, optional values are %v", SupportedAuthAdditionalScopes))
|
||||
}
|
||||
|
||||
// Validate token/tokenSource mutual exclusivity
|
||||
if c.Token != "" && c.TokenSource != nil {
|
||||
errs = AppendError(errs, fmt.Errorf("cannot specify both auth.token and auth.tokenSource"))
|
||||
}
|
||||
|
||||
// Validate tokenSource if specified
|
||||
if c.TokenSource != nil {
|
||||
if c.TokenSource.Type == "exec" {
|
||||
if err := v.ValidateUnsafeFeature(security.TokenSourceExec); err != nil {
|
||||
errs = AppendError(errs, err)
|
||||
}
|
||||
}
|
||||
if err := c.TokenSource.Validate(); err != nil {
|
||||
errs = AppendError(errs, fmt.Errorf("invalid auth.tokenSource: %v", err))
|
||||
}
|
||||
}
|
||||
errs = AppendError(errs, v.validateAuthTokenSource(c.Token, c.TokenSource))
|
||||
|
||||
if err := v.validateOIDCConfig(&c.OIDC); err != nil {
|
||||
errs = AppendError(errs, err)
|
||||
}
|
||||
if c.Method == v1.AuthMethodOIDC && c.OIDC.TokenSource == nil {
|
||||
if err := ValidateOIDCClientCredentialsConfig(&c.OIDC); err != nil {
|
||||
errs = AppendError(errs, err)
|
||||
}
|
||||
}
|
||||
return nil, errs
|
||||
}
|
||||
|
||||
@@ -141,6 +131,9 @@ func validateTransportConfig(c *v1.ClientTransportConfig) (Warning, error) {
|
||||
if !slices.Contains(SupportedTransportProtocols, c.Protocol) {
|
||||
errs = AppendError(errs, fmt.Errorf("invalid transport.protocol, optional values are %v", SupportedTransportProtocols))
|
||||
}
|
||||
if !slices.Contains(SupportedWireProtocols, c.WireProtocol) {
|
||||
errs = AppendError(errs, fmt.Errorf("invalid transport.wireProtocol, optional values are %v", SupportedWireProtocols))
|
||||
}
|
||||
return warnings, errs
|
||||
}
|
||||
|
||||
|
||||
57
pkg/config/v1/validation/oidc.go
Normal file
57
pkg/config/v1/validation/oidc.go
Normal file
@@ -0,0 +1,57 @@
|
||||
// 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 (
|
||||
"errors"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||
)
|
||||
|
||||
func ValidateOIDCClientCredentialsConfig(c *v1.AuthOIDCClientConfig) error {
|
||||
var errs []string
|
||||
|
||||
if c.ClientID == "" {
|
||||
errs = append(errs, "auth.oidc.clientID is required")
|
||||
}
|
||||
|
||||
if c.TokenEndpointURL == "" {
|
||||
errs = append(errs, "auth.oidc.tokenEndpointURL is required")
|
||||
} else {
|
||||
tokenURL, err := url.Parse(c.TokenEndpointURL)
|
||||
if err != nil || !tokenURL.IsAbs() || tokenURL.Host == "" {
|
||||
errs = append(errs, "auth.oidc.tokenEndpointURL must be an absolute http or https URL")
|
||||
} else if tokenURL.Scheme != "http" && tokenURL.Scheme != "https" {
|
||||
errs = append(errs, "auth.oidc.tokenEndpointURL must use http or https")
|
||||
}
|
||||
}
|
||||
|
||||
if _, ok := c.AdditionalEndpointParams["scope"]; ok {
|
||||
errs = append(errs, "auth.oidc.additionalEndpointParams.scope is not allowed; use auth.oidc.scope instead")
|
||||
}
|
||||
|
||||
if c.Audience != "" {
|
||||
if _, ok := c.AdditionalEndpointParams["audience"]; ok {
|
||||
errs = append(errs, "cannot specify both auth.oidc.audience and auth.oidc.additionalEndpointParams.audience")
|
||||
}
|
||||
}
|
||||
|
||||
if len(errs) == 0 {
|
||||
return nil
|
||||
}
|
||||
return errors.New(strings.Join(errs, "; "))
|
||||
}
|
||||
78
pkg/config/v1/validation/oidc_test.go
Normal file
78
pkg/config/v1/validation/oidc_test.go
Normal file
@@ -0,0 +1,78 @@
|
||||
// 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 (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||
)
|
||||
|
||||
func TestValidateOIDCClientCredentialsConfig(t *testing.T) {
|
||||
tokenServer := httptest.NewServer(http.NotFoundHandler())
|
||||
defer tokenServer.Close()
|
||||
|
||||
t.Run("valid", func(t *testing.T) {
|
||||
require.NoError(t, ValidateOIDCClientCredentialsConfig(&v1.AuthOIDCClientConfig{
|
||||
ClientID: "test-client",
|
||||
TokenEndpointURL: tokenServer.URL,
|
||||
AdditionalEndpointParams: map[string]string{
|
||||
"resource": "api",
|
||||
},
|
||||
}))
|
||||
})
|
||||
|
||||
t.Run("invalid token endpoint url", func(t *testing.T) {
|
||||
err := ValidateOIDCClientCredentialsConfig(&v1.AuthOIDCClientConfig{
|
||||
ClientID: "test-client",
|
||||
TokenEndpointURL: "://bad",
|
||||
})
|
||||
require.ErrorContains(t, err, "auth.oidc.tokenEndpointURL")
|
||||
})
|
||||
|
||||
t.Run("missing client id", func(t *testing.T) {
|
||||
err := ValidateOIDCClientCredentialsConfig(&v1.AuthOIDCClientConfig{
|
||||
TokenEndpointURL: tokenServer.URL,
|
||||
})
|
||||
require.ErrorContains(t, err, "auth.oidc.clientID is required")
|
||||
})
|
||||
|
||||
t.Run("scope endpoint param is not allowed", func(t *testing.T) {
|
||||
err := ValidateOIDCClientCredentialsConfig(&v1.AuthOIDCClientConfig{
|
||||
ClientID: "test-client",
|
||||
TokenEndpointURL: tokenServer.URL,
|
||||
AdditionalEndpointParams: map[string]string{
|
||||
"scope": "email",
|
||||
},
|
||||
})
|
||||
require.ErrorContains(t, err, "auth.oidc.additionalEndpointParams.scope is not allowed; use auth.oidc.scope instead")
|
||||
})
|
||||
|
||||
t.Run("audience conflict", func(t *testing.T) {
|
||||
err := ValidateOIDCClientCredentialsConfig(&v1.AuthOIDCClientConfig{
|
||||
ClientID: "test-client",
|
||||
TokenEndpointURL: tokenServer.URL,
|
||||
Audience: "api",
|
||||
AdditionalEndpointParams: map[string]string{
|
||||
"audience": "override",
|
||||
},
|
||||
})
|
||||
require.ErrorContains(t, err, "cannot specify both auth.oidc.audience and auth.oidc.additionalEndpointParams.audience")
|
||||
})
|
||||
}
|
||||
@@ -81,7 +81,7 @@ func validateDomainConfigForClient(c *v1.DomainConfig) error {
|
||||
func validateDomainConfigForServer(c *v1.DomainConfig, s *v1.ServerConfig) error {
|
||||
for _, domain := range c.CustomDomains {
|
||||
if s.SubDomainHost != "" && len(strings.Split(s.SubDomainHost, ".")) < len(strings.Split(domain, ".")) {
|
||||
if strings.Contains(domain, s.SubDomainHost) {
|
||||
if strings.HasSuffix(domain, "."+s.SubDomainHost) {
|
||||
return fmt.Errorf("custom domain [%s] should not belong to subdomain host [%s]", domain, s.SubDomainHost)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,6 @@ import (
|
||||
"github.com/samber/lo"
|
||||
|
||||
v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||
"github.com/fatedier/frp/pkg/policy/security"
|
||||
)
|
||||
|
||||
func (v *ConfigValidator) ValidateServerConfig(c *v1.ServerConfig) (Warning, error) {
|
||||
@@ -36,22 +35,7 @@ func (v *ConfigValidator) ValidateServerConfig(c *v1.ServerConfig) (Warning, err
|
||||
errs = AppendError(errs, fmt.Errorf("invalid auth additional scopes, optional values are %v", SupportedAuthAdditionalScopes))
|
||||
}
|
||||
|
||||
// Validate token/tokenSource mutual exclusivity
|
||||
if c.Auth.Token != "" && c.Auth.TokenSource != nil {
|
||||
errs = AppendError(errs, fmt.Errorf("cannot specify both auth.token and auth.tokenSource"))
|
||||
}
|
||||
|
||||
// Validate tokenSource if specified
|
||||
if c.Auth.TokenSource != nil {
|
||||
if c.Auth.TokenSource.Type == "exec" {
|
||||
if err := v.ValidateUnsafeFeature(security.TokenSourceExec); err != nil {
|
||||
errs = AppendError(errs, err)
|
||||
}
|
||||
}
|
||||
if err := c.Auth.TokenSource.Validate(); err != nil {
|
||||
errs = AppendError(errs, fmt.Errorf("invalid auth.tokenSource: %v", err))
|
||||
}
|
||||
}
|
||||
errs = AppendError(errs, v.validateAuthTokenSource(c.Auth.Token, c.Auth.TokenSource))
|
||||
|
||||
if err := validateLogConfig(&c.Log); err != nil {
|
||||
errs = AppendError(errs, err)
|
||||
|
||||
@@ -29,6 +29,10 @@ var (
|
||||
"websocket",
|
||||
"wss",
|
||||
}
|
||||
SupportedWireProtocols = []string{
|
||||
"v1",
|
||||
"v2",
|
||||
}
|
||||
|
||||
SupportedAuthMethods = []v1.AuthMethod{
|
||||
"token",
|
||||
|
||||
Reference in New Issue
Block a user