Files
kubectx/internal/proxy/readonly_security_test.go
Ahmet Alp Balkan 9130a99d5b fix gofmt
2026-03-26 23:12:42 -04:00

615 lines
18 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package proxy
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
// ============================================================
// Security & Jailbreak Test Suite for kubectx readonly proxy
// ============================================================
// --- Jailbreak: HTTP method smuggling ---
func TestJailbreak_MethodOverrideHeaders(t *testing.T) {
handler, _ := newTestHandler(t)
// Attackers might try X-HTTP-Method-Override or similar headers
// to smuggle a POST through as a GET.
overrideHeaders := []string{
"X-HTTP-Method-Override",
"X-HTTP-Method",
"X-Method-Override",
}
for _, hdr := range overrideHeaders {
t.Run(hdr, func(t *testing.T) {
// Send GET with override header claiming POST
req := httptest.NewRequest(http.MethodGet, "/api/v1/namespaces", nil)
req.Header.Set(hdr, "POST")
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
// The proxy should allow it (it's a GET), but the backend should
// NOT see it as a POST. The key is: do these headers reach the backend?
// If backend honors the override header, the readonly protection is bypassed.
if rr.Code == http.StatusOK {
// Check if backend received the override header - it could be dangerous
t.Logf("INFO: %s header passed through to backend (method override headers are forwarded)", hdr)
}
})
}
}
// --- Jailbreak: Path traversal and URL manipulation ---
func TestJailbreak_PathTraversal(t *testing.T) {
handler, _ := newTestHandler(t)
paths := []struct {
name string
method string
path string
want int
}{
// Try to sneak a POST through with path encoding
{"encoded POST path", http.MethodPost, "/api/v1/%6eamespaces", 405},
{"double-encoded path", http.MethodPost, "/api/v1/%256eamespaces", 405},
// Try making a DELETE look like a review endpoint
{"DELETE masquerading as review", http.MethodDelete,
"/apis/authorization.k8s.io/v1/selfsubjectaccessreviews", 405},
// PUT masquerading as review
{"PUT masquerading as review", http.MethodPut,
"/apis/authorization.k8s.io/v1/selfsubjectaccessreviews", 405},
// PATCH masquerading as review
{"PATCH masquerading as review", http.MethodPatch,
"/apis/authorization.k8s.io/v1/selfsubjectaccessreviews", 405},
}
for _, tt := range paths {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest(tt.method, tt.path, nil)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code != tt.want {
t.Errorf("expected %d, got %d", tt.want, rr.Code)
}
})
}
}
// --- Jailbreak: DryRun parameter smuggling ---
func TestJailbreak_DryRunSmuggling(t *testing.T) {
handler, _ := newTestHandler(t)
tests := []struct {
name string
method string
rawURL string
wantOK bool
comment string
}{
{"dryRun=All (legit)", http.MethodPost, "/api/v1/namespaces?dryRun=All", true,
"legitimate dry-run should work"},
{"dryRun=all lowercase", http.MethodPost, "/api/v1/namespaces?dryRun=all", false,
"case-sensitive: 'all' != 'All'"},
{"dryRun=ALL uppercase", http.MethodPost, "/api/v1/namespaces?dryRun=ALL", false,
"case-sensitive: 'ALL' != 'All'"},
{"dryRun=All+extra", http.MethodPost, "/api/v1/namespaces?dryRun=All&dryRun=None", true,
"POTENTIAL ISSUE: multiple dryRun params - Go Query().Get() returns first"},
{"dryRun=None+All", http.MethodPost, "/api/v1/namespaces?dryRun=None&dryRun=All", false,
"Query().Get() returns first param which is None - should be blocked"},
{"dryRun with spaces", http.MethodPost, "/api/v1/namespaces?dryRun=%20All", false,
"space-padded dryRun should be rejected"},
{"dryRun=All with null byte", http.MethodPost, "/api/v1/namespaces?dryRun=All%00None", false,
"null byte injection in dryRun value"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest(tt.method, tt.rawURL, nil)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
gotOK := rr.Code == http.StatusOK
if gotOK != tt.wantOK {
t.Errorf("%s: expected ok=%v, got status %d [%s]", tt.name, tt.wantOK, rr.Code, tt.comment)
} else {
t.Logf("PASS: %s [%s]", tt.name, tt.comment)
}
})
}
}
// --- Jailbreak: Upgrade header smuggling ---
func TestJailbreak_UpgradeSmuggling(t *testing.T) {
handler, _ := newTestHandler(t)
tests := []struct {
name string
method string
path string
headers map[string]string
wantOK bool
}{
// Case variation - Go canonicalizes header keys, so "connection" → "Connection"
{"connection key lowercase (Go canonicalizes)", http.MethodGet, "/api/v1/pods/x/exec",
map[string]string{"connection": "Upgrade"}, false,
// Go normalizes header key to "Connection", value "Upgrade" matches exactly → blocked
},
// Fixed: Connection value "upgrade" (lowercase) now caught by strings.EqualFold
{"Connection: upgrade value lowercase", http.MethodGet, "/api/v1/pods/x/exec",
map[string]string{"Connection": "upgrade"}, false,
},
// Multiple Connection header values
{"Connection with multiple values", http.MethodGet, "/api/v1/pods/x/exec",
map[string]string{"Connection": "keep-alive, Upgrade", "Upgrade": "SPDY/3.1"}, false,
// Has Upgrade header set so isUpgrade catches it via second check
},
// Empty upgrade header
{"empty Upgrade header", http.MethodGet, "/api/v1/pods/x/exec",
map[string]string{"Upgrade": ""}, true,
// Empty string != "" is false, so this passes through
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest(tt.method, tt.path, nil)
for k, v := range tt.headers {
req.Header.Set(k, v)
}
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
gotOK := rr.Code == http.StatusOK
if gotOK != tt.wantOK {
t.Errorf("expected ok=%v, got status %d", tt.wantOK, rr.Code)
}
})
}
}
// --- Jailbreak: Review endpoint spoofing ---
func TestJailbreak_ReviewEndpointSpoofing(t *testing.T) {
handler, _ := newTestHandler(t)
tests := []struct {
name string
path string
wantOK bool
}{
// Legitimate
{"legit SSAR", "/apis/authorization.k8s.io/v1/selfsubjectaccessreviews", true},
// Trailing slash
{"trailing slash", "/apis/authorization.k8s.io/v1/selfsubjectaccessreviews/", false},
// Path with query string
{"with query string", "/apis/authorization.k8s.io/v1/selfsubjectaccessreviews?foo=bar", true},
// CRD in different API group matching same resource name
{"spoofed API group", "/apis/authorization.evil.io/v1/selfsubjectaccessreviews", false},
// Subresource under a review endpoint name
{"subresource", "/apis/authorization.k8s.io/v1/selfsubjectaccessreviews/status", false},
// Double dot in API group
{"double-dot group", "/apis/authorization..k8s..io/v1/selfsubjectaccessreviews", false},
// Unicode homograph
{"unicode homograph", "/apis/authorization.k8s.іo/v1/selfsubjectaccessreviews", false},
// Namespace-scoped version of cluster-scoped review
{"namespace-scoped SSAR", "/apis/authorization.k8s.io/v1/namespaces/default/selfsubjectaccessreviews", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, tt.path, nil)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
gotOK := rr.Code == http.StatusOK
if gotOK != tt.wantOK {
t.Errorf("expected ok=%v, got status %d", tt.wantOK, rr.Code)
}
})
}
}
// --- Jailbreak: CONNECT method (HTTP tunneling) ---
func TestJailbreak_ConnectMethod(t *testing.T) {
handler, _ := newTestHandler(t)
req := httptest.NewRequest("CONNECT", "/", nil)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code != http.StatusMethodNotAllowed {
t.Errorf("CONNECT should be blocked, got %d", rr.Code)
}
}
// --- Jailbreak: Custom/unusual HTTP methods ---
func TestJailbreak_UnusualMethods(t *testing.T) {
handler, _ := newTestHandler(t)
methods := []string{
"CONNECT",
"TRACE",
"PROPFIND", // WebDAV
"MKCOL", // WebDAV
"COPY", // WebDAV
"MOVE", // WebDAV
"LOCK", // WebDAV
"UNLOCK", // WebDAV
"PURGE", // Varnish
"LINK", // Link
"UNLINK", // Unlink
"VIEW", // non-standard
"CUSTOMDELETE", // non-standard
}
for _, method := range methods {
t.Run(method, func(t *testing.T) {
req := httptest.NewRequest(method, "/api/v1/pods", nil)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code != http.StatusMethodNotAllowed {
t.Errorf("%s should be blocked, got %d", method, rr.Code)
}
})
}
}
// --- Jailbreak: Large request body on allowed endpoint ---
func TestJailbreak_LargeBodyOnGET(t *testing.T) {
handler, _ := newTestHandler(t)
// Some proxies might convert a GET with a body to a POST
body := strings.NewReader(`{"kind":"Namespace","apiVersion":"v1","metadata":{"name":"evil"}}`)
req := httptest.NewRequest(http.MethodGet, "/api/v1/namespaces", body)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
// GET with body should still be treated as GET (allowed)
if rr.Code != http.StatusOK {
t.Errorf("GET with body should still be allowed, got %d", rr.Code)
}
}
// --- Blocked response format verification ---
func TestBlockedResponse_Format(t *testing.T) {
handler, _ := newTestHandler(t)
req := httptest.NewRequest(http.MethodPost, "/api/v1/pods", nil)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
// Verify Content-Type
ct := rr.Header().Get("Content-Type")
if ct != "application/json" {
t.Errorf("expected Content-Type application/json, got %q", ct)
}
// Verify response body is valid Kubernetes Status
var status metav1.Status
body, _ := io.ReadAll(rr.Body)
if err := json.Unmarshal(body, &status); err != nil {
t.Fatalf("response is not valid JSON: %v\nbody: %s", err, body)
}
if status.APIVersion != "v1" {
t.Errorf("expected apiVersion=v1, got %q", status.APIVersion)
}
if status.Kind != "Status" {
t.Errorf("expected kind=Status, got %q", status.Kind)
}
if status.Code != 405 {
t.Errorf("expected code=405, got %d", status.Code)
}
if status.Message == "" {
t.Error("expected non-empty message")
}
if !strings.Contains(status.Message, "[kubectx]") {
t.Errorf("expected message to contain [kubectx], got %q", status.Message)
}
}
// --- Kubeconfig rewriting security tests ---
func TestRewriteKubeconfig_Security(t *testing.T) {
// Verify that no credentials leak into the rewritten kubeconfig
input := `
apiVersion: v1
kind: Config
clusters:
- name: test-cluster
cluster:
server: https://real-server.example.com:6443
certificate-authority-data: c2VjcmV0LWNh
contexts:
- name: test-ctx
context:
cluster: test-cluster
user: test-user
current-context: test-ctx
users:
- name: test-user
user:
client-certificate-data: c2VjcmV0LWNlcnQ=
client-key-data: c2VjcmV0LWtleQ==
token: super-secret-token
`
out, err := RewriteKubeconfig([]byte(input), "127.0.0.1:12345")
if err != nil {
t.Fatal(err)
}
result := string(out)
// Real server URL must not appear
if strings.Contains(result, "real-server.example.com") {
t.Error("SECURITY: real server URL leaked into rewritten kubeconfig")
}
// Credentials must not appear
sensitiveStrings := []string{
"c2VjcmV0LWNh", // CA data
"c2VjcmV0LWNlcnQ=", // client cert
"c2VjcmV0LWtleQ==", // client key
"super-secret-token", // token
}
for _, s := range sensitiveStrings {
if strings.Contains(result, s) {
t.Errorf("SECURITY: credential data %q leaked into rewritten kubeconfig", s)
}
}
// Proxy address must be present
if !strings.Contains(result, "127.0.0.1:12345") {
t.Error("proxy address not found in rewritten kubeconfig")
}
// [RO] suffix must be present
if !strings.Contains(result, "[RO]") {
t.Error("[RO] context suffix not found in rewritten kubeconfig")
}
}
// --- Concurrent request handling ---
func TestConcurrent_MixedRequests(t *testing.T) {
handler, _ := newTestHandler(t)
done := make(chan struct{}, 100)
// Fire off mixed GET and POST requests concurrently
for i := 0; i < 50; i++ {
go func(i int) {
defer func() { done <- struct{}{} }()
var method string
if i%2 == 0 {
method = http.MethodGet
} else {
method = http.MethodPost
}
req := httptest.NewRequest(method, "/api/v1/pods", nil)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if method == http.MethodGet && rr.Code != http.StatusOK {
t.Errorf("GET #%d: expected 200, got %d", i, rr.Code)
}
if method == http.MethodPost && rr.Code != http.StatusMethodNotAllowed {
t.Errorf("POST #%d: expected 405, got %d", i, rr.Code)
}
}(i)
}
for i := 0; i < 50; i++ {
<-done
}
}
// --- Watchlist (GET with watch param - should be allowed) ---
func TestWatch_AllowedViaGET(t *testing.T) {
handler, _ := newTestHandler(t)
req := httptest.NewRequest(http.MethodGet, "/api/v1/pods?watch=true", nil)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Errorf("GET with watch=true should be allowed, got %d", rr.Code)
}
}
// --- kubectl apply --dry-run=server sends POST with dryRun=All ---
func TestDryRunApply_ServerSide(t *testing.T) {
handler, _ := newTestHandler(t)
// Simulates: kubectl apply --dry-run=server
body := strings.NewReader(`{"kind":"Namespace","apiVersion":"v1","metadata":{"name":"test"}}`)
req := httptest.NewRequest(http.MethodPost, "/api/v1/namespaces?dryRun=All&fieldManager=kubectl-client-side-apply", body)
req.Header.Set("Content-Type", "application/json")
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Errorf("server-side dry-run apply should be allowed, got %d", rr.Code)
}
}
// --- kubectl logs should work (GET, no upgrade) ---
func TestLogs_AllowedViaGET(t *testing.T) {
handler, _ := newTestHandler(t)
req := httptest.NewRequest(http.MethodGet, "/api/v1/namespaces/default/pods/my-pod/log?follow=true", nil)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Errorf("kubectl logs should be allowed (GET), got %d", rr.Code)
}
}
// --- kubectl exec should be blocked ---
func TestExec_Blocked(t *testing.T) {
handler, _ := newTestHandler(t)
req := httptest.NewRequest(http.MethodPost, "/api/v1/namespaces/default/pods/my-pod/exec?command=sh", nil)
req.Header.Set("Connection", "Upgrade")
req.Header.Set("Upgrade", "SPDY/3.1")
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code != http.StatusMethodNotAllowed {
t.Errorf("kubectl exec should be blocked, got %d", rr.Code)
}
var status metav1.Status
json.NewDecoder(rr.Body).Decode(&status)
if !strings.Contains(status.Message, "exec") {
t.Errorf("blocked message should mention exec, got %q", status.Message)
}
}
// --- kubectl cp should be blocked ---
func TestCp_Blocked(t *testing.T) {
handler, _ := newTestHandler(t)
// kubectl cp first does exec
req := httptest.NewRequest(http.MethodPost, "/api/v1/namespaces/default/pods/my-pod/exec?command=tar", nil)
req.Header.Set("Connection", "Upgrade")
req.Header.Set("Upgrade", "SPDY/3.1")
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code != http.StatusMethodNotAllowed {
t.Errorf("kubectl cp should be blocked, got %d", rr.Code)
}
}
// --- kubectl port-forward should be blocked ---
func TestPortForward_Blocked(t *testing.T) {
handler, _ := newTestHandler(t)
req := httptest.NewRequest(http.MethodPost, "/api/v1/namespaces/default/pods/my-pod/portforward", nil)
req.Header.Set("Connection", "Upgrade")
req.Header.Set("Upgrade", "SPDY/3.1")
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code != http.StatusMethodNotAllowed {
t.Errorf("kubectl port-forward should be blocked, got %d", rr.Code)
}
}
// --- E2E proxy integration test with real HTTP server ---
func TestE2E_ProxyWithBackend(t *testing.T) {
var backendRequests []string
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
backendRequests = append(backendRequests, fmt.Sprintf("%s %s", r.Method, r.URL.Path))
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
switch {
case r.URL.Path == "/api/v1/pods" && r.Method == "GET":
w.Write([]byte(`{"kind":"PodList","apiVersion":"v1","items":[{"metadata":{"name":"test-pod"}}]}`))
case r.URL.Path == "/api/v1/namespaces" && r.Method == "GET":
w.Write([]byte(`{"kind":"NamespaceList","apiVersion":"v1","items":[{"metadata":{"name":"default"}}]}`))
default:
w.Write([]byte(`{"kind":"Status","apiVersion":"v1","status":"Success"}`))
}
}))
defer backend.Close()
target, _ := url.Parse(backend.URL)
handler := NewHandler(target, http.DefaultTransport)
proxyServer := httptest.NewServer(handler)
defer proxyServer.Close()
client := proxyServer.Client()
// Test 1: GET pods - should reach backend
resp, err := client.Get(proxyServer.URL + "/api/v1/pods")
if err != nil {
t.Fatal(err)
}
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
if resp.StatusCode != 200 {
t.Errorf("GET pods: expected 200, got %d", resp.StatusCode)
}
if !strings.Contains(string(body), "test-pod") {
t.Error("GET pods: response doesn't contain expected pod")
}
// Test 2: POST namespace - should be blocked (never reach backend)
preCount := len(backendRequests)
resp, err = client.Post(proxyServer.URL+"/api/v1/namespaces", "application/json",
strings.NewReader(`{"kind":"Namespace","apiVersion":"v1","metadata":{"name":"evil"}}`))
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if resp.StatusCode != 405 {
t.Errorf("POST namespace: expected 405, got %d", resp.StatusCode)
}
if len(backendRequests) != preCount {
t.Error("POST namespace: request leaked through to backend!")
}
// Test 3: DELETE pod - should be blocked
req, _ := http.NewRequest(http.MethodDelete, proxyServer.URL+"/api/v1/namespaces/default/pods/test-pod", nil)
resp, err = client.Do(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if resp.StatusCode != 405 {
t.Errorf("DELETE pod: expected 405, got %d", resp.StatusCode)
}
// Test 4: GET namespaces - should work
resp, err = client.Get(proxyServer.URL + "/api/v1/namespaces")
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if resp.StatusCode != 200 {
t.Errorf("GET namespaces: expected 200, got %d", resp.StatusCode)
}
// Test 5: dry-run POST - should reach backend
req, _ = http.NewRequest(http.MethodPost, proxyServer.URL+"/api/v1/namespaces?dryRun=All", nil)
resp, err = client.Do(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if resp.StatusCode != 200 {
t.Errorf("dry-run POST: expected 200, got %d", resp.StatusCode)
}
}