mirror of
https://github.com/ahmetb/kubectx.git
synced 2026-05-19 05:56:32 +00:00
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>
197 lines
6.0 KiB
Go
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)
|
|
}
|