diff --git a/go.mod b/go.mod index e5fcf6c02..e2b163fb4 100644 --- a/go.mod +++ b/go.mod @@ -53,6 +53,8 @@ require ( sigs.k8s.io/yaml v1.6.0 ) +require github.com/google/uuid v1.6.0 + require ( dario.cat/mergo v1.0.1 // indirect github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect @@ -88,7 +90,6 @@ require ( github.com/google/btree v1.1.3 // indirect github.com/google/gnostic-models v0.7.0 // indirect github.com/google/go-cmp v0.7.0 // indirect - github.com/google/uuid v1.6.0 // indirect github.com/gorilla/handlers v1.5.2 // indirect github.com/gorilla/mux v1.8.1 // indirect github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 // indirect diff --git a/pkg/getter/getter.go b/pkg/getter/getter.go index a2d0f0ee2..3315eab65 100644 --- a/pkg/getter/getter.go +++ b/pkg/getter/getter.go @@ -49,6 +49,7 @@ type getterOptions struct { timeout time.Duration transport *http.Transport artifactType string + sessionHeader bool } // Option allows specifying various settings configurable by the user for overriding the defaults @@ -106,6 +107,11 @@ func WithTLSClientConfig(certFile, keyFile, caFile string) Option { opts.caFile = caFile } } +func WithSessionHeader(enabled bool) Option { + return func(opts *getterOptions) { + opts.sessionHeader = enabled + } +} func WithPlainHTTP(plainHTTP bool) Option { return func(opts *getterOptions) { diff --git a/pkg/getter/httpgetter.go b/pkg/getter/httpgetter.go index 2bc12bdbf..c5943906d 100644 --- a/pkg/getter/httpgetter.go +++ b/pkg/getter/httpgetter.go @@ -24,20 +24,26 @@ import ( "net/url" "sync" + "github.com/google/uuid" + "helm.sh/helm/v4/internal/tlsutil" "helm.sh/helm/v4/internal/version" ) +// helmSessionHeader is used to group HTTP requests initiated +// during a single Helm command execution. +const helmSessionHeader = "helm-session" + // HTTPGetter is the default HTTP(/S) backend handler type HTTPGetter struct { opts getterOptions transport *http.Transport once sync.Once + sessionID string } // Get performs a Get from repo.Getter and returns the body. func (g *HTTPGetter) Get(href string, options ...Option) (*bytes.Buffer, error) { - // Create a local copy of options to avoid data races when Get is called concurrently opts := g.opts for _, opt := range options { opt(&opts) @@ -46,13 +52,18 @@ func (g *HTTPGetter) Get(href string, options ...Option) (*bytes.Buffer, error) } func (g *HTTPGetter) get(href string, opts getterOptions) (*bytes.Buffer, error) { - // Set a helm specific user agent so that a repo server and metrics can - // separate helm calls from other tools interacting with repos. req, err := http.NewRequest(http.MethodGet, href, nil) if err != nil { return nil, err } + // sessionID is generated once per HTTPGetter instance and, + // when sessionHeader is enabled, is sent with each request + // via the helm-session header for request correlation. + if g.sessionID != "" && opts.sessionHeader { + req.Header.Set(helmSessionHeader, g.sessionID) + } + if opts.acceptHeader != "" { req.Header.Set("Accept", opts.acceptHeader) } @@ -62,8 +73,6 @@ func (g *HTTPGetter) get(href string, opts getterOptions) (*bytes.Buffer, error) req.Header.Set("User-Agent", opts.userAgent) } - // Before setting the basic auth credentials, make sure the URL associated - // with the basic auth is the one being fetched. u1, err := url.Parse(opts.url) if err != nil { return nil, fmt.Errorf("unable to parse getter URL: %w", err) @@ -72,10 +81,8 @@ func (g *HTTPGetter) get(href string, opts getterOptions) (*bytes.Buffer, error) if err != nil { return nil, fmt.Errorf("unable to parse URL getting from: %w", err) } - - // Host on URL (returned from url.Parse) contains the port if present. - // This check ensures credentials are not passed between different - // services on different ports. + // Ensure credentials are only sent to the same host and scheme + // to prevent leaking credentials across different services. if opts.passCredentialsAll || (u1.Scheme == u2.Scheme && u1.Host == u2.Host) { if opts.username != "" && opts.password != "" { req.SetBasicAuth(opts.username, opts.password) @@ -92,6 +99,7 @@ func (g *HTTPGetter) get(href string, opts getterOptions) (*bytes.Buffer, error) return nil, err } defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("failed to fetch %s : %s", href, resp.Status) } @@ -109,6 +117,10 @@ func NewHTTPGetter(options ...Option) (Getter, error) { opt(&client.opts) } + // sessionID is generated once per HTTPGetter instance + // and reused across all requests when sessionHeader is enabled. + client.sessionID = uuid.New().String() + return &client, nil } @@ -120,11 +132,9 @@ func (g *HTTPGetter) httpClient(opts getterOptions) (*http.Client, error) { }, nil } - // Check if we need custom TLS configuration needsCustomTLS := (opts.certFile != "" && opts.keyFile != "") || opts.caFile != "" || opts.insecureSkipVerifyTLS if needsCustomTLS { - // Create a new transport for custom TLS to avoid race conditions transport := &http.Transport{ DisableCompression: true, Proxy: http.ProxyFromEnvironment, @@ -147,7 +157,6 @@ func (g *HTTPGetter) httpClient(opts getterOptions) (*http.Client, error) { }, nil } - // Use shared transport for default case (no custom TLS) g.once.Do(func() { g.transport = &http.Transport{ DisableCompression: true, diff --git a/pkg/getter/httpgetter_test.go b/pkg/getter/httpgetter_test.go index 7d4581233..18ab147a0 100644 --- a/pkg/getter/httpgetter_test.go +++ b/pkg/getter/httpgetter_test.go @@ -678,3 +678,38 @@ func TestHTTPTransportOption(t *testing.T) { t.Fatal("transport.TLSClientConfig should not be set") } } + +func TestHTTPGetterSessionHeader(t *testing.T) { + headerChan := make(chan string, 2) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + headerChan <- r.Header.Get(helmSessionHeader) + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + // Create getter for HTTP session header test + g, err := NewHTTPGetter(WithURL(srv.URL), WithSessionHeader(true)) + if err != nil { + t.Fatal(err) + } + + if _, err := g.Get(srv.URL); err != nil { + t.Fatal(err) + } + + if _, err := g.Get(srv.URL); err != nil { + t.Fatal(err) + } + + h1 := <-headerChan + h2 := <-headerChan + + if h1 == "" || h2 == "" { + t.Fatalf("expected %s header to be set", helmSessionHeader) + } + + if h1 != h2 { + t.Errorf("expected same session ID, got %s and %s", h1, h2) + } +}