mirror of
https://github.com/ahmetb/kubectx.git
synced 2026-05-15 03:32:02 +00:00
615 lines
18 KiB
Go
615 lines
18 KiB
Go
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)
|
||
}
|
||
}
|