From 8a1b9500e3e046bb329207c5814a2c92362ff106 Mon Sep 17 00:00:00 2001 From: cappyzawa Date: Wed, 3 Dec 2025 11:21:01 +0900 Subject: [PATCH 1/2] runtime/secrets: validate proxy URL scheme and length Add validation to ProxyURLFromSecret to ensure proxy URLs use supported schemes (http, https, socks5) and do not exceed the maximum length of 2048 characters. This addresses the regression introduced in PR #1041 which removed all validation after users reported SOCKS5 proxy breakage. The new implementation properly supports SOCKS5 while maintaining security through scheme and length validation. Signed-off-by: cappyzawa --- runtime/secrets/converter.go | 42 +++++++++++++++++- runtime/secrets/converter_test.go | 74 +++++++++++++++++++++++++++++++ runtime/secrets/secrets.go | 7 +++ 3 files changed, 121 insertions(+), 2 deletions(-) diff --git a/runtime/secrets/converter.go b/runtime/secrets/converter.go index b312c55bb..59c07fd42 100644 --- a/runtime/secrets/converter.go +++ b/runtime/secrets/converter.go @@ -23,6 +23,8 @@ import ( "errors" "fmt" "net/url" + "slices" + "strings" "github.com/go-logr/logr" corev1 "k8s.io/api/core/v1" @@ -132,6 +134,9 @@ func TLSConfigFromSecret(ctx context.Context, secret *corev1.Secret, opts ...TLS // The function expects the secret to contain an "address" field with the // proxy URL. Optional "username" and "password" fields can be provided // for proxy authentication. +// +// Supported proxy schemes are: http, https, and socks5. +// The proxy URL must not exceed 2048 characters. func ProxyURLFromSecret(ctx context.Context, secret *corev1.Secret) (*url.URL, error) { addressData, exists := secret.Data[KeyAddress] if !exists { @@ -144,10 +149,10 @@ func ProxyURLFromSecret(ctx context.Context, secret *corev1.Secret) (*url.URL, e return nil, fmt.Errorf("secret '%s': proxy address is empty", ref) } - proxyURL, err := url.Parse(address) + proxyURL, err := parseProxyURL(address) if err != nil { ref := client.ObjectKeyFromObject(secret) - return nil, fmt.Errorf("secret '%s': failed to parse proxy address '%s': %w", ref, address, err) + return nil, fmt.Errorf("secret '%s': %w", ref, err) } username, hasUsername := secret.Data[KeyUsername] @@ -162,6 +167,39 @@ func ProxyURLFromSecret(ctx context.Context, secret *corev1.Secret) (*url.URL, e return proxyURL, nil } +func parseProxyURL(address string) (*url.URL, error) { + if err := validateProxyURLString(address); err != nil { + return nil, err + } + + u, err := url.Parse(address) + if err != nil { + return nil, fmt.Errorf("failed to parse proxy address '%s': %w", address, err) + } + + if err := validateProxyURLStruct(u); err != nil { + return nil, err + } + + return u, nil +} + +func validateProxyURLString(address string) error { + if len(address) > MaxProxyURLLength { + return fmt.Errorf("proxy URL exceeds maximum length of %d characters", MaxProxyURLLength) + } + return nil +} + +func validateProxyURLStruct(u *url.URL) error { + if !slices.Contains(supportedProxySchemes, u.Scheme) { + supportedSchemes := strings.Join(supportedProxySchemes, ", ") + return fmt.Errorf("proxy URL must use one of the supported schemes (%s), got '%s'", + supportedSchemes, u.Scheme) + } + return nil +} + // BasicAuthFromSecret retrieves basic authentication credentials from a Kubernetes secret. // // The function expects the secret to contain "username" and "password" fields. diff --git a/runtime/secrets/converter_test.go b/runtime/secrets/converter_test.go index 30f54d00a..9ba69df30 100644 --- a/runtime/secrets/converter_test.go +++ b/runtime/secrets/converter_test.go @@ -20,6 +20,7 @@ import ( "context" "crypto/tls" "fmt" + "strings" "testing" "github.com/go-logr/logr" @@ -752,6 +753,79 @@ func TestProxyURLFromSecret(t *testing.T) { ), errMsg: "secret 'default/proxy-secret': failed to parse proxy address", }, + { + name: "socks5 proxy", + secret: testSecret( + withName("proxy-secret"), + withData(map[string][]byte{ + secrets.KeyAddress: []byte("socks5://socks-proxy.example.com:1080"), + }), + ), + wantURL: "socks5://socks-proxy.example.com:1080", + }, + { + name: "socks5 proxy with authentication", + secret: testSecret( + withName("proxy-secret"), + withData(map[string][]byte{ + secrets.KeyAddress: []byte("socks5://socks-proxy.example.com:1080"), + secrets.KeyUsername: []byte("sockuser"), + secrets.KeyPassword: []byte("sockpass"), + }), + ), + wantURL: "socks5://sockuser:sockpass@socks-proxy.example.com:1080", + }, + { + name: "unsupported scheme - ftp", + secret: testSecret( + withName("proxy-secret"), + withData(map[string][]byte{ + secrets.KeyAddress: []byte("ftp://ftp.example.com:21"), + }), + ), + errMsg: "proxy URL must use one of the supported schemes (http, https, socks5), got 'ftp'", + }, + { + name: "unsupported scheme - socks4", + secret: testSecret( + withName("proxy-secret"), + withData(map[string][]byte{ + secrets.KeyAddress: []byte("socks4://proxy.example.com:1080"), + }), + ), + errMsg: "proxy URL must use one of the supported schemes (http, https, socks5), got 'socks4'", + }, + { + name: "URL exceeds maximum length", + secret: testSecret( + withName("proxy-secret"), + withData(map[string][]byte{ + secrets.KeyAddress: []byte("http://" + strings.Repeat("a", 2050)), + }), + ), + errMsg: "proxy URL exceeds maximum length of 2048 characters", + }, + { + name: "URL at maximum length boundary", + secret: testSecret( + withName("proxy-secret"), + withData(map[string][]byte{ + // Create a URL exactly 2048 characters (http:// = 7 chars, so 2041 'a's) + secrets.KeyAddress: []byte("http://" + strings.Repeat("a", 2041)), + }), + ), + wantURL: "http://" + strings.Repeat("a", 2041), + }, + { + name: "missing scheme", + secret: testSecret( + withName("proxy-secret"), + withData(map[string][]byte{ + secrets.KeyAddress: []byte("//proxy.example.com:8080"), + }), + ), + errMsg: "proxy URL must use one of the supported schemes (http, https, socks5), got ''", + }, } for _, tt := range tests { diff --git a/runtime/secrets/secrets.go b/runtime/secrets/secrets.go index f7fa5e369..38c53c721 100644 --- a/runtime/secrets/secrets.go +++ b/runtime/secrets/secrets.go @@ -69,8 +69,15 @@ const ( KeySSHPublicKey = "identity.pub" // KeySSHKnownHosts is the key for SSH known hosts data in secrets. KeySSHKnownHosts = "known_hosts" + + // MaxProxyURLLength is the maximum allowed length for proxy URLs. + MaxProxyURLLength = 2048 ) +// supportedProxySchemes defines the officially supported proxy URL schemes. +// See https://fluxcd.io/flux/installation/configuration/proxy-setting for more information. +var supportedProxySchemes = []string{"http", "https", "socks5"} + // AuthMethods holds all available authentication methods detected from a secret. type AuthMethods struct { Basic *BasicAuth From e4755cafec3190a9c67fc5c98877766c6d966718 Mon Sep 17 00:00:00 2001 From: cappyzawa Date: Wed, 3 Dec 2025 11:22:34 +0900 Subject: [PATCH 2/2] Prepare for release Signed-off-by: cappyzawa --- tests/integration/go.mod | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/integration/go.mod b/tests/integration/go.mod index 40e413e2d..6b5152394 100644 --- a/tests/integration/go.mod +++ b/tests/integration/go.mod @@ -22,7 +22,7 @@ require ( github.com/fluxcd/pkg/cache v0.12.0 github.com/fluxcd/pkg/git v0.38.0 github.com/fluxcd/pkg/git/gogit v0.42.0 - github.com/fluxcd/pkg/runtime v0.91.0 + github.com/fluxcd/pkg/runtime v0.92.0 github.com/fluxcd/test-infra/tftestenv v0.0.0-20250626232827-e0ca9c3f8d7b github.com/go-git/go-git/v5 v5.16.3 github.com/google/go-containerregistry v0.20.6