From 951c63544dd98bf9988e8e66b6a85d6ab52c3316 Mon Sep 17 00:00:00 2001 From: Aniket Date: Wed, 30 Sep 2026 01:22:21 -0700 Subject: [PATCH 1/2] Add helm-session header to Kubernetes API requests Every Helm CLI invocation now stamps a helm-session header carrying a 128-bit session ID on its Kubernetes API requests, so administrators can correlate all requests from a single command execution for auditing. The ID is generated once per Kubernetes client build (not once per process), so SDK clients building a client per operation get distinct sessions. It is logged at debug level so a user can quote it to their cluster admin, who will see the same value in the audit log. The wrapper sits outside the retry wrapper so retried requests keep the same session ID. Scoped to Kubernetes API requests; chart and registry downloads are untouched. Signed-off-by: Aniket --- pkg/cli/environment.go | 16 ++++++- pkg/kubeenv/session.go | 58 +++++++++++++++++++++++ pkg/kubeenv/session_test.go | 93 +++++++++++++++++++++++++++++++++++++ 3 files changed, 166 insertions(+), 1 deletion(-) create mode 100644 pkg/kubeenv/session.go create mode 100644 pkg/kubeenv/session_test.go diff --git a/pkg/cli/environment.go b/pkg/cli/environment.go index b0463d01f..6e09091f1 100644 --- a/pkg/cli/environment.go +++ b/pkg/cli/environment.go @@ -24,6 +24,7 @@ These dependencies are expressed as interfaces so that alternate implementations package cli import ( + "log/slog" "net/http" "os" "strconv" @@ -133,8 +134,21 @@ func New() *EnvSettings { WrapConfigFn: func(config *rest.Config) *rest.Config { config.Burst = env.BurstLimit config.QPS = env.QPS + // One session ID per client build: a CLI invocation builds one + // client, so all requests from a single command execution share a + // session, while SDK clients building a client per operation get + // distinct sessions. Logged at debug level so a user can quote it + // to their cluster admin, who will see the same value in the + // helm-session request header in the Kubernetes audit log. + sessionID := kubeenv.NewSessionID() + slog.Debug("created Kubernetes client session", "helm-session", sessionID) config.Wrap(func(rt http.RoundTripper) http.RoundTripper { - return &kubeenv.RetryingRoundTripper{Wrapped: rt} + return &kubeenv.SessionRoundTripper{ + // Session wrapper stays outside the retry wrapper so + // retries keep the same session ID. + Wrapped: &kubeenv.RetryingRoundTripper{Wrapped: rt}, + SessionID: sessionID, + } }) config.UserAgent = version.GetUserAgent() return config diff --git a/pkg/kubeenv/session.go b/pkg/kubeenv/session.go new file mode 100644 index 000000000..8bef7c1aa --- /dev/null +++ b/pkg/kubeenv/session.go @@ -0,0 +1,58 @@ +/* +Copyright The Helm 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 kubeenv + +import ( + "crypto/rand" + "encoding/hex" + "net/http" + "strconv" + "time" +) + +// SessionHeader is the HTTP header Helm sends on Kubernetes API requests so +// requests from a single command execution can be correlated for auditing. +const SessionHeader = "helm-session" + +// SessionRoundTripper sets the SessionHeader header carrying SessionID on +// every request sent through the wrapped [http.RoundTripper]. +type SessionRoundTripper struct { + Wrapped http.RoundTripper + SessionID string +} + +// RoundTrip implements [http.RoundTripper]. +func (rt *SessionRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + // Clone the request: a RoundTripper must not mutate the request it is given. + r := req.Clone(req.Context()) + r.Header.Set(SessionHeader, rt.SessionID) + return rt.Wrapped.RoundTrip(r) +} + +// NewSessionID generates a fresh 128-bit session identifier. Callers should +// generate one ID per Kubernetes client they build: a Helm CLI invocation +// builds one client, so all requests from a single command execution share a +// session, while SDK clients building a client per operation get distinct +// sessions instead of sharing a process-global one. +func NewSessionID() string { + var b [16]byte + if _, err := rand.Read(b[:]); err == nil { + return hex.EncodeToString(b[:]) + } + // crypto/rand effectively never fails; fall back to a timestamp-based value. + return "fallback-" + strconv.FormatInt(time.Now().UnixNano(), 16) +} diff --git a/pkg/kubeenv/session_test.go b/pkg/kubeenv/session_test.go new file mode 100644 index 000000000..0ffbefed0 --- /dev/null +++ b/pkg/kubeenv/session_test.go @@ -0,0 +1,93 @@ +/* +Copyright The Helm 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 kubeenv + +import ( + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// captureRoundTripper records the request it receives and returns a canned response. +type captureRoundTripper struct { + got *http.Request +} + +func (c *captureRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + c.got = req + return &http.Response{StatusCode: http.StatusOK, Header: http.Header{}}, nil +} + +func TestSessionRoundTripper(t *testing.T) { + tests := []struct { + name string + sessionID string + }{ + {name: "sets the session header", sessionID: "test-session-id"}, + {name: "sets the generated session ID", sessionID: NewSessionID()}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + capture := &captureRoundTripper{} + rt := &SessionRoundTripper{Wrapped: capture, SessionID: tt.sessionID} + + req, err := http.NewRequest(http.MethodGet, "https://example.com/api", nil) + require.NoError(t, err) + // A pre-existing header with the same name must be replaced, not duplicated. + req.Header.Set(SessionHeader, "stale") + + _, err = rt.RoundTrip(req) + require.NoError(t, err) + require.NotNil(t, capture.got) + + assert.Equal(t, tt.sessionID, capture.got.Header.Get(SessionHeader)) + assert.Len(t, capture.got.Header.Values(SessionHeader), 1) + // The caller's request must not be mutated. + assert.Equal(t, "stale", req.Header.Get(SessionHeader)) + }) + } +} + +func TestSessionRoundTripper_StableAcrossRequests(t *testing.T) { + capture := &captureRoundTripper{} + rt := &SessionRoundTripper{Wrapped: capture, SessionID: NewSessionID()} + + var first string + for range 3 { + req, err := http.NewRequest(http.MethodGet, "https://example.com/api", nil) + require.NoError(t, err) + _, err = rt.RoundTrip(req) + require.NoError(t, err) + got := capture.got.Header.Get(SessionHeader) + require.NotEmpty(t, got) + if first == "" { + first = got + } else { + assert.Equal(t, first, got, "session ID must be stable across requests") + } + } +} + +func TestNewSessionID_UniqueAndNonEmpty(t *testing.T) { + id1 := NewSessionID() + id2 := NewSessionID() + assert.NotEmpty(t, id1) + assert.NotEqual(t, id1, id2, "NewSessionID must return a fresh ID per call") +} From 2f65e55e8c08772c07282f61b6921223f4a7a9a1 Mon Sep 17 00:00:00 2001 From: Aniket Date: Thu, 1 Oct 2026 11:17:01 -0700 Subject: [PATCH 2/2] Scope helm-session ID to the CLI invocation Generate the session ID once per EnvSettings instead of once per Kubernetes client build. A single command (e.g. helm upgrade) builds several clients, so per-build IDs produced a different session per client and defeated request correlation. One settings instance now means one session, while separate settings (distinct SDK operations) still get distinct sessions. Signed-off-by: Aniket --- pkg/cli/environment.go | 22 +++++++---- pkg/cli/environment_test.go | 75 +++++++++++++++++++++++++++++++++---- pkg/kubeenv/session.go | 8 ++-- 3 files changed, 85 insertions(+), 20 deletions(-) diff --git a/pkg/cli/environment.go b/pkg/cli/environment.go index 6e09091f1..037699495 100644 --- a/pkg/cli/environment.go +++ b/pkg/cli/environment.go @@ -29,6 +29,7 @@ import ( "os" "strconv" "strings" + "sync" "github.com/spf13/pflag" "k8s.io/cli-runtime/pkg/genericclioptions" @@ -119,6 +120,16 @@ func New() *EnvSettings { } env.Debug, _ = strconv.ParseBool(os.Getenv("HELM_DEBUG")) + // One session ID per settings instance: a CLI invocation creates a single + // EnvSettings, so every Kubernetes client built while running one command + // shares the same session and all of its API requests can be correlated + // in the Kubernetes audit log. SDK clients performing distinct operations + // with separate settings get distinct sessions. Logged once at debug + // level so a user can quote it to their cluster admin, who will see the + // same value in the helm-session request header. + sessionID := kubeenv.NewSessionID() + var logSessionOnce sync.Once + // bind to kubernetes config flags config := &genericclioptions.ConfigFlags{ Namespace: &env.namespace, @@ -134,14 +145,9 @@ func New() *EnvSettings { WrapConfigFn: func(config *rest.Config) *rest.Config { config.Burst = env.BurstLimit config.QPS = env.QPS - // One session ID per client build: a CLI invocation builds one - // client, so all requests from a single command execution share a - // session, while SDK clients building a client per operation get - // distinct sessions. Logged at debug level so a user can quote it - // to their cluster admin, who will see the same value in the - // helm-session request header in the Kubernetes audit log. - sessionID := kubeenv.NewSessionID() - slog.Debug("created Kubernetes client session", "helm-session", sessionID) + logSessionOnce.Do(func() { + slog.Debug("created Kubernetes client session", "helm-session", sessionID) + }) config.Wrap(func(rt http.RoundTripper) http.RoundTripper { return &kubeenv.SessionRoundTripper{ // Session wrapper stays outside the retry wrapper so diff --git a/pkg/cli/environment_test.go b/pkg/cli/environment_test.go index fc71038f8..408649812 100644 --- a/pkg/cli/environment_test.go +++ b/pkg/cli/environment_test.go @@ -17,6 +17,7 @@ limitations under the License. package cli import ( + "net/http" "os" "path/filepath" "strings" @@ -25,8 +26,10 @@ import ( "github.com/spf13/pflag" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "k8s.io/client-go/rest" "helm.sh/helm/v4/internal/version" + "helm.sh/helm/v4/pkg/kubeenv" ) func TestSetNamespace(t *testing.T) { @@ -223,6 +226,69 @@ func TestUserAgentHeaderInK8sRESTClientConfig(t *testing.T) { cleanup := resetEnv() t.Cleanup(cleanup) + t.Setenv("KUBECONFIG", writeTestKubeconfig(t)) + + settings := New() + restConfig, err := settings.RESTClientGetter().ToRESTConfig() + require.NoError(t, err) + + expectedUserAgent := version.GetUserAgent() + assert.Equal(t, expectedUserAgent, restConfig.UserAgent) +} + +func TestSessionIDStableAcrossClientBuilds(t *testing.T) { + cleanup := resetEnv() + t.Cleanup(cleanup) + + t.Setenv("KUBECONFIG", writeTestKubeconfig(t)) + + settings := New() + + // A single command (e.g. helm upgrade) builds several Kubernetes clients; + // every one of them must carry the same session ID. + var ids []string + for i := 0; i < 5; i++ { + restConfig, err := settings.RESTClientGetter().ToRESTConfig() + require.NoError(t, err) + ids = append(ids, sessionIDFromRESTConfig(t, restConfig)) + } + require.NotEmpty(t, ids[0], "session ID must be set on the request") + for _, id := range ids[1:] { + assert.Equal(t, ids[0], id, "all clients built from one settings must share a session") + } + + // A separate settings instance (another command invocation, or an SDK + // client performing a distinct operation) gets a distinct session. + other := New() + otherConfig, err := other.RESTClientGetter().ToRESTConfig() + require.NoError(t, err) + assert.NotEqual(t, ids[0], sessionIDFromRESTConfig(t, otherConfig), + "separate settings must not share a session") +} + +// sessionIDFromRESTConfig applies the config's transport wrappers to a +// capturing RoundTripper and returns the helm-session header that would be +// sent on a real request. +func sessionIDFromRESTConfig(t *testing.T, restConfig *rest.Config) string { + t.Helper() + var got http.Header + capture := roundTripperFunc(func(req *http.Request) (*http.Response, error) { + got = req.Header + return &http.Response{StatusCode: http.StatusOK}, nil + }) + req, err := http.NewRequest(http.MethodGet, "https://127.0.0.1:6443/version", nil) + require.NoError(t, err) + _, err = restConfig.WrapTransport(capture).RoundTrip(req) + require.NoError(t, err) + return got.Get(kubeenv.SessionHeader) +} + +type roundTripperFunc func(*http.Request) (*http.Response, error) + +func (f roundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error) { return f(req) } + +func writeTestKubeconfig(t *testing.T) string { + t.Helper() kubeconfigPath := filepath.Join(t.TempDir(), "config") kubeconfig := `apiVersion: v1 clusters: @@ -243,14 +309,7 @@ users: token: test-token ` require.NoError(t, os.WriteFile(kubeconfigPath, []byte(kubeconfig), 0o600), "failed to create test kubeconfig") - t.Setenv("KUBECONFIG", kubeconfigPath) - - settings := New() - restConfig, err := settings.RESTClientGetter().ToRESTConfig() - require.NoError(t, err) - - expectedUserAgent := version.GetUserAgent() - assert.Equal(t, expectedUserAgent, restConfig.UserAgent) + return kubeconfigPath } func resetEnv() func() { diff --git a/pkg/kubeenv/session.go b/pkg/kubeenv/session.go index 8bef7c1aa..0305e7bd2 100644 --- a/pkg/kubeenv/session.go +++ b/pkg/kubeenv/session.go @@ -44,10 +44,10 @@ func (rt *SessionRoundTripper) RoundTrip(req *http.Request) (*http.Response, err } // NewSessionID generates a fresh 128-bit session identifier. Callers should -// generate one ID per Kubernetes client they build: a Helm CLI invocation -// builds one client, so all requests from a single command execution share a -// session, while SDK clients building a client per operation get distinct -// sessions instead of sharing a process-global one. +// generate one ID per logical operation: the Helm CLI creates a single +// [cli.EnvSettings] per command invocation, so every Kubernetes client built +// while running one command shares a session, while separate settings (e.g. +// SDK clients performing distinct operations) get distinct sessions. func NewSessionID() string { var b [16]byte if _, err := rand.Read(b[:]); err == nil {