From bd0c6646022e84a56046b5c4f908aeb66d664be5 Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Wed, 13 May 2026 14:22:19 -0400 Subject: [PATCH] feat(canary): kubeconfig fake K8s API handler (forbidden response) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Trigger implementation completes the kubeconfig Generator's interface conformance. Behavior matches spec §9.5 + §8.5 defense-in-depth: - ALWAYS returns 403 + Kubernetes-shaped Status JSON (whether the token is valid or not). Attackers cannot distinguish "valid token, no permission" from "no such token" by status code or response shape — both are 403 with byte-identical structure modulo the resource/verb message slot. - Status JSON conforms to k8s.io Status object: kind=Status, apiVersion=v1, metadata={}, status=Failure, reason=Forbidden, code=403, message=verisimilar real-kubectl error string. - Message format mirrors kubectl's actual output: is forbidden: User "system:anonymous" cannot resource "" in API group "" in the namespace "default" where is the last segment of r.URL.Path and is derived from the HTTP method (GET/HEAD → list, POST → create, PUT → update, PATCH → patch, DELETE → delete, others → list). This is richer than spec §9.5's hardcoded "list" — kubectl ships the verb based on the action it's attempting, so a /apis/.../pods DELETE response saying "cannot list" would tip the attacker off. Method mapping is forensic gold for the operator (which kubectl action actually fired) and verisimilar to the attacker. - Event.Extra captures the kubectl_path / kubectl_method / kubectl_query / kubectl_ua fields per spec §9.5 line 1234. UA capture is the forensic prize — kubectl ships its version + arch in the UA string ("kubectl/v1.30.0 (linux/amd64) kubernetes/..."). - Source-IP triplet (realIP / lastNonEmptyXFF / optionalHeader) is a content-copy of webbug/docx/pdf — sanctioned duplication per Phase 2/3/4 anti-relitigation set. Phase 9 middleware extraction collapses these into one shared helper. - Nil-token path returns the same 403+Status with verisimilar message + headers, and nil event so the handler cannot persist a row with empty TokenID (FK violation) — spec §8.5. Tests cover (~36 cases including subtests): - 403 + JSON content-type + cache headers - Status JSON shape (Kind/APIVersion/metadata={}/Status/Reason/Code) - Message resource extraction: pods, secrets, single-resource get, API version probe, trailing slash, healthz probe (6 paths) - Verb derivation from HTTP method (7 methods, 5 distinct verbs) - User "system:anonymous" impersonation present - kubectl_path/method/query/ua in Extra JSON - Empty query string handled - Source-IP precedence (11 subcases mirroring docx/pdf for parity) - Missing UA/Referer → nil pointers - Nil-token defense-in-depth: still returns 403 + parseable Status, nil event, response-shape parity with valid-token case (HTTP code + JSON kind/version/status/reason/code all identical so an attacker cannot probe token validity by diffing responses) Generator now satisfies generators.Generator interface. Registry wire-up in next commit. --- .../token/generators/kubeconfig/handler.go | 201 +++++++ .../generators/kubeconfig/handler_test.go | 518 ++++++++++++++++++ 2 files changed, 719 insertions(+) create mode 100644 PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/kubeconfig/handler.go create mode 100644 PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/kubeconfig/handler_test.go diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/kubeconfig/handler.go b/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/kubeconfig/handler.go new file mode 100644 index 00000000..c2028ece --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/kubeconfig/handler.go @@ -0,0 +1,201 @@ +// ©AngelaMos | 2026 +// handler.go + +package kubeconfig + +import ( + "context" + "encoding/json" + "fmt" + "net" + "net/http" + "path" + "strings" + + "github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/event" + "github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/token" + "github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/token/generators" +) + +const ( + headerCFConnectingIP = "CF-Connecting-IP" + headerXForwardedFor = "X-Forwarded-For" + headerXRealIP = "X-Real-IP" + headerReferer = "Referer" + headerUserAgent = "User-Agent" + headerCacheControl = "Cache-Control" + headerPragma = "Pragma" + + cacheControlNoStore = "no-store, no-cache, must-revalidate, max-age=0" + pragmaNoCache = "no-cache" + contentTypeJSON = "application/json" + + statusKind = "Status" + statusAPIVersion = "v1" + statusFailure = "Failure" + statusReason = "Forbidden" + statusMessageFmt = `%s is forbidden: User "system:anonymous" cannot %s resource "%s" in API group "" in the namespace "default"` + + defaultResource = "resource" + + verbList = "list" + verbCreate = "create" + verbUpdate = "update" + verbPatch = "patch" + verbDelete = "delete" + + extraKubectlPath = "kubectl_path" + extraKubectlMethod = "kubectl_method" + extraKubectlQuery = "kubectl_query" + extraKubectlUA = "kubectl_ua" +) + +type kubernetesStatus struct { + Kind string `json:"kind"` + APIVersion string `json:"apiVersion"` + Metadata statusMetadata `json:"metadata"` + Status string `json:"status"` + Message string `json:"message"` + Reason string `json:"reason"` + Code int `json:"code"` +} + +type statusMetadata struct{} + +func (g *Generator) Trigger( + _ context.Context, + t *token.Token, + r *http.Request, +) (*event.Event, *generators.TriggerResponse, error) { + resource := resourceFromPath(r.URL.Path) + verb := verbFromMethod(r.Method) + + body, err := buildForbiddenBody(resource, verb) + if err != nil { + return nil, nil, fmt.Errorf( + "kubeconfig: build forbidden response: %w", + err, + ) + } + + resp := &generators.TriggerResponse{ + StatusCode: http.StatusForbidden, + ContentType: contentTypeJSON, + Body: body, + ExtraHeaders: map[string]string{ + headerCacheControl: cacheControlNoStore, + headerPragma: pragmaNoCache, + }, + } + + if t == nil { + return nil, resp, nil + } + + extra, err := buildKubectlExtra(r) + if err != nil { + return nil, nil, fmt.Errorf( + "kubeconfig: build event extra: %w", + err, + ) + } + + evt := &event.Event{ + TokenID: t.ID, + SourceIP: realIP(r), + UserAgent: optionalHeader(r.UserAgent()), + Referer: optionalHeader(r.Header.Get(headerReferer)), + Extra: extra, + } + return evt, resp, nil +} + +func buildForbiddenBody(resource, verb string) ([]byte, error) { + s := kubernetesStatus{ + Kind: statusKind, + APIVersion: statusAPIVersion, + Status: statusFailure, + Message: fmt.Sprintf(statusMessageFmt, resource, verb, resource), + Reason: statusReason, + Code: http.StatusForbidden, + } + body, err := json.Marshal(s) + if err != nil { + return nil, fmt.Errorf("marshal kubernetes status: %w", err) + } + return body, nil +} + +func buildKubectlExtra(r *http.Request) (json.RawMessage, error) { + extra := map[string]string{ + extraKubectlPath: r.URL.Path, + extraKubectlMethod: r.Method, + extraKubectlQuery: r.URL.RawQuery, + extraKubectlUA: r.Header.Get(headerUserAgent), + } + body, err := json.Marshal(extra) + if err != nil { + return nil, fmt.Errorf("marshal kubectl extra: %w", err) + } + return body, nil +} + +func resourceFromPath(urlPath string) string { + last := path.Base(urlPath) + if last == "" || last == "/" || last == "." { + return defaultResource + } + return last +} + +func verbFromMethod(method string) string { + switch method { + case http.MethodPost: + return verbCreate + case http.MethodPut: + return verbUpdate + case http.MethodPatch: + return verbPatch + case http.MethodDelete: + return verbDelete + default: + return verbList + } +} + +func optionalHeader(v string) *string { + v = strings.TrimSpace(v) + if v == "" { + return nil + } + return &v +} + +func realIP(r *http.Request) string { + if v := strings.TrimSpace(r.Header.Get(headerCFConnectingIP)); v != "" { + return v + } + if v := lastNonEmptyXFF(r.Header.Get(headerXForwardedFor)); v != "" { + return v + } + if v := strings.TrimSpace(r.Header.Get(headerXRealIP)); v != "" { + return v + } + if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil { + return host + } + return r.RemoteAddr +} + +func lastNonEmptyXFF(header string) string { + if header == "" { + return "" + } + parts := strings.Split(header, ",") + for i := len(parts) - 1; i >= 0; i-- { + if v := strings.TrimSpace(parts[i]); v != "" { + return v + } + } + return "" +} diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/kubeconfig/handler_test.go b/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/kubeconfig/handler_test.go new file mode 100644 index 00000000..5e29cec9 --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/kubeconfig/handler_test.go @@ -0,0 +1,518 @@ +// ©AngelaMos | 2026 +// handler_test.go + +package kubeconfig_test + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/token/generators/kubeconfig" +) + +const ( + cacheControlNoStoreValue = "no-store, no-cache, must-revalidate, max-age=0" + pragmaNoCacheValue = "no-cache" + expectedResponseMIME = "application/json" +) + +type k8sStatusResponse struct { + Kind string `json:"kind"` + APIVersion string `json:"apiVersion"` + Metadata map[string]interface{} `json:"metadata"` + Status string `json:"status"` + Message string `json:"message"` + Reason string `json:"reason"` + Code int `json:"code"` +} + +type kubectlExtra struct { + KubectlPath string `json:"kubectl_path"` + KubectlMethod string `json:"kubectl_method"` + KubectlQuery string `json:"kubectl_query"` + KubectlUA string `json:"kubectl_ua"` +} + +func parseStatus(t *testing.T, body []byte) k8sStatusResponse { + t.Helper() + var s k8sStatusResponse + require.NoError( + t, + json.Unmarshal(body, &s), + "response body must be valid Kubernetes Status JSON", + ) + return s +} + +func parseKubectlExtra(t *testing.T, raw json.RawMessage) kubectlExtra { + t.Helper() + var k kubectlExtra + require.NoError(t, json.Unmarshal(raw, &k)) + return k +} + +func TestTrigger_ResponseStatusCodeIs403(t *testing.T) { + g := kubeconfig.New() + tok := newKubeconfigToken("abc") + r := httptest.NewRequest(http.MethodGet, "/k/abc/api/v1/pods", nil) + + _, resp, err := g.Trigger(context.Background(), tok, r) + require.NoError(t, err) + require.NotNil(t, resp) + require.Equal(t, http.StatusForbidden, resp.StatusCode) +} + +func TestTrigger_ResponseContentTypeIsJSON(t *testing.T) { + g := kubeconfig.New() + tok := newKubeconfigToken("abc") + r := httptest.NewRequest(http.MethodGet, "/k/abc/api/v1/pods", nil) + + _, resp, err := g.Trigger(context.Background(), tok, r) + require.NoError(t, err) + require.Equal(t, expectedResponseMIME, resp.ContentType) +} + +func TestTrigger_CacheHeadersSet(t *testing.T) { + g := kubeconfig.New() + tok := newKubeconfigToken("abc") + r := httptest.NewRequest(http.MethodGet, "/k/abc/api/v1/pods", nil) + + _, resp, err := g.Trigger(context.Background(), tok, r) + require.NoError(t, err) + require.Equal( + t, + cacheControlNoStoreValue, + resp.ExtraHeaders["Cache-Control"], + ) + require.Equal(t, pragmaNoCacheValue, resp.ExtraHeaders["Pragma"]) +} + +func TestTrigger_ResponseIsValidKubernetesStatus(t *testing.T) { + g := kubeconfig.New() + tok := newKubeconfigToken("abc") + r := httptest.NewRequest(http.MethodGet, "/k/abc/api/v1/pods", nil) + + _, resp, err := g.Trigger(context.Background(), tok, r) + require.NoError(t, err) + + s := parseStatus(t, resp.Body) + require.Equal(t, "Status", s.Kind) + require.Equal(t, "v1", s.APIVersion) + require.NotNil(t, s.Metadata) + require.Empty(t, s.Metadata, "metadata must be empty object {}") + require.Equal(t, "Failure", s.Status) + require.Equal(t, "Forbidden", s.Reason) + require.Equal(t, http.StatusForbidden, s.Code) +} + +func TestTrigger_MessageUsesPathLastSegmentAsResource(t *testing.T) { + g := kubeconfig.New() + tok := newKubeconfigToken("abc") + + cases := []struct { + name string + path string + wantWord string + }{ + { + name: "pods endpoint", + path: "/k/abc/api/v1/namespaces/default/pods", + wantWord: "pods", + }, + { + name: "secrets endpoint", + path: "/k/abc/api/v1/secrets", + wantWord: "secrets", + }, + { + name: "single-resource get", + path: "/k/abc/api/v1/namespaces/default/pods/web-1", + wantWord: "web-1", + }, + { + name: "api version probe", + path: "/k/abc/api/v1", + wantWord: "v1", + }, + { + name: "trailing slash path falls back to default", + path: "/k/abc/", + wantWord: "abc", + }, + { + name: "non-resource healthz probe", + path: "/k/abc/healthz", + wantWord: "healthz", + }, + } + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + r := httptest.NewRequest(http.MethodGet, tc.path, nil) + _, resp, err := g.Trigger(context.Background(), tok, r) + require.NoError(t, err) + s := parseStatus(t, resp.Body) + require.Contains( + t, + s.Message, + tc.wantWord+` is forbidden`, + "message must lead with the resource name", + ) + require.Contains( + t, + s.Message, + `resource "`+tc.wantWord+`"`, + "message must repeat the resource name in the resource clause", + ) + }) + } +} + +func TestTrigger_MessageVerbDerivedFromHTTPMethod(t *testing.T) { + g := kubeconfig.New() + tok := newKubeconfigToken("abc") + + cases := []struct { + method string + wantVerb string + }{ + {http.MethodGet, "list"}, + {http.MethodHead, "list"}, + {http.MethodPost, "create"}, + {http.MethodPut, "update"}, + {http.MethodPatch, "patch"}, + {http.MethodDelete, "delete"}, + {http.MethodOptions, "list"}, + } + for _, tc := range cases { + tc := tc + t.Run(tc.method, func(t *testing.T) { + r := httptest.NewRequest( + tc.method, + "/k/abc/api/v1/pods", + nil, + ) + _, resp, err := g.Trigger(context.Background(), tok, r) + require.NoError(t, err) + s := parseStatus(t, resp.Body) + require.Contains( + t, + s.Message, + `cannot `+tc.wantVerb+` resource`, + "message must use the verb mapped from the HTTP method", + ) + }) + } +} + +func TestTrigger_MessageImpersonatesAnonymousUser(t *testing.T) { + g := kubeconfig.New() + tok := newKubeconfigToken("abc") + r := httptest.NewRequest(http.MethodGet, "/k/abc/api/v1/pods", nil) + + _, resp, err := g.Trigger(context.Background(), tok, r) + require.NoError(t, err) + + s := parseStatus(t, resp.Body) + require.Contains( + t, + s.Message, + `User "system:anonymous"`, + "message must impersonate the anonymous user for verisimilitude", + ) +} + +func TestTrigger_RecordsEventWithRequestMetadata(t *testing.T) { + g := kubeconfig.New() + tok := newKubeconfigToken("token1") + + t.Run( + "captures token id, source ip, user agent, referer", + func(t *testing.T) { + r := httptest.NewRequest( + http.MethodGet, + "/k/token1/api/v1/pods?watch=true", + nil, + ) + r.Header.Set("CF-Connecting-IP", "203.0.113.50") + r.Header.Set( + "User-Agent", + "kubectl/v1.30.0 (linux/amd64) kubernetes/9b7f2dd", + ) + r.Header.Set("Referer", "https://victim.example.com/dashboard") + + evt, _, err := g.Trigger(context.Background(), tok, r) + require.NoError(t, err) + require.NotNil(t, evt) + require.Equal(t, "token1", evt.TokenID) + require.Equal(t, "203.0.113.50", evt.SourceIP) + require.NotNil(t, evt.UserAgent) + require.Equal( + t, + "kubectl/v1.30.0 (linux/amd64) kubernetes/9b7f2dd", + *evt.UserAgent, + ) + require.NotNil(t, evt.Referer) + require.Equal( + t, + "https://victim.example.com/dashboard", + *evt.Referer, + ) + }, + ) + + t.Run("kubectl_* extra captured", func(t *testing.T) { + r := httptest.NewRequest( + http.MethodPost, + "/k/token1/api/v1/namespaces/default/pods?dryRun=All", + nil, + ) + r.Header.Set("CF-Connecting-IP", "203.0.113.50") + r.Header.Set("User-Agent", "kubectl/v1.30.0") + + evt, _, err := g.Trigger(context.Background(), tok, r) + require.NoError(t, err) + require.NotNil(t, evt) + + extra := parseKubectlExtra(t, evt.Extra) + require.Equal( + t, + "/k/token1/api/v1/namespaces/default/pods", + extra.KubectlPath, + ) + require.Equal(t, http.MethodPost, extra.KubectlMethod) + require.Equal(t, "dryRun=All", extra.KubectlQuery) + require.Equal(t, "kubectl/v1.30.0", extra.KubectlUA) + }) + + t.Run("missing query is empty string in extra", func(t *testing.T) { + r := httptest.NewRequest( + http.MethodGet, + "/k/token1/api/v1/pods", + nil, + ) + evt, _, err := g.Trigger(context.Background(), tok, r) + require.NoError(t, err) + extra := parseKubectlExtra(t, evt.Extra) + require.Empty(t, extra.KubectlQuery) + }) + + t.Run("source ip precedence", func(t *testing.T) { + cases := []struct { + name string + headers map[string]string + remote string + wantIP string + }{ + { + name: "CF wins over XFF and XRI", + headers: map[string]string{ + "CF-Connecting-IP": "203.0.113.10", + "X-Forwarded-For": "198.51.100.1, 198.51.100.2", + "X-Real-IP": "192.0.2.99", + }, + remote: "127.0.0.1:9999", + wantIP: "203.0.113.10", + }, + { + name: "XFF rightmost wins over XRI when no CF", + headers: map[string]string{ + "X-Forwarded-For": "198.51.100.1, 198.51.100.7", + "X-Real-IP": "192.0.2.99", + }, + remote: "127.0.0.1:9999", + wantIP: "198.51.100.7", + }, + { + name: "XFF trailing-comma falls through to last non-empty", + headers: map[string]string{ + "X-Forwarded-For": "198.51.100.1, ", + "X-Real-IP": "192.0.2.99", + }, + remote: "127.0.0.1:9999", + wantIP: "198.51.100.1", + }, + { + name: "XFF entirely empty entries fall through to XRI", + headers: map[string]string{ + "X-Forwarded-For": ", ,", + "X-Real-IP": "192.0.2.99", + }, + remote: "127.0.0.1:9999", + wantIP: "192.0.2.99", + }, + { + name: "XRI when no CF or XFF", + headers: map[string]string{ + "X-Real-IP": "192.0.2.99", + }, + remote: "127.0.0.1:9999", + wantIP: "192.0.2.99", + }, + { + name: "RemoteAddr IPv4 strips port", + headers: nil, + remote: "127.0.0.1:9999", + wantIP: "127.0.0.1", + }, + { + name: "RemoteAddr IPv6 strips brackets and port", + headers: nil, + remote: "[2001:db8::1]:54321", + wantIP: "2001:db8::1", + }, + { + name: "RemoteAddr loopback IPv6 strips brackets and port", + headers: nil, + remote: "[::1]:9999", + wantIP: "::1", + }, + { + name: "RemoteAddr without port falls back to raw value", + headers: nil, + remote: "127.0.0.1", + wantIP: "127.0.0.1", + }, + { + name: "XFF IPv6 rightmost", + headers: map[string]string{ + "X-Forwarded-For": "198.51.100.1, 2001:db8::dead", + }, + remote: "127.0.0.1:9999", + wantIP: "2001:db8::dead", + }, + { + name: "CF value is trimmed of whitespace", + headers: map[string]string{ + "CF-Connecting-IP": " 203.0.113.10 ", + }, + remote: "127.0.0.1:9999", + wantIP: "203.0.113.10", + }, + } + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + r := httptest.NewRequest( + http.MethodGet, + "/k/token1/api/v1/pods", + nil, + ) + for k, v := range tc.headers { + r.Header.Set(k, v) + } + r.RemoteAddr = tc.remote + evt, _, err := g.Trigger(context.Background(), tok, r) + require.NoError(t, err) + require.NotNil(t, evt) + require.Equal(t, tc.wantIP, evt.SourceIP) + }) + } + }) + + t.Run( + "missing user agent and referer record as nil pointers", + func(t *testing.T) { + r := httptest.NewRequest( + http.MethodGet, + "/k/token1/api/v1/pods", + nil, + ) + r.Header.Del("User-Agent") + r.Header.Del("Referer") + r.Header.Set("CF-Connecting-IP", "203.0.113.5") + + evt, _, err := g.Trigger(context.Background(), tok, r) + require.NoError(t, err) + require.NotNil(t, evt) + require.Nil( + t, + evt.UserAgent, + "absent user agent must map to nil, not empty string", + ) + require.Nil( + t, + evt.Referer, + "absent referer must map to nil, not empty string", + ) + }, + ) +} + +func TestTrigger_TokenNotFound_StillReturns403(t *testing.T) { + g := kubeconfig.New() + r := httptest.NewRequest( + http.MethodGet, + "/k/does-not-exist/api/v1/pods", + nil, + ) + r.Header.Set("CF-Connecting-IP", "203.0.113.100") + r.Header.Set("User-Agent", "kubectl/v1.30.0") + + evt, resp, err := g.Trigger(context.Background(), nil, r) + require.NoError( + t, + err, + "nil-token path must not error (spec §8.5 defense-in-depth)", + ) + require.NotNil(t, resp) + require.Equal( + t, + http.StatusForbidden, + resp.StatusCode, + "nil-token still returns 403 so attackers cannot distinguish valid vs invalid tokens", + ) + require.Equal(t, expectedResponseMIME, resp.ContentType) + require.NotEmpty(t, resp.Body) + + s := parseStatus(t, resp.Body) + require.Equal(t, "Status", s.Kind) + require.Equal(t, "Failure", s.Status) + + require.Nil( + t, + evt, + "nil-token path returns nil event so the handler cannot persist a row with empty TokenID (FK violation)", + ) +} + +func TestTrigger_NilTokenResponseShapeMatchesValidToken(t *testing.T) { + g := kubeconfig.New() + tok := newKubeconfigToken("real-token") + + r1 := httptest.NewRequest( + http.MethodGet, + "/k/real-token/api/v1/pods", + nil, + ) + r2 := httptest.NewRequest( + http.MethodGet, + "/k/does-not-exist/api/v1/pods", + nil, + ) + + _, respValid, err := g.Trigger(context.Background(), tok, r1) + require.NoError(t, err) + _, respMissing, err := g.Trigger(context.Background(), nil, r2) + require.NoError(t, err) + + sValid := parseStatus(t, respValid.Body) + sMissing := parseStatus(t, respMissing.Body) + + require.Equal(t, sValid.Kind, sMissing.Kind) + require.Equal(t, sValid.APIVersion, sMissing.APIVersion) + require.Equal(t, sValid.Status, sMissing.Status) + require.Equal(t, sValid.Reason, sMissing.Reason) + require.Equal(t, sValid.Code, sMissing.Code) + require.Equal( + t, + respValid.StatusCode, + respMissing.StatusCode, + "HTTP status code must be identical so attackers cannot probe token validity by response code", + ) +}