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