From e0339d67f0fc8541922a96dbf341564cde1ba11a Mon Sep 17 00:00:00 2001 From: Wouter van Os Date: Wed, 29 Jul 2026 11:11:16 +0200 Subject: [PATCH] Allow X-Stream-Protocol-Version so SPDY streaming works The header allowlist permits the Sec-Websocket-* handshake headers but not X-Stream-Protocol-Version, so every request on the SPDY streaming transport had its protocol negotiation header stripped and was rejected by the API server with: unable to upgrade connection: unable to upgrade: X-Stream-Protocol-Version is required This breaks kubectl exec, attach and cp whenever the WebSocket transport is not used: clients older than 1.30, clients with KUBECTL_REMOTE_COMMAND_WEBSOCKETS=false, and clients that fall back to SPDY because a proxy-url is configured. It also breaks kubectl port-forward against API servers without WebSocket port-forward support, since SPDY is the only transport left there. The failure is easy to misread, because kubectl attempts WebSocket first and falls back to SPDY: the real error from the first attempt is discarded and the API server answers the SPDY request with a 400 whose body is not a Status object, which kubectl renders as a bare "Error from server:" with no message. --- internal/proxy/proxy.go | 2 ++ internal/proxy/proxy_test.go | 14 +++++++++++--- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/internal/proxy/proxy.go b/internal/proxy/proxy.go index 89e2c26..7e5d3ef 100644 --- a/internal/proxy/proxy.go +++ b/internal/proxy/proxy.go @@ -40,6 +40,7 @@ const ( SecWebsocketVersionHeader = "Sec-Websocket-Version" SecWebsocketProtocolHeader = "Sec-Websocket-Protocol" SecWebsocketExtensionsHeader = "Sec-Websocket-Extensions" + StreamProtocolVersionHeader = "X-Stream-Protocol-Version" KubectlCommandHeader = "Kubectl-Command" KubectlSessionHeader = "Kubectl-Session" @@ -135,6 +136,7 @@ func proxyHandler(peerStore *PeerStore, kubeAPIServerURL *url.URL, certPool *x50 SecWebsocketVersionHeader: nil, SecWebsocketProtocolHeader: nil, SecWebsocketExtensionsHeader: nil, + StreamProtocolVersionHeader: nil, } for k, v := range req.Header { if _, ok := allowedHeaders[k]; !ok { diff --git a/internal/proxy/proxy_test.go b/internal/proxy/proxy_test.go index c8aea41..90dd88d 100644 --- a/internal/proxy/proxy_test.go +++ b/internal/proxy/proxy_test.go @@ -70,7 +70,7 @@ func TestProxyHandler(t *testing.T) { rw.WriteHeader(http.StatusNotFound) return } - body := fmt.Sprintf("%s %s %s", req.Header[AuthorizationHeader], req.Header[ImpersonateUserHeader], req.Header[ImpersonateGroupHeader]) + body := fmt.Sprintf("%s %s %s %s", req.Header[AuthorizationHeader], req.Header[ImpersonateUserHeader], req.Header[ImpersonateGroupHeader], req.Header[StreamProtocolVersionHeader]) // nolint: errcheck rw.Write([]byte(body)) })) @@ -92,7 +92,7 @@ func TestProxyHandler(t *testing.T) { name: "valid peer", headers: nil, expectedStatus: http.StatusOK, - expectedBody: "[Bearer foobar] [foo] [group1 group2]", + expectedBody: "[Bearer foobar] [foo] [group1 group2] []", }, { name: "valid peer with bearer token", @@ -100,7 +100,15 @@ func TestProxyHandler(t *testing.T) { AuthorizationHeader: "Bearer testtest", }, expectedStatus: http.StatusOK, - expectedBody: "[Bearer foobar] [foo] [group1 group2]", + expectedBody: "[Bearer foobar] [foo] [group1 group2] []", + }, + { + name: "valid peer with stream protocol version", + headers: map[string]string{ + StreamProtocolVersionHeader: "v5.channel.k8s.io", + }, + expectedStatus: http.StatusOK, + expectedBody: "[Bearer foobar] [foo] [group1 group2] [v5.channel.k8s.io]", }, { name: "no peer found",