Files
kubectx/internal/proxy/readonly.go
Ahmet Alp Balkan 19d813d567 Use case-insensitive comparison for Connection upgrade header
The isUpgrade() check used exact string match (== "Upgrade") for the
Connection header value. A client sending "Connection: upgrade"
(lowercase) would bypass this check. While not exploitable in practice
(the Upgrade header check catches real upgrades), this hardens the
proxy with defense-in-depth.

Also adds a comprehensive security test suite covering jailbreak
attempts: method override smuggling, path traversal, dryRun parameter
injection, upgrade header smuggling, review endpoint spoofing, unusual
HTTP methods, concurrent request filtering, and credential leakage.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 23:06:39 -04:00

197 lines
6.0 KiB
Go

package proxy
import (
"context"
"encoding/json"
"fmt"
"log"
"net"
"net/http"
"net/http/httputil"
"net/url"
"os"
"regexp"
"strings"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/rest"
"k8s.io/client-go/tools/clientcmd"
"github.com/ahmetb/kubectx/internal/env"
)
// nonMutatingPostPatterns match Kubernetes "review" endpoints that use POST
// but don't create persistent resources. Patterns are anchored to known API
// groups to prevent spoofing via custom resource names.
var nonMutatingPostPatterns = []*regexp.Regexp{
regexp.MustCompile(`^/apis/authorization\.k8s\.io/[^/]+/selfsubjectaccessreviews$`),
regexp.MustCompile(`^/apis/authorization\.k8s\.io/[^/]+/subjectaccessreviews$`),
regexp.MustCompile(`^/apis/authorization\.k8s\.io/[^/]+/namespaces/[^/]+/localsubjectaccessreviews$`),
regexp.MustCompile(`^/apis/authorization\.k8s\.io/[^/]+/selfsubjectrulesreviews$`),
regexp.MustCompile(`^/apis/authentication\.k8s\.io/[^/]+/tokenreviews$`),
regexp.MustCompile(`^/apis/authentication\.k8s\.io/[^/]+/selfsubjectreviews$`),
}
var debugLog = func() *log.Logger {
if _, ok := os.LookupEnv(env.EnvDebug); ok {
return log.New(os.Stderr, "[readonly-proxy] ", log.Ltime)
}
return log.New(nopWriter{}, "", 0)
}()
type nopWriter struct{}
func (nopWriter) Write(p []byte) (int, error) { return len(p), nil }
// ReadonlyProxy is a reverse proxy that only allows read-only HTTP methods.
type ReadonlyProxy struct {
server *http.Server
listener net.Listener
}
// Config holds information needed to start the readonly proxy.
type Config struct {
KubeconfigPath string
ContextName string
}
// Start creates and starts a readonly reverse proxy on a random localhost port.
// The proxy loads TLS/auth config from the kubeconfig and forwards only
// GET, HEAD, and OPTIONS requests (without protocol upgrades) to the real API server.
func Start(cfg Config) (*ReadonlyProxy, error) {
loadingRules := &clientcmd.ClientConfigLoadingRules{ExplicitPath: cfg.KubeconfigPath}
overrides := &clientcmd.ConfigOverrides{CurrentContext: cfg.ContextName}
clientConfig := clientcmd.NewNonInteractiveDeferredLoadingClientConfig(loadingRules, overrides)
restCfg, err := clientConfig.ClientConfig()
if err != nil {
return nil, fmt.Errorf("failed to load kubeconfig: %w", err)
}
targetURL, err := url.Parse(restCfg.Host)
if err != nil {
return nil, fmt.Errorf("failed to parse server URL %q: %w", restCfg.Host, err)
}
transport, err := rest.TransportFor(restCfg)
if err != nil {
return nil, fmt.Errorf("failed to create transport: %w", err)
}
handler := NewHandler(targetURL, transport)
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
return nil, fmt.Errorf("failed to listen: %w", err)
}
srv := &http.Server{Handler: handler}
go srv.Serve(listener)
debugLog.Printf("started on %s, proxying to %s", listener.Addr(), targetURL)
return &ReadonlyProxy{
server: srv,
listener: listener,
}, nil
}
// Addr returns the listener address (e.g. "127.0.0.1:54321").
func (p *ReadonlyProxy) Addr() string {
return p.listener.Addr().String()
}
// Shutdown gracefully stops the proxy.
func (p *ReadonlyProxy) Shutdown(ctx context.Context) error {
debugLog.Printf("shutting down")
return p.server.Shutdown(ctx)
}
// NewHandler creates the readonly proxy HTTP handler.
// Exported for testing with a fake backend.
func NewHandler(target *url.URL, transport http.RoundTripper) http.Handler {
proxy := httputil.NewSingleHostReverseProxy(target)
proxy.Transport = transport
proxy.FlushInterval = -1 // flush immediately for streaming (logs -f, watches)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
debugLog.Printf(">> %s %s", r.Method, r.URL.Path)
if reason, ok := checkRequest(r); !ok {
debugLog.Printf("<< %s %s -> 405 (%s)", r.Method, r.URL.Path, reason)
writeBlockedResponse(w, r.Method,
fmt.Sprintf("[kubectx] readonly mode: %s", reason))
return
}
debugLog.Printf("<< %s %s -> proxied", r.Method, r.URL.Path)
proxy.ServeHTTP(w, r)
})
}
// checkRequest determines whether a request should be proxied or blocked.
// Returns ("", true) if allowed, or (reason, false) if blocked.
func checkRequest(r *http.Request) (reason string, allowed bool) {
if isUpgrade(r) {
return "operations like exec, cp, and port-forward are not allowed", false
}
if isReadOnly(r) {
return "", true
}
if isNonMutatingPost(r) {
return "", true
}
if isDryRun(r) {
return "", true
}
return fmt.Sprintf("%s requests are not allowed", r.Method), false
}
// isUpgrade returns true if the request is a protocol upgrade (SPDY/WebSocket).
func isUpgrade(r *http.Request) bool {
return strings.EqualFold(r.Header.Get("Connection"), "Upgrade") || r.Header.Get("Upgrade") != ""
}
// isReadOnly returns true for safe HTTP methods that never modify state.
func isReadOnly(r *http.Request) bool {
switch r.Method {
case http.MethodGet, http.MethodHead, http.MethodOptions:
return true
}
return false
}
// isNonMutatingPost returns true for Kubernetes "review" endpoints that use
// POST but don't create persistent resources (e.g. SubjectAccessReview).
// Patterns are anchored to known API groups to prevent spoofing.
func isNonMutatingPost(r *http.Request) bool {
if r.Method != http.MethodPost {
return false
}
for _, re := range nonMutatingPostPatterns {
if re.MatchString(r.URL.Path) {
return true
}
}
return false
}
// isDryRun returns true if the request has ?dryRun=All, which means
// the API server will validate but not persist the request.
func isDryRun(r *http.Request) bool {
return r.URL.Query().Get("dryRun") == "All"
}
func writeBlockedResponse(w http.ResponseWriter, method, message string) {
status := &metav1.Status{
TypeMeta: metav1.TypeMeta{APIVersion: "v1", Kind: "Status"},
Status: metav1.StatusFailure,
Message: message,
Reason: metav1.StatusReasonMethodNotAllowed,
Code: http.StatusMethodNotAllowed,
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusMethodNotAllowed)
json.NewEncoder(w).Encode(status)
}