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
50 changes: 43 additions & 7 deletions backend/cmd/headlamp.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import (
auth "github.com/kubernetes-sigs/headlamp/backend/pkg/auth"
"github.com/kubernetes-sigs/headlamp/backend/pkg/cache"
cfg "github.com/kubernetes-sigs/headlamp/backend/pkg/config"
"github.com/kubernetes-sigs/headlamp/backend/pkg/serviceproxy"

headlampcfg "github.com/kubernetes-sigs/headlamp/backend/pkg/headlampconfig"
"github.com/kubernetes-sigs/headlamp/backend/pkg/helm"
Expand Down Expand Up @@ -293,7 +294,7 @@ func addPluginDeleteRoute(config *HeadlampConfig, r *mux.Router) {

logger.Log(logger.LevelInfo, nil, nil, "Received DELETE request for plugin: "+mux.Vars(r)["name"])

if err := checkHeadlampBackendToken(w, r); err != nil {
if err := config.checkHeadlampBackendToken(w, r); err != nil {
config.telemetryHandler.RecordError(span, err, " Invalid backend token")
logger.Log(logger.LevelWarn, nil, err, "Invalid backend token for DELETE /plugins/{name}")
return
Expand Down Expand Up @@ -623,8 +624,11 @@ func createHeadlampHandler(config *HeadlampConfig) http.Handler {

oidcAuthConfig, err := kContext.OidcConfig()
if err != nil {
logger.Log(logger.LevelError, map[string]string{"cluster": cluster},
err, "failed to get oidc config")
// Avoid the noise in the pod log while accessing Headlamp using Service Token
if config.oidcIdpIssuerURL != "" {
logger.Log(logger.LevelError, map[string]string{"cluster": cluster},
err, "failed to get oidc config")
}

http.Error(w, err.Error(), http.StatusInternalServerError)
return
Expand Down Expand Up @@ -1087,6 +1091,23 @@ func getHelmHandler(c *HeadlampConfig, w http.ResponseWriter, r *http.Request) (
return nil, errors.New("not found")
}

tokenFromCookie, err := auth.GetTokenFromCookie(r, clusterName)

bearerToken := r.Header.Get("Authorization")
if err == nil && tokenFromCookie != "" && bearerToken == "" {
r.Header.Set("Authorization", fmt.Sprintf("Bearer %s", tokenFromCookie))
}

// If the request contains a bearer token in the Authorization header, set it in AuthInfo.
// This token will be used authentication to the Kubernetes cluster.
bearerToken = r.Header.Get("Authorization")
if bearerToken != "" {
reqToken := strings.TrimPrefix(bearerToken, "Bearer ")
if reqToken != "" {
context.AuthInfo.Token = reqToken
}
}

namespace := r.URL.Query().Get("namespace")

helmHandler, err := helm.NewHandler(context.ClientConfig(), c.cache, namespace)
Expand All @@ -1110,7 +1131,11 @@ func getHelmHandler(c *HeadlampConfig, w http.ResponseWriter, r *http.Request) (
// Check request for header "X-HEADLAMP_BACKEND-TOKEN" matches HEADLAMP_BACKEND_TOKEN env
// This check is to prevent access except for from the app.
// The app sets HEADLAMP_BACKEND_TOKEN, and gives the token to the frontend.
func checkHeadlampBackendToken(w http.ResponseWriter, r *http.Request) error {
func (c *HeadlampConfig) checkHeadlampBackendToken(w http.ResponseWriter, r *http.Request) error {
if c.UseInCluster {
return nil
}

backendToken := r.Header.Get("X-HEADLAMP_BACKEND-TOKEN")
backendTokenEnv := os.Getenv("HEADLAMP_BACKEND_TOKEN")

Expand All @@ -1122,6 +1147,16 @@ func checkHeadlampBackendToken(w http.ResponseWriter, r *http.Request) error {
return nil
}

// handleClusterServiceProxy registers a new route for the path serviceproxy/{namespace}/{name}
// to proxy requests to in-cluster services.
func handleClusterServiceProxy(c *HeadlampConfig, router *mux.Router) {
router.HandleFunc("/clusters/{clusterName}/serviceproxy/{namespace}/{name}",
func(w http.ResponseWriter, r *http.Request) {
serviceproxy.RequestHandler(c.KubeConfigStore, w, r)
}).Queries("request", "{request}").
Methods("GET")
}

//nolint:funlen
func handleClusterHelm(c *HeadlampConfig, router *mux.Router) {
router.PathPrefix("/clusters/{clusterName}/helm/{.*}").HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
Expand All @@ -1137,7 +1172,7 @@ func handleClusterHelm(c *HeadlampConfig, router *mux.Router) {

c.telemetryHandler.RecordRequestCount(ctx, r, attribute.String("cluster", clusterName))

if err := checkHeadlampBackendToken(w, r); err != nil {
if err := c.checkHeadlampBackendToken(w, r); err != nil {
c.handleError(w, ctx, span, err, "failed to check headlamp backend token", http.StatusForbidden)

return
Expand Down Expand Up @@ -1399,6 +1434,7 @@ func (c *HeadlampConfig) handleClusterRequests(router *mux.Router) {
handleClusterHelm(c, router)
}

handleClusterServiceProxy(c, router)
handleClusterAPI(c, router)
}

Expand Down Expand Up @@ -1569,7 +1605,7 @@ func (c *HeadlampConfig) addCluster(w http.ResponseWriter, r *http.Request) {
defer recordRequestCompletion(c, ctx, start, r)
c.telemetryHandler.RecordRequestCount(ctx, r)

if err := checkHeadlampBackendToken(w, r); err != nil {
if err := c.checkHeadlampBackendToken(w, r); err != nil {
c.telemetryHandler.RecordError(span, err, "invalid backend token")
c.telemetryHandler.RecordErrorCount(ctx, attribute.String("error.type", "invalid token"))
logger.Log(logger.LevelError, nil, err, "invalid token")
Expand Down Expand Up @@ -1777,7 +1813,7 @@ func (c *HeadlampConfig) deleteCluster(w http.ResponseWriter, r *http.Request) {

name := mux.Vars(r)["name"]

if err := checkHeadlampBackendToken(w, r); err != nil {
if err := c.checkHeadlampBackendToken(w, r); err != nil {
c.telemetryHandler.RecordError(span, err, "invalid backend token")
c.telemetryHandler.RecordErrorCount(ctx, attribute.String("error.type", "invalid_token"))
logger.Log(logger.LevelError, nil, err, "invalid token")
Expand Down
124 changes: 124 additions & 0 deletions backend/cmd/headlamp_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,14 @@ import (
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"time"

Expand All @@ -43,6 +45,8 @@ import (
"github.com/kubernetes-sigs/headlamp/backend/pkg/telemetry"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/tools/clientcmd"
"k8s.io/client-go/tools/clientcmd/api"
)
Expand Down Expand Up @@ -1555,3 +1559,123 @@ func TestCacheMiddleware_CacheInvalidation(t *testing.T) {
assert.Equal(t, "true", resp1.Header.Get("X-HEADLAMP-CACHE"))
assert.Equal(t, http.StatusOK, resp1.StatusCode)
}

//nolint:funlen
func TestHandleClusterServiceProxy(t *testing.T) {
cfg := &HeadlampConfig{
HeadlampCFG: &headlampconfig.HeadlampCFG{KubeConfigStore: kubeconfig.NewContextStore()},
telemetryHandler: &telemetry.RequestHandler{},
telemetryConfig: GetDefaultTestTelemetryConfig(),
}

// Backend service the proxy should call
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/healthz" {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("OK"))

return
}

http.NotFound(w, r)
}))
t.Cleanup(backend.Close)

// Extract host:port to feed into the Service external name + port
bu, err := url.Parse(backend.URL)
require.NoError(t, err)
host, portStr, err := net.SplitHostPort(strings.TrimPrefix(bu.Host, "["))
require.NoError(t, err)
portNum, err := strconv.Atoi(strings.TrimSuffix(portStr, "]"))
require.NoError(t, err)

// Fake k8s API that returns a Service pointing to backend
kubeAPI := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet && r.URL.Path == "/api/v1/namespaces/default/services/my-service" {
svc := &corev1.Service{
ObjectMeta: metav1.ObjectMeta{
Name: "my-service",
Namespace: "default",
},
Spec: corev1.ServiceSpec{
ExternalName: host,
Ports: []corev1.ServicePort{
{
Name: "http",
Port: int32(portNum), //nolint:gosec
},
},
},
}

w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(svc)

return
}

http.NotFound(w, r)
}))
t.Cleanup(kubeAPI.Close)

// Add a context that matches clusterName in URL
err = cfg.KubeConfigStore.AddContext(&kubeconfig.Context{
Name: "kubernetes",
KubeContext: &api.Context{
Cluster: "kubernetes",
AuthInfo: "kubernetes",
},
Cluster: &api.Cluster{Server: kubeAPI.URL}, // client-go will talk to this
AuthInfo: &api.AuthInfo{},
})
require.NoError(t, err)

router := mux.NewRouter()
handleClusterServiceProxy(cfg, router)

cluster := "kubernetes"
ns := "default"
svc := "my-service"

// Case 1: Missing ?request => route doesn't match => 404, no headers set
{
req := httptest.NewRequest(http.MethodGet,
"/clusters/"+cluster+"/serviceproxy/"+ns+"/"+svc, nil)
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
assert.Equal(t, http.StatusNotFound, rr.Code)
assert.Empty(t, rr.Header().Get("Cache-Control"))
}

// Case 2: ?request present but missing Authorization => 401, headers set
{
req := httptest.NewRequest(http.MethodGet,
"/clusters/"+cluster+"/serviceproxy/"+ns+"/"+svc+"?request=/healthz", nil)
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
assert.Equal(t, http.StatusUnauthorized, rr.Code)
assert.Equal(t, "no-cache, private, max-age=0", rr.Header().Get("Cache-Control"))
assert.Equal(t, "no-cache", rr.Header().Get("Pragma"))
assert.Equal(t, "0", rr.Header().Get("X-Accel-Expires"))
}

// Case 3 (Happy path): ?request present and Authorization provided => proxy reaches backend => 200 OK
{
req := httptest.NewRequest(http.MethodGet,
"/clusters/"+cluster+"/serviceproxy/"+ns+"/"+svc+"?request=/healthz", nil)
req.Header.Set("Authorization", "Bearer test-token")

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

// Handler always sets no-cache headers
assert.Equal(t, "no-cache, private, max-age=0", rr.Header().Get("Cache-Control"))
assert.Equal(t, "no-cache", rr.Header().Get("Pragma"))
assert.Equal(t, "0", rr.Header().Get("X-Accel-Expires"))

// Happy path: backend returns OK
assert.Equal(t, http.StatusOK, rr.Code)
assert.Equal(t, "OK", rr.Body.String())
}
}
1 change: 0 additions & 1 deletion backend/pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -335,7 +335,6 @@ func flagset() *flag.FlagSet {
f.String("listen-addr", "", "Address to listen on; default is empty, which means listening to any address")
f.Uint("port", defaultPort, "Port to listen from")
f.String("proxy-urls", "", "Allow proxy requests to specified URLs")
f.Bool("enable-helm", false, "Enable Helm operations")

f.String("oidc-client-id", "", "ClientID for OIDC")
f.String("oidc-client-secret", "", "ClientSecret for OIDC")
Expand Down
7 changes: 0 additions & 7 deletions backend/pkg/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -178,13 +178,6 @@ func TestParseFlags(t *testing.T) {
assert.Equal(t, filepath.Join(getTestDataPath(), "valid_ca.pem"), conf.OidcCAFile)
},
},
{
name: "enable_helm",
args: []string{"go run ./cmd", "--enable-helm"},
verify: func(t *testing.T, conf *config.Config) {
assert.Equal(t, true, conf.EnableHelm)
},
},
}

for _, tt := range tests {
Expand Down
53 changes: 42 additions & 11 deletions backend/pkg/helm/release.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ limitations under the License.
package helm

import (
"context"
"encoding/base64"
"encoding/json"
"errors"
Expand All @@ -36,6 +37,9 @@ import (
"helm.sh/helm/v3/pkg/getter"
"helm.sh/helm/v3/pkg/release"
"helm.sh/helm/v3/pkg/storage/driver"
authv1 "k8s.io/api/authentication/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/kubernetes"
"sigs.k8s.io/yaml"
)

Expand Down Expand Up @@ -576,15 +580,49 @@ func (h *Handler) getChart(
return chart, nil
}

// Verify the user has minimal privileges by performing a whoami check.
// This prevents spurious downloads by ensuring basic authentication before proceeding.
func VerifyUser(h *Handler, req InstallRequest) bool {
restConfig, err := h.Configuration.RESTClientGetter.ToRESTConfig()
if err != nil {
logger.Log(logger.LevelError, map[string]string{"chart": req.Chart, "releaseName": req.Name}, err, "getting chart")
return false
}

cs, err := kubernetes.NewForConfig(restConfig)
if err != nil {
logger.Log(logger.LevelError, map[string]string{"chart": req.Chart, "releaseName": req.Name}, err, "getting chart")
return false
}

review, err := cs.AuthenticationV1().SelfSubjectReviews().Create(context.Background(),
&authv1.SelfSubjectReview{}, metav1.CreateOptions{})
if err != nil {
logger.Log(logger.LevelError, map[string]string{"chart": req.Chart, "releaseName": req.Name}, err, "getting chart")
return false
}

if user := review.Status.UserInfo.Username; user == "" || user == "system:anonymous" {
logger.Log(logger.LevelError, map[string]string{"chart": req.Chart, "releaseName": req.Name},
errors.New("insufficient privileges"), "getting chart: user is not authorized to perform this operation")
return false
}

return true
}

func (h *Handler) installRelease(req InstallRequest) {
// Get install client
installClient := action.NewInstall(h.Configuration)
installClient.ReleaseName = req.Name
installClient.Namespace = req.Namespace
installClient.Description = req.Description
installClient.CreateNamespace = req.CreateNamespace
installClient.ChartPathOptions.Version = req.Version

if !VerifyUser(h, req) {
return
}

chart, err := h.getChart("install", req.Chart, req.Name,
installClient.ChartPathOptions, req.DependencyUpdate, h.EnvSettings)
if err != nil {
Expand All @@ -594,8 +632,6 @@ func (h *Handler) installRelease(req InstallRequest) {
return
}

values := make(map[string]interface{})

decodedBytes, err := base64.StdEncoding.DecodeString(req.Values)
if err != nil {
logger.Log(logger.LevelError, map[string]string{"chart": req.Chart, "releaseName": req.Name},
Expand All @@ -605,28 +641,23 @@ func (h *Handler) installRelease(req InstallRequest) {
return
}

err = yaml.Unmarshal(decodedBytes, &values)
if err != nil {
values := make(map[string]interface{})
if err = yaml.Unmarshal(decodedBytes, &values); err != nil {
logger.Log(logger.LevelError, map[string]string{"chart": req.Chart, "releaseName": req.Name},
err, "unmarshalling values")
h.setReleaseStatusSilent("install", req.Name, failed, err)

return
}

// Install chart
_, err = installClient.Run(chart, values)
if err != nil {
if _, err = installClient.Run(chart, values); err != nil {
logger.Log(logger.LevelError, map[string]string{"chart": req.Chart, "releaseName": req.Name},
err, "installing chart")
h.setReleaseStatusSilent("install", req.Name, failed, err)

return
}

logger.Log(logger.LevelInfo, map[string]string{"chart": req.Chart, "releaseName": req.Name},
nil, "chart installed successfully")

h.setReleaseStatusSilent("install", req.Name, success, nil)
}

Expand Down
Loading
Loading