From 10b73ecb8487121644430a111bca3171082bc17d Mon Sep 17 00:00:00 2001 From: theghost5800 Date: Fri, 15 Aug 2025 18:05:05 +0300 Subject: [PATCH 1/2] Add custom user agent for every http request JIRA:LMCROSSITXSADEPLOY-2854 --- README.md | 1 + clients/baseclient/base_client.go | 6 +- clients/baseclient/base_client_test.go | 58 ++++ clients/baseclient/user_agent_transport.go | 37 +++ .../baseclient/user_agent_transport_test.go | 127 +++++++++ .../rest_cloud_foundry_client_extended.go | 10 +- commands/base_command.go | 12 +- commands/csrf_transport_test.go | 86 ++++++ multiapps_plugin.go | 2 + testutil/transport.go | 7 +- util/env_configuration_help.go | 1 + util/user_agent_builder.go | 87 ++++++ util/user_agent_builder_test.go | 248 ++++++++++++++++++ 13 files changed, 677 insertions(+), 5 deletions(-) create mode 100644 clients/baseclient/base_client_test.go create mode 100644 clients/baseclient/user_agent_transport.go create mode 100644 clients/baseclient/user_agent_transport_test.go create mode 100644 commands/csrf_transport_test.go create mode 100644 util/user_agent_builder.go create mode 100644 util/user_agent_builder_test.go diff --git a/README.md b/README.md index eae54cc..2dc5aba 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,7 @@ The configuration of the MultiApps CF plugin is done via env variables. The foll For example, with a 100MB MTAR the minimum value for this environment variable would be 2, and for a 400MB MTAR it would be 8. Finally, the minimum value cannot grow over 50, so with a 4GB MTAR, the minimum value would be 50 and not 80. * `MULTIAPPS_UPLOAD_CHUNKS_SEQUENTIALLY=` - By default, MTAR chunks are uploaded in parallel for better performance. In case of a bad internet connection, the option to upload them sequentially will lessen network load. * `MULTIAPPS_DISABLE_UPLOAD_PROGRESS_BAR=` - By default, the file upload shows a progress bar. In case of CI/CD systems where console text escaping isn't supported, the bar can be disabled to reduce unnecessary logs. +* `MULTIAPPS_USER_AGENT_SUFFIX=` - Allows customization of the User-Agent header sent with all HTTP requests. The value will be appended to the standard User-Agent string format: "Multiapps-CF-plugin/{version} ({operating system version}) {golang builder version} {custom_value}". Only alphanumeric characters, spaces, hyphens, dots, and underscores are allowed. Maximum length is 128 characters; longer values will be truncated. Dangerous characters (control characters, colons, semicolons) are automatically removed for security. This can be useful for tracking requests from specific environments or CI/CD systems. # How to contribute * [Did you find a bug?](CONTRIBUTING.md#did-you-find-a-bug) diff --git a/clients/baseclient/base_client.go b/clients/baseclient/base_client.go index b553a4b..f45cfec 100644 --- a/clients/baseclient/base_client.go +++ b/clients/baseclient/base_client.go @@ -30,7 +30,11 @@ func NewHTTPTransport(host, url string, rt http.RoundTripper) *client.Runtime { // TODO: apply the changes made by Boyan here, as after the update of the dependencies the changes are not available transport := client.New(host, url, schemes) transport.Consumers["text/html"] = runtime.TextConsumer() - transport.Transport = rt + + // Wrap the RoundTripper with User-Agent support + userAgentTransport := NewUserAgentTransport(rt) + transport.Transport = userAgentTransport + jar, _ := cookiejar.New(nil) transport.Jar = jar return transport diff --git a/clients/baseclient/base_client_test.go b/clients/baseclient/base_client_test.go new file mode 100644 index 0000000..7a1d39b --- /dev/null +++ b/clients/baseclient/base_client_test.go @@ -0,0 +1,58 @@ +package baseclient + +import ( + "net/http" + "net/http/httptest" + + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" +) + +var _ = Describe("BaseClient", func() { + + Describe("NewHTTPTransport", func() { + Context("when creating transport with User-Agent functionality", func() { + var server *httptest.Server + var capturedHeaders http.Header + + BeforeEach(func() { + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedHeaders = r.Header + w.WriteHeader(http.StatusOK) + })) + }) + + AfterEach(func() { + if server != nil { + server.Close() + } + }) + + It("should include User-Agent header in requests", func() { + transport := NewHTTPTransport(server.URL, "/", nil) + + req, err := http.NewRequest("GET", server.URL+"/test", nil) + Expect(err).ToNot(HaveOccurred()) + + _, err = transport.Transport.RoundTrip(req) + Expect(err).ToNot(HaveOccurred()) + + userAgent := capturedHeaders.Get("User-Agent") + Expect(userAgent).ToNot(BeEmpty(), "Expected User-Agent header to be set") + Expect(userAgent).To(HavePrefix("Multiapps-CF-plugin/"), "Expected User-Agent to start with 'Multiapps-CF-plugin/'") + }) + }) + + Context("when custom round tripper is provided", func() { + It("should preserve the custom round tripper as base transport", func() { + customTransport := &mockRoundTripper{} + + transport := NewHTTPTransport("example.com", "/", customTransport) + + userAgentTransport, ok := transport.Transport.(*UserAgentTransport) + Expect(ok).To(BeTrue(), "Expected transport to be wrapped with UserAgentTransport") + Expect(userAgentTransport.Base).To(Equal(customTransport), "Expected custom round tripper to be preserved as base transport") + }) + }) + }) +}) diff --git a/clients/baseclient/user_agent_transport.go b/clients/baseclient/user_agent_transport.go new file mode 100644 index 0000000..f8e102c --- /dev/null +++ b/clients/baseclient/user_agent_transport.go @@ -0,0 +1,37 @@ +package baseclient + +import ( + "net/http" + + "github.com/cloudfoundry-incubator/multiapps-cli-plugin/util" +) + +// UserAgentTransport wraps an existing RoundTripper and adds User-Agent header +type UserAgentTransport struct { + Base http.RoundTripper + UserAgent string +} + +// NewUserAgentTransport creates a new transport with User-Agent header support +func NewUserAgentTransport(base http.RoundTripper) *UserAgentTransport { + if base == nil { + base = http.DefaultTransport + } + + return &UserAgentTransport{ + Base: base, + UserAgent: util.BuildUserAgent(), + } +} + +// RoundTrip implements the RoundTripper interface +func (uat *UserAgentTransport) RoundTrip(req *http.Request) (*http.Response, error) { + // Clone the request to avoid modifying the original + reqCopy := req.Clone(req.Context()) + + // Add or override the User-Agent header + reqCopy.Header.Set("User-Agent", uat.UserAgent) + + // Execute the request with the base transport + return uat.Base.RoundTrip(reqCopy) +} diff --git a/clients/baseclient/user_agent_transport_test.go b/clients/baseclient/user_agent_transport_test.go new file mode 100644 index 0000000..9ac7439 --- /dev/null +++ b/clients/baseclient/user_agent_transport_test.go @@ -0,0 +1,127 @@ +package baseclient + +import ( + "net/http" + "net/http/httptest" + + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" +) + +// mockRoundTripper for testing +type mockRoundTripper struct { + lastRequest *http.Request +} + +func (m *mockRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + m.lastRequest = req + return &http.Response{ + StatusCode: 200, + Header: make(http.Header), + Body: http.NoBody, + }, nil +} + +var _ = Describe("UserAgentTransport", func() { + + Describe("NewUserAgentTransport", func() { + Context("when base transport is nil", func() { + It("should use http.DefaultTransport as base", func() { + transport := NewUserAgentTransport(nil) + + Expect(transport.Base).To(Equal(http.DefaultTransport)) + }) + + It("should set User-Agent with correct prefix", func() { + transport := NewUserAgentTransport(nil) + + Expect(transport.UserAgent).ToNot(BeEmpty()) + Expect(transport.UserAgent).To(HavePrefix("Multiapps-CF-plugin/")) + }) + }) + + Context("when custom base transport is provided", func() { + It("should use the provided transport as base", func() { + mockTransport := &mockRoundTripper{} + transport := NewUserAgentTransport(mockTransport) + + Expect(transport.Base).To(Equal(mockTransport)) + }) + }) + }) + + Describe("RoundTrip", func() { + var mockTransport *mockRoundTripper + var userAgentTransport *UserAgentTransport + + BeforeEach(func() { + mockTransport = &mockRoundTripper{} + userAgentTransport = NewUserAgentTransport(mockTransport) + }) + + Context("when making a request", func() { + var req *http.Request + + BeforeEach(func() { + req = httptest.NewRequest("GET", "http://example.com", nil) + req.Header.Set("Existing-Header", "value") + }) + + It("should pass the request to base transport", func() { + _, err := userAgentTransport.RoundTrip(req) + + Expect(err).ToNot(HaveOccurred()) + Expect(mockTransport.lastRequest).ToNot(BeNil()) + }) + + It("should add User-Agent header to the request", func() { + _, err := userAgentTransport.RoundTrip(req) + + Expect(err).ToNot(HaveOccurred()) + userAgent := mockTransport.lastRequest.Header.Get("User-Agent") + Expect(userAgent).ToNot(BeEmpty()) + Expect(userAgent).To(HavePrefix("Multiapps-CF-plugin/")) + }) + + It("should preserve existing headers", func() { + _, err := userAgentTransport.RoundTrip(req) + + Expect(err).ToNot(HaveOccurred()) + existingHeader := mockTransport.lastRequest.Header.Get("Existing-Header") + Expect(existingHeader).To(Equal("value")) + }) + + It("should not modify the original request", func() { + _, err := userAgentTransport.RoundTrip(req) + + Expect(err).ToNot(HaveOccurred()) + Expect(req.Header.Get("User-Agent")).To(BeEmpty()) + }) + }) + + Context("when request has existing User-Agent header", func() { + var req *http.Request + + BeforeEach(func() { + req = httptest.NewRequest("GET", "http://example.com", nil) + req.Header.Set("User-Agent", "existing-user-agent") + }) + + It("should override the existing User-Agent header", func() { + _, err := userAgentTransport.RoundTrip(req) + + Expect(err).ToNot(HaveOccurred()) + userAgent := mockTransport.lastRequest.Header.Get("User-Agent") + Expect(userAgent).ToNot(Equal("existing-user-agent")) + Expect(userAgent).To(HavePrefix("Multiapps-CF-plugin/")) + }) + + It("should not modify the original request", func() { + _, err := userAgentTransport.RoundTrip(req) + + Expect(err).ToNot(HaveOccurred()) + Expect(req.Header.Get("User-Agent")).To(Equal("existing-user-agent")) + }) + }) + }) +}) diff --git a/clients/cfrestclient/rest_cloud_foundry_client_extended.go b/clients/cfrestclient/rest_cloud_foundry_client_extended.go index 7a6cc42..d6b061d 100644 --- a/clients/cfrestclient/rest_cloud_foundry_client_extended.go +++ b/clients/cfrestclient/rest_cloud_foundry_client_extended.go @@ -11,6 +11,7 @@ import ( "code.cloudfoundry.org/cli/plugin" "code.cloudfoundry.org/jsonry" + "github.com/cloudfoundry-incubator/multiapps-cli-plugin/clients/baseclient" "github.com/cloudfoundry-incubator/multiapps-cli-plugin/clients/models" "github.com/cloudfoundry-incubator/multiapps-cli-plugin/log" ) @@ -156,10 +157,15 @@ func getPaginatedResourcesWithIncluded[T any, Auxiliary any](url, token string, func executeRequest(url, token string, isSslDisabled bool) ([]byte, error) { req, _ := http.NewRequest(http.MethodGet, url, nil) req.Header.Add("Authorization", token) + + // Create transport with TLS configuration httpTransport := http.DefaultTransport.(*http.Transport).Clone() httpTransport.TLSClientConfig = &tls.Config{InsecureSkipVerify: isSslDisabled} - client := http.DefaultClient - client.Transport = httpTransport + + // Wrap with User-Agent transport + userAgentTransport := baseclient.NewUserAgentTransport(httpTransport) + + client := &http.Client{Transport: userAgentTransport} resp, err := client.Do(req) if err != nil { return nil, err diff --git a/commands/base_command.go b/commands/base_command.go index 78f3203..689f08b 100644 --- a/commands/base_command.go +++ b/commands/base_command.go @@ -276,7 +276,17 @@ func newTransport(isSslDisabled bool) http.RoundTripper { // Increase tls handshake timeout to cope with slow internet connections. 3 x default value =30s. httpTransport.TLSHandshakeTimeout = 30 * time.Second httpTransport.TLSClientConfig = &tls.Config{InsecureSkipVerify: isSslDisabled} - return &csrf.Transport{Delegate: httpTransport, Csrf: &csrfx} + + // Wrap with User-Agent transport first + userAgentTransport := baseclient.NewUserAgentTransport(httpTransport) + + // Then wrap with CSRF transport + return &csrf.Transport{Delegate: userAgentTransport, Csrf: &csrfx} +} + +// NewTransportForTesting creates a transport for testing purposes +func NewTransportForTesting(isSslDisabled bool) http.RoundTripper { + return newTransport(isSslDisabled) } func getNonProtectedMethods() map[string]struct{} { diff --git a/commands/csrf_transport_test.go b/commands/csrf_transport_test.go new file mode 100644 index 0000000..3cd4160 --- /dev/null +++ b/commands/csrf_transport_test.go @@ -0,0 +1,86 @@ +package commands_test + +import ( + "net/http" + "net/http/httptest" + + "github.com/cloudfoundry-incubator/multiapps-cli-plugin/commands" + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" +) + +var _ = Describe("CSRFTransport", func() { + + Describe("newTransport", func() { + var server *httptest.Server + var capturedHeaders http.Header + var transport http.RoundTripper + + BeforeEach(func() { + transport = commands.NewTransportForTesting(false) + }) + + AfterEach(func() { + if server != nil { + server.Close() + } + }) + + Context("when making regular requests", func() { + BeforeEach(func() { + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedHeaders = r.Header + w.WriteHeader(http.StatusOK) + })) + }) + + It("should include User-Agent header", func() { + req, err := http.NewRequest("GET", server.URL+"/test", nil) + Expect(err).ToNot(HaveOccurred()) + + _, err = transport.RoundTrip(req) + Expect(err).ToNot(HaveOccurred()) + + userAgent := capturedHeaders.Get("User-Agent") + Expect(userAgent).ToNot(BeEmpty(), "Expected User-Agent header to be set in CSRF transport") + Expect(userAgent).To(HavePrefix("Multiapps-CF-plugin/"), "Expected User-Agent to start with 'Multiapps-CF-plugin/'") + }) + }) + + Context("when CSRF token fetch is required", func() { + var requestCount int + + BeforeEach(func() { + requestCount = 0 + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestCount++ + capturedHeaders = r.Header + + if requestCount == 1 { + // First request should be CSRF token fetch - return 403 to trigger token fetch + w.Header().Set("X-Csrf-Token", "required") + w.WriteHeader(http.StatusForbidden) + } else { + // Second request should have the token + w.WriteHeader(http.StatusOK) + } + })) + }) + + It("should include User-Agent header in CSRF token fetch request", func() { + req, err := http.NewRequest("POST", server.URL+"/test", nil) + Expect(err).ToNot(HaveOccurred()) + + // Execute the request through the transport + // This should trigger a CSRF token fetch first + // We expect this to potentially error since our mock server doesn't properly implement CSRF + // But we can still verify the User-Agent was set in the token fetch request + transport.RoundTrip(req) + + userAgent := capturedHeaders.Get("User-Agent") + Expect(userAgent).ToNot(BeEmpty(), "Expected User-Agent header to be set in CSRF token fetch request") + Expect(userAgent).To(HavePrefix("Multiapps-CF-plugin/"), "Expected User-Agent to start with 'Multiapps-CF-plugin/'") + }) + }) + }) +}) diff --git a/multiapps_plugin.go b/multiapps_plugin.go index 683b679..2ed8c19 100644 --- a/multiapps_plugin.go +++ b/multiapps_plugin.go @@ -11,6 +11,7 @@ import ( "code.cloudfoundry.org/cli/plugin" "github.com/cloudfoundry-incubator/multiapps-cli-plugin/commands" "github.com/cloudfoundry-incubator/multiapps-cli-plugin/log" + "github.com/cloudfoundry-incubator/multiapps-cli-plugin/util" ) // Version is the version of the CLI plugin. It is injected on linking time. @@ -42,6 +43,7 @@ func (p *MultiappsPlugin) Run(cliConnection plugin.CliConnection, args []string) if err != nil { log.Fatalln(err) } + util.SetPluginVersion(Version) command.Initialize(command.GetPluginCommand().Name, cliConnection) status := command.Execute(args[1:]) if status == commands.Failure { diff --git a/testutil/transport.go b/testutil/transport.go index e27bac5..adad02d 100644 --- a/testutil/transport.go +++ b/testutil/transport.go @@ -5,6 +5,7 @@ import ( "io" "net/http" + "github.com/cloudfoundry-incubator/multiapps-cli-plugin/clients/baseclient" "github.com/cloudfoundry-incubator/multiapps-cli-plugin/clients/csrf" "github.com/go-openapi/runtime" "github.com/go-openapi/strfmt" @@ -26,7 +27,11 @@ func NewCustomTransport(statusCode int) *csrf.Transport { resp.Body = io.NopCloser(buf) return &resp, nil }) - return &csrf.Transport{Delegate: transport, Csrf: &csrf.CsrfTokenHelper{}} + + // Wrap with User-Agent transport for testing consistency + userAgentTransport := baseclient.NewUserAgentTransport(transport) + + return &csrf.Transport{Delegate: userAgentTransport, Csrf: &csrf.CsrfTokenHelper{}} } // NewCustomBearerToken creates a new bearer token to be used for testing diff --git a/util/env_configuration_help.go b/util/env_configuration_help.go index 28984fa..87bfa90 100644 --- a/util/env_configuration_help.go +++ b/util/env_configuration_help.go @@ -5,6 +5,7 @@ const BaseEnvHelpText = ` ENVIRONMENT: DEBUG=1 Enables the logging of HTTP requests in STDOUT and STDERR. MULTIAPPS_CONTROLLER_URL= Overrides the default deploy-service. with a custom URL. + MULTIAPPS_USER_AGENT_SUFFIX= Appends custom text to User-Agent header. Only alphanumeric, spaces, hyphens, dots, underscores allowed. Max 128 chars, excess truncated. ` const UploadEnvHelpText = BaseEnvHelpText + ` MULTIAPPS_UPLOAD_CHUNK_SIZE= Configures chunk size (in MB) for MTAR upload. diff --git a/util/user_agent_builder.go b/util/user_agent_builder.go new file mode 100644 index 0000000..f60ecb6 --- /dev/null +++ b/util/user_agent_builder.go @@ -0,0 +1,87 @@ +package util + +import ( + "fmt" + "os" + "regexp" + "runtime" + "strings" +) + +// Limit for MULTIAPPS_USER_AGENT_SUFFIX +const maxSuffixLength = 128 + +// pluginVersion stores the version set from the main package +var pluginVersion string = "0.0.0" + +// SetPluginVersion sets the plugin version for use in User-Agent +func SetPluginVersion(version string) { + pluginVersion = version +} + +// GetPluginVersion returns the current plugin version +func GetPluginVersion() string { + return pluginVersion +} + +// BuildUserAgent creates a User-Agent string in the format: +// "Multiapps-CF-plugin/{version} ({operating system version}) {golang builder version} {custom_env_value}" +func BuildUserAgent() string { + osInformation := getOperatingSystemInformation() + goVersion := runtime.Version() + customValue := getCustomEnvValue() + + userAgent := fmt.Sprintf("Multiapps-CF-plugin/%s (%s) %s", pluginVersion, osInformation, goVersion) + + if customValue != "" { + userAgent = fmt.Sprintf("%s %s", userAgent, customValue) + } + + return userAgent +} + +// getOperatingSystemInformation returns OS name and architecture +func getOperatingSystemInformation() string { + return fmt.Sprintf("%s %s", runtime.GOOS, runtime.GOARCH) +} + +// getCustomEnvValue reads value from custom environment variable with validation +func getCustomEnvValue() string { + value := os.Getenv("MULTIAPPS_USER_AGENT_SUFFIX") + if value == "" { + return "" + } + + return sanitizeUserAgentSuffix(value) +} + +// sanitizeUserAgentSuffix sanitizes the user agent suffix +func sanitizeUserAgentSuffix(value string) string { + // Security constraints for HTTP User-Agent header: + // 1. Max length to prevent server buffer overflow (Tomcat default: 8KB total headers) + // 2. Only allow safe characters to prevent header injection + // 3. Remove control characters and newlines + + // Remove control characters, CR, LF, and other dangerous characters + value = strings.ReplaceAll(value, "\r", "") + value = strings.ReplaceAll(value, "\n", "") + value = strings.ReplaceAll(value, "\t", " ") + + // Only allow ASCII characters, spaces, hyphens, dots, underscores, and alphanumeric + // This prevents header injection attacks + invalidChars := regexp.MustCompile(`[^a-zA-Z0-9 .\-_]`) + value = invalidChars.ReplaceAllString(value, "") + + // Remove sequences that could be interpreted as header separators + value = strings.ReplaceAll(value, ":", "") + value = strings.ReplaceAll(value, ";", "") + + // Trim whitespace and limit length + value = strings.TrimSpace(value) + if len(value) > maxSuffixLength { + value = value[:maxSuffixLength] + value = strings.TrimSpace(value) // Trim again in case we cut in the middle of whitespace + } + + return value +} diff --git a/util/user_agent_builder_test.go b/util/user_agent_builder_test.go new file mode 100644 index 0000000..3baaf60 --- /dev/null +++ b/util/user_agent_builder_test.go @@ -0,0 +1,248 @@ +package util_test + +import ( + "fmt" + "os" + "runtime" + "strings" + + "github.com/cloudfoundry-incubator/multiapps-cli-plugin/util" + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" +) + +var _ = Describe("UserAgentBuilder", func() { + + Describe("BuildUserAgent", func() { + var originalVersion string + + BeforeEach(func() { + // Save original version for restoration + originalVersion = util.GetPluginVersion() + }) + + AfterEach(func() { + // Restore original version and clean up environment + util.SetPluginVersion(originalVersion) + os.Unsetenv("MULTIAPPS_USER_AGENT_SUFFIX") + }) + + Context("with default version", func() { + BeforeEach(func() { + util.SetPluginVersion("0.0.0") + }) + + It("should contain default version, OS, arch, and Go version", func() { + userAgent := util.BuildUserAgent() + + Expect(userAgent).To(ContainSubstring("Multiapps-CF-plugin/0.0.0")) + Expect(userAgent).To(ContainSubstring(runtime.GOOS)) + Expect(userAgent).To(ContainSubstring(runtime.GOARCH)) + Expect(userAgent).To(ContainSubstring(runtime.Version())) + Expect(userAgent).To(HavePrefix("Multiapps-CF-plugin/")) + }) + }) + + Context("with custom version", func() { + BeforeEach(func() { + util.SetPluginVersion("3.6.0") + }) + + It("should contain the custom version", func() { + userAgent := util.BuildUserAgent() + + Expect(userAgent).To(ContainSubstring("Multiapps-CF-plugin/3.6.0")) + Expect(userAgent).To(ContainSubstring(runtime.GOOS)) + Expect(userAgent).To(ContainSubstring(runtime.GOARCH)) + Expect(userAgent).To(ContainSubstring(runtime.Version())) + }) + }) + + Context("with dev version", func() { + BeforeEach(func() { + util.SetPluginVersion("3.7.0-dev") + }) + + It("should contain the dev version", func() { + userAgent := util.BuildUserAgent() + + Expect(userAgent).To(ContainSubstring("Multiapps-CF-plugin/3.7.0-dev")) + Expect(userAgent).To(ContainSubstring(runtime.GOOS)) + Expect(userAgent).To(ContainSubstring(runtime.GOARCH)) + Expect(userAgent).To(ContainSubstring(runtime.Version())) + }) + }) + + Context("with custom environment value", func() { + BeforeEach(func() { + util.SetPluginVersion("3.6.0") + os.Setenv("MULTIAPPS_USER_AGENT_SUFFIX", "custom-suffix") + }) + + It("should include the custom suffix", func() { + userAgent := util.BuildUserAgent() + + Expect(userAgent).To(ContainSubstring("Multiapps-CF-plugin/3.6.0")) + Expect(userAgent).To(ContainSubstring(runtime.GOOS)) + Expect(userAgent).To(ContainSubstring(runtime.GOARCH)) + Expect(userAgent).To(ContainSubstring(runtime.Version())) + Expect(userAgent).To(ContainSubstring("custom-suffix")) + }) + }) + + Context("with sanitized environment value", func() { + BeforeEach(func() { + util.SetPluginVersion("1.0.0") + }) + + It("should remove dangerous characters", func() { + os.Setenv("MULTIAPPS_USER_AGENT_SUFFIX", "value:with;dangerous") + userAgent := util.BuildUserAgent() + + Expect(userAgent).To(ContainSubstring("valuewithdangerous")) + Expect(userAgent).ToNot(ContainSubstring(":")) + Expect(userAgent).ToNot(ContainSubstring(";")) + }) + + It("should handle control characters", func() { + os.Setenv("MULTIAPPS_USER_AGENT_SUFFIX", "value\r\nwith\tcontrol") + userAgent := util.BuildUserAgent() + + Expect(userAgent).To(ContainSubstring("valuewith control")) + Expect(userAgent).ToNot(ContainSubstring("\r")) + Expect(userAgent).ToNot(ContainSubstring("\n")) + Expect(userAgent).ToNot(ContainSubstring("\t")) + }) + + It("should remove unicode characters", func() { + os.Setenv("MULTIAPPS_USER_AGENT_SUFFIX", "value†with•unicode") + userAgent := util.BuildUserAgent() + + Expect(userAgent).To(ContainSubstring("valuewithunicode")) + Expect(userAgent).ToNot(ContainSubstring("†")) + Expect(userAgent).ToNot(ContainSubstring("•")) + }) + + It("should trim whitespace", func() { + os.Setenv("MULTIAPPS_USER_AGENT_SUFFIX", " trimmed ") + userAgent := util.BuildUserAgent() + + Expect(userAgent).To(ContainSubstring("trimmed")) + Expect(userAgent).ToNot(ContainSubstring(" trimmed ")) + }) + + It("should handle empty value", func() { + os.Setenv("MULTIAPPS_USER_AGENT_SUFFIX", "") + userAgent := util.BuildUserAgent() + + // Should only contain the base user agent without suffix + expectedBase := fmt.Sprintf("Multiapps-CF-plugin/1.0.0 (%s %s) %s", runtime.GOOS, runtime.GOARCH, runtime.Version()) + Expect(userAgent).To(Equal(expectedBase)) + }) + + It("should handle only whitespace", func() { + os.Setenv("MULTIAPPS_USER_AGENT_SUFFIX", " \t\r\n ") + userAgent := util.BuildUserAgent() + + // Should only contain the base user agent without suffix (whitespace gets trimmed to empty) + expectedBase := fmt.Sprintf("Multiapps-CF-plugin/1.0.0 (%s %s) %s", runtime.GOOS, runtime.GOARCH, runtime.Version()) + Expect(userAgent).To(Equal(expectedBase)) + }) + + It("should truncate excessively long values", func() { + longValue := strings.Repeat("a", 600) // 600 chars, should be truncated to 128 + os.Setenv("MULTIAPPS_USER_AGENT_SUFFIX", longValue) + userAgent := util.BuildUserAgent() + + // Should contain truncated suffix + expectedSuffix := strings.Repeat("a", 128) + Expect(userAgent).To(ContainSubstring(expectedSuffix)) + // But not the full original value + Expect(userAgent).ToNot(ContainSubstring(longValue)) + }) + }) + + Context("with maximum length suffix", func() { + BeforeEach(func() { + util.SetPluginVersion("1.0.0") + longSuffix := strings.Repeat("a", 128) + os.Setenv("MULTIAPPS_USER_AGENT_SUFFIX", longSuffix) + }) + + It("should not exceed reasonable total length", func() { + userAgent := util.BuildUserAgent() + + const maxTotalLength = 1024 + Expect(len(userAgent)).To(BeNumerically("<=", maxTotalLength)) + }) + + It("should not contain dangerous characters", func() { + userAgent := util.BuildUserAgent() + + Expect(userAgent).ToNot(ContainSubstring("\r")) + Expect(userAgent).ToNot(ContainSubstring("\n")) + }) + + It("should not contain dangerous colon characters except in Go version", func() { + userAgent := util.BuildUserAgent() + + if strings.Contains(userAgent, ":") { + Expect(userAgent).To(ContainSubstring("go1.")) + } + }) + }) + }) + + Describe("SetPluginVersion", func() { + var originalVersion string + + BeforeEach(func() { + originalVersion = util.GetPluginVersion() + }) + + AfterEach(func() { + util.SetPluginVersion(originalVersion) + }) + + It("should set the plugin version correctly", func() { + testVersion := "1.2.3-test" + util.SetPluginVersion(testVersion) + + Expect(util.GetPluginVersion()).To(Equal(testVersion)) + }) + + It("should be used in BuildUserAgent", func() { + testVersion := "1.2.3-test" + util.SetPluginVersion(testVersion) + + userAgent := util.BuildUserAgent() + expectedPrefix := fmt.Sprintf("Multiapps-CF-plugin/%s", testVersion) + Expect(userAgent).To(HavePrefix(expectedPrefix)) + }) + }) + + Describe("GetPluginVersion", func() { + var originalVersion string + + BeforeEach(func() { + originalVersion = util.GetPluginVersion() + }) + + AfterEach(func() { + util.SetPluginVersion(originalVersion) + }) + + It("should return the current version", func() { + testVersion := "2.4.6" + util.SetPluginVersion(testVersion) + + Expect(util.GetPluginVersion()).To(Equal(testVersion)) + }) + + It("should return default version initially", func() { + util.SetPluginVersion("0.0.0") + + Expect(util.GetPluginVersion()).To(Equal("0.0.0")) + }) + }) +}) From 18d0be307efd0e7c6ff5620ab58bc760e8d6f322 Mon Sep 17 00:00:00 2001 From: theghost5800 Date: Wed, 27 Aug 2025 14:28:45 +0300 Subject: [PATCH 2/2] Add cf cli version to user agent header JIRA:LMCROSSITXSADEPLOY-2854 --- multiapps_plugin.go | 6 +++ util/user_agent_builder.go | 17 ++++++- util/user_agent_builder_test.go | 88 +++++++++++++++++++++++++++++++-- 3 files changed, 106 insertions(+), 5 deletions(-) diff --git a/multiapps_plugin.go b/multiapps_plugin.go index 2ed8c19..6be8166 100644 --- a/multiapps_plugin.go +++ b/multiapps_plugin.go @@ -43,6 +43,12 @@ func (p *MultiappsPlugin) Run(cliConnection plugin.CliConnection, args []string) if err != nil { log.Fatalln(err) } + versionOutput, err := cliConnection.CliCommandWithoutTerminalOutput("version") + if err != nil { + log.Traceln(err) + versionOutput = []string{util.DefaultCliVersion} + } + util.SetCfCliVersion(strings.Join(versionOutput, " ")) util.SetPluginVersion(Version) command.Initialize(command.GetPluginCommand().Name, cliConnection) status := command.Execute(args[1:]) diff --git a/util/user_agent_builder.go b/util/user_agent_builder.go index f60ecb6..5c81611 100644 --- a/util/user_agent_builder.go +++ b/util/user_agent_builder.go @@ -10,28 +10,43 @@ import ( // Limit for MULTIAPPS_USER_AGENT_SUFFIX const maxSuffixLength = 128 +const DefaultCliVersion = "unknown-cf cli version" // pluginVersion stores the version set from the main package var pluginVersion string = "0.0.0" +// cfCliVersion stores the version set from the main package +var cfCliVersion string = DefaultCliVersion + // SetPluginVersion sets the plugin version for use in User-Agent func SetPluginVersion(version string) { pluginVersion = version } +// SetCfCliVersion sets the cf CLI version for use in User-Agent +func SetCfCliVersion(version string) { + cfCliVersion = version +} + // GetPluginVersion returns the current plugin version func GetPluginVersion() string { return pluginVersion } +// GetCfCliVersion returns the current cf CLI version +func GetCfCliVersion() string { + return cfCliVersion +} + // BuildUserAgent creates a User-Agent string in the format: // "Multiapps-CF-plugin/{version} ({operating system version}) {golang builder version} {custom_env_value}" func BuildUserAgent() string { osInformation := getOperatingSystemInformation() goVersion := runtime.Version() + cfCliVersion := GetCfCliVersion() customValue := getCustomEnvValue() - userAgent := fmt.Sprintf("Multiapps-CF-plugin/%s (%s) %s", pluginVersion, osInformation, goVersion) + userAgent := fmt.Sprintf("Multiapps-CF-plugin/%s (%s) %s (%s)", pluginVersion, osInformation, goVersion, cfCliVersion) if customValue != "" { userAgent = fmt.Sprintf("%s %s", userAgent, customValue) diff --git a/util/user_agent_builder_test.go b/util/user_agent_builder_test.go index 3baaf60..06e00db 100644 --- a/util/user_agent_builder_test.go +++ b/util/user_agent_builder_test.go @@ -3,6 +3,7 @@ package util_test import ( "fmt" "os" + "regexp" "runtime" "strings" @@ -15,15 +16,18 @@ var _ = Describe("UserAgentBuilder", func() { Describe("BuildUserAgent", func() { var originalVersion string + var originalCfCliVersion string BeforeEach(func() { - // Save original version for restoration + // Save original versions for restoration originalVersion = util.GetPluginVersion() + originalCfCliVersion = util.GetCfCliVersion() }) AfterEach(func() { - // Restore original version and clean up environment + // Restore original versions and clean up environment util.SetPluginVersion(originalVersion) + util.SetCfCliVersion(originalCfCliVersion) os.Unsetenv("MULTIAPPS_USER_AGENT_SUFFIX") }) @@ -73,6 +77,29 @@ var _ = Describe("UserAgentBuilder", func() { }) }) + Context("with custom CF CLI version", func() { + BeforeEach(func() { + util.SetPluginVersion("1.0.0") + util.SetCfCliVersion("8.5.0") + }) + + It("should contain the CF CLI version in parentheses", func() { + userAgent := util.BuildUserAgent() + + Expect(userAgent).To(ContainSubstring("(8.5.0)")) + Expect(userAgent).To(ContainSubstring("Multiapps-CF-plugin/1.0.0")) + }) + + It("should have correct format with CF CLI version", func() { + userAgent := util.BuildUserAgent() + + // Expected format: "Multiapps-CF-plugin/{version} ({os} {arch}) {go version} ({cf cli version})" + expectedPattern := fmt.Sprintf("Multiapps-CF-plugin/1.0.0 \\(%s %s\\) %s \\(8.5.0\\)", runtime.GOOS, runtime.GOARCH, regexp.QuoteMeta(runtime.Version())) + matched, _ := regexp.MatchString(expectedPattern, userAgent) + Expect(matched).To(BeTrue(), fmt.Sprintf("User agent '%s' should match pattern '%s'", userAgent, expectedPattern)) + }) + }) + Context("with custom environment value", func() { BeforeEach(func() { util.SetPluginVersion("3.6.0") @@ -136,7 +163,7 @@ var _ = Describe("UserAgentBuilder", func() { userAgent := util.BuildUserAgent() // Should only contain the base user agent without suffix - expectedBase := fmt.Sprintf("Multiapps-CF-plugin/1.0.0 (%s %s) %s", runtime.GOOS, runtime.GOARCH, runtime.Version()) + expectedBase := fmt.Sprintf("Multiapps-CF-plugin/1.0.0 (%s %s) %s (unknown-cf cli version)", runtime.GOOS, runtime.GOARCH, runtime.Version()) Expect(userAgent).To(Equal(expectedBase)) }) @@ -145,7 +172,7 @@ var _ = Describe("UserAgentBuilder", func() { userAgent := util.BuildUserAgent() // Should only contain the base user agent without suffix (whitespace gets trimmed to empty) - expectedBase := fmt.Sprintf("Multiapps-CF-plugin/1.0.0 (%s %s) %s", runtime.GOOS, runtime.GOARCH, runtime.Version()) + expectedBase := fmt.Sprintf("Multiapps-CF-plugin/1.0.0 (%s %s) %s (unknown-cf cli version)", runtime.GOOS, runtime.GOARCH, runtime.Version()) Expect(userAgent).To(Equal(expectedBase)) }) @@ -245,4 +272,57 @@ var _ = Describe("UserAgentBuilder", func() { Expect(util.GetPluginVersion()).To(Equal("0.0.0")) }) }) + + Describe("SetCfCliVersion", func() { + var originalVersion string + + BeforeEach(func() { + originalVersion = util.GetCfCliVersion() + }) + + AfterEach(func() { + util.SetCfCliVersion(originalVersion) + }) + + It("should set the CF CLI version correctly", func() { + testVersion := "8.5.0" + util.SetCfCliVersion(testVersion) + + Expect(util.GetCfCliVersion()).To(Equal(testVersion)) + }) + + It("should be used in BuildUserAgent", func() { + testVersion := "8.5.0" + util.SetCfCliVersion(testVersion) + util.SetPluginVersion("1.0.0") + + userAgent := util.BuildUserAgent() + Expect(userAgent).To(ContainSubstring(fmt.Sprintf("(%s)", testVersion))) + }) + }) + + Describe("GetCfCliVersion", func() { + var originalVersion string + + BeforeEach(func() { + originalVersion = util.GetCfCliVersion() + }) + + AfterEach(func() { + util.SetCfCliVersion(originalVersion) + }) + + It("should return the current CF CLI version", func() { + testVersion := "8.6.0" + util.SetCfCliVersion(testVersion) + + Expect(util.GetCfCliVersion()).To(Equal(testVersion)) + }) + + It("should return default CF CLI version initially", func() { + util.SetCfCliVersion(util.DefaultCliVersion) + + Expect(util.GetCfCliVersion()).To(Equal(util.DefaultCliVersion)) + }) + }) })