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:
parent
6f994f3756
commit
bd0c664602
|
|
@ -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 ""
|
||||||
|
}
|
||||||
|
|
@ -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",
|
||||||
|
)
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue