Summary

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

Advisory details

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/forbidden_list.go — the comparison primitive:

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/forbidden_list.go — the public entry point the webhooks call:

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/user_metadata.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/user_metadata.go → getForbiddenNodeLabels / getForbiddenNodeAnnotations call forbiddenLabels.ExactMatch(...) directly.

(The sibling allow-list primitive AllowedListSpec.ExactMatch in pkg/api/allowed_list.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):

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/forbidden_bypass_poc_test.go. The denied list is a realistic three-key adminis

References