Skip to content
75 changes: 72 additions & 3 deletions backend/cmd/headlamp.go
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,11 @@ var maxProxyResponseSize int64 = 100 * 1024 * 1024
//nolint:gochecknoglobals // allow test override
var externalProxyTimeout = 30 * time.Second

//nolint:gochecknoglobals // shared client preserves connection pooling for external proxy requests
var externalProxyHTTPClient = &http.Client{
Transport: newExternalProxyTransport(),
Comment thread
ayushmaan-16 marked this conversation as resolved.
}

const kubeConfigSource = "kubeconfig" // source for kubeconfig contexts

const (
Expand Down Expand Up @@ -631,7 +636,17 @@ func createHeadlampHandler(ctx context.Context, config *HeadlampConfig) http.Han

// We may want to filter some headers, otherwise we could just use a shallow copy
proxyReq.Header = make(http.Header)
connectionHeaderTokens := externalProxyConnectionHeaderTokens(r.Header)

for h, val := range r.Header {
if shouldFilterExternalProxyRequestHeader(h) {
continue
}

Comment thread
ayushmaan-16 marked this conversation as resolved.
if _, ok := connectionHeaderTokens[strings.ToLower(h)]; ok {
continue
}

proxyReq.Header[h] = val
Comment thread
ayushmaan-16 marked this conversation as resolved.
}

Expand All @@ -641,9 +656,7 @@ func createHeadlampHandler(ctx context.Context, config *HeadlampConfig) http.Han
w.Header().Set("Pragma", "no-cache")
w.Header().Set("X-Accel-Expires", "0")

client := http.Client{}

resp, err := client.Do(proxyReq) //nolint:gosec
resp, err := externalProxyHTTPClient.Do(proxyReq) //nolint:gosec
if err != nil {
logger.Log(logger.LevelError, nil, err, "making request")
http.Error(w, err.Error(), http.StatusBadGateway)
Expand Down Expand Up @@ -1170,6 +1183,62 @@ func isLoopbackAddr(addr string) bool {
return ip != nil && ip.IsLoopback()
}

func shouldFilterExternalProxyRequestHeader(headerName string) bool {
hLower := strings.ToLower(headerName)

if hLower == "authorization" ||
hLower == "cookie" ||
hLower == "accept-encoding" ||
strings.HasPrefix(hLower, "x-headlamp-") ||
strings.HasPrefix(hLower, "x-headlamp_") {
return true
}
Comment thread
ayushmaan-16 marked this conversation as resolved.

switch hLower {
case "connection",
"forward-to",
"keep-alive",
"proxy-connection",
"proxy-to",
"te",
"trailer",
"transfer-encoding",
"upgrade":
return true
default:
return false
}
Comment thread
ayushmaan-16 marked this conversation as resolved.
}

func externalProxyConnectionHeaderTokens(headers http.Header) map[string]struct{} {
tokens := map[string]struct{}{}

for _, headerValue := range headers.Values("Connection") {
for _, token := range strings.Split(headerValue, ",") {
token = strings.ToLower(strings.TrimSpace(token))
if token != "" {
tokens[token] = struct{}{}
}
}
}

return tokens
}

func newExternalProxyTransport() http.RoundTripper {
defaultTransport, ok := http.DefaultTransport.(*http.Transport)
if !ok {
return &http.Transport{
DisableCompression: true,
}
}

transport := defaultTransport.Clone()
transport.DisableCompression = true

return transport
}

// allowedHosts returns the set of normalized host values that are considered
// valid for the given listen address and port. All entries are lowercased and
// host:port pairs are built with net.JoinHostPort so that IPv6 literals are
Expand Down
127 changes: 127 additions & 0 deletions backend/cmd/headlamp_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import (
"runtime"
"strconv"
"strings"
"sync"
"testing"
"time"

Expand Down Expand Up @@ -2790,3 +2791,129 @@ func TestExternalProxyOversizeResponseGzip(t *testing.T) {
assert.Equal(t, http.StatusOK, rr.Code)
assert.Equal(t, int(maxProxyResponseSize), rr.Body.Len())
}

func assertHeadersEmpty(t *testing.T, headers http.Header, headerNames ...string) {
t.Helper()

for _, headerName := range headerNames {
assert.Empty(t, headers.Get(headerName), "%s header should be filtered", headerName)
}
}

func assertHeaderPrefixAbsent(t *testing.T, headers http.Header, prefix string) {
t.Helper()

for h := range headers {
if strings.HasPrefix(strings.ToUpper(h), prefix) {
t.Errorf("Header %s should have been filtered", h)
}
}
}

func newExternalProxyHeaderFilteringHandler(
t *testing.T,
) (http.Handler, *url.URL, *httptest.Server, func() http.Header) {
t.Helper()

var mu sync.Mutex

var receivedHeaders http.Header

proxyTarget := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
receivedHeaders = r.Header.Clone()
mu.Unlock()

Comment thread
ayushmaan-16 marked this conversation as resolved.
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("OK"))
}))

proxyURL, err := url.Parse(proxyTarget.URL)
if err != nil {
t.Fatal(err)
}

cache := cache.New[interface{}]()
kubeConfigStore := kubeconfig.NewContextStore()

c := &HeadlampConfig{
HeadlampConfig: &headlampconfig.HeadlampConfig{
HeadlampCFG: &headlampconfig.HeadlampCFG{
UseInCluster: false,
ProxyURLs: []string{proxyURL.String()},
KubeConfigStore: kubeConfigStore,
},
Cache: cache,
},
}

receivedHeadersSnapshot := func() http.Header {
mu.Lock()
defer mu.Unlock()

return receivedHeaders.Clone()
}

return createHeadlampHandler(context.Background(), c), proxyURL, proxyTarget, receivedHeadersSnapshot
}

func TestExternalProxyHeaderFiltering(t *testing.T) {
handler, proxyURL, proxyTarget, receivedHeadersSnapshot := newExternalProxyHeaderFilteringHandler(t)
defer proxyTarget.Close()

req, err := http.NewRequestWithContext(context.Background(), "GET", "/externalproxy", nil)
if err != nil {
t.Fatal(err)
}

// Set the proxy-to header (internal routing header that should be filtered)
req.Header.Set("proxy-to", proxyURL.String())
req.Header.Set("Forward-to", "/some/path")

// Set sensitive headers that should be filtered
req.Header.Set("Authorization", "Bearer sensitive-token")
req.Header.Set("Cookie", "session=sensitive-cookie")
// Test hyphenated X-Headlamp-* headers
req.Header.Set("X-Headlamp-Backend-Token", "sensitive-backend-token")
req.Header.Set("X-Headlamp-Custom", "sensitive-custom-header")
// Test an underscore-style X-HEADLAMP_* variant defensively as well.
req.Header.Set("X-HEADLAMP_BACKEND-TOKEN", "sensitive-underscore-token")

// Set transport/protocol headers that should not be forwarded.
req.Header.Set("Accept-Encoding", "br, zstd")
req.Header.Set("Connection", "Upgrade, X-Connection-Only")
req.Header.Set("Keep-Alive", "timeout=5")
req.Header.Set("Proxy-Connection", "keep-alive")
req.Header.Set("TE", "trailers")
req.Header.Set("Trailer", "Expires")
req.Header.Set("Transfer-Encoding", "chunked")
req.Header.Set("Upgrade", "websocket")
req.Header.Set("X-Connection-Only", "drop-me")

// Set a non-sensitive header that should be preserved
req.Header.Set("X-Custom-Preserve", "preserve-me")

rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)

assert.Equal(t, http.StatusOK, rr.Code)

receivedHeaders := receivedHeadersSnapshot()
assert.Equal(t, "preserve-me", receivedHeaders.Get("X-Custom-Preserve"), "Non-sensitive header should be preserved")
assertHeadersEmpty(t, receivedHeaders, "Authorization", "Cookie")
assertHeaderPrefixAbsent(t, receivedHeaders, "X-HEADLAMP-")
assertHeadersEmpty(t, receivedHeaders, "X-HEADLAMP_BACKEND-TOKEN")
assertHeaderPrefixAbsent(t, receivedHeaders, "X-HEADLAMP_")
assertHeadersEmpty(t, receivedHeaders, "proxy-to", "Forward-to")
assertHeadersEmpty(t, receivedHeaders,
"Accept-Encoding",
"Connection",
"Keep-Alive",
"Proxy-Connection",
"TE",
"Trailer",
"Transfer-Encoding",
"Upgrade",
"X-Connection-Only",
)
}
Loading