Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<BOOLEAN>` - 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=<BOOLEAN>` - 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=<STRING>` - 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)
Expand Down
6 changes: 5 additions & 1 deletion clients/baseclient/base_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
58 changes: 58 additions & 0 deletions clients/baseclient/base_client_test.go
Original file line number Diff line number Diff line change
@@ -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")
})
})
})
})
37 changes: 37 additions & 0 deletions clients/baseclient/user_agent_transport.go
Original file line number Diff line number Diff line change
@@ -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)
}
127 changes: 127 additions & 0 deletions clients/baseclient/user_agent_transport_test.go
Original file line number Diff line number Diff line change
@@ -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"))
})
})
})
})
10 changes: 8 additions & 2 deletions clients/cfrestclient/rest_cloud_foundry_client_extended.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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
Expand Down
12 changes: 11 additions & 1 deletion commands/base_command.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{} {
Expand Down
86 changes: 86 additions & 0 deletions commands/csrf_transport_test.go
Original file line number Diff line number Diff line change
@@ -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/'")
})
})
})
})
8 changes: 8 additions & 0 deletions multiapps_plugin.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -42,6 +43,13 @@ 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:])
if status == commands.Failure {
Expand Down
Loading