feat(canary): kubeconfig fake K8s API handler (forbidden response)

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:
      <resource> is forbidden: User "system:anonymous" cannot <verb>
      resource "<resource>" in API group "" in the namespace "default"
    where <resource> is the last segment of r.URL.Path and <verb> 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.
This commit is contained in:
CarterPerez-dev 2026-05-13 14:22:19 -04:00
parent 6f994f3756
commit bd0c664602
2 changed files with 719 additions and 0 deletions

View File

@ -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 ""
}

View File

@ -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",
)
}