GHSA-f94q-w3w8-cj67: Medium severity go/github.com/projectcapsule/capsule vulnerability
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.
Affected Software
Remediation
Recommended actions to resolve this vulnerability, in priority order.
- Upgrade
Upgrade
go/github.com/projectcapsule/capsuleto a version that resolves this vulnerability.Fixed in 0.13.7 - Configuration
In hostname_regex.go, change h.OnUpdate’s parameter order to match the TypedHandler[T] interface/dispatcher contract (i.e., validate newTenant.Spec.IngressOptions.AllowedHostnames.Regex, not oldTenant’s). This fixes the bug where the webhook currently validates the old tenant object and ignores the compile error, allowing malformed AllowedHostnames.Regex to be persisted to etcd and causing a DoS for subsequent Ingress CREATE/UPDATE operations in the tenant.
internal/webhook/tenant/validation/hostname_regex.go OnUpdate parameter order (new vs old Tenant) = Swap the parameters so the handler validates the NEW Tenant object's AllowedHostnames.Regex
Event History
Frequently Asked Questions
Who can exploit this issue?
An attacker needs high privileges and the ability to submit an update to a Tenant object. The issue is relevant where Tenant AllowedHostnames.Regex values can be changed through the admission webhook.
What is the operational impact after a malformed regex is accepted?
The malformed regex can be persisted to etcd and cause a denial of service for all Ingress operations within the affected tenant. Confidentiality and integrity impact are not indicated by the provided severity vector.
How can I check whether a tenant is already affected?
Inspect Tenant objects for malformed AllowedHostnames.Regex values that may have been accepted during an update. Affected tenants may experience failures affecting all Ingress operations.