Skip to content

[SECURITY] Webhook probe allows SSRF via unvalidated service target #2116

Description

@dgn

doProbe makes an HTTP GET to a URL constructed from the webhook's clientConfig.Service fields — service name and namespace come directly from the MutatingWebhookConfiguration object with no allowlist. An attacker who can create or modify webhook configurations can make the operator send HTTP requests to arbitrary cluster-internal services.

URL construction:

func getReadinessProbeURL(config admissionv1.WebhookClientConfig) (string, error) {
switch {
case config.URL != nil:
return "", errors.New("only webhooks pointing to a Service are supported")
case config.Service != nil:
svc := config.Service
port := 443
if svc.Port != nil {
port = int(*svc.Port)
}
return fmt.Sprintf("https://%s.%s.svc:%d/ready", svc.Name, svc.Namespace, port), nil
default:
return "", errors.New("no URL or Service specified in WebhookClientConfig")
}
}

HTTP request execution:

func doProbe(ctx context.Context, webhook *admissionv1.MutatingWebhookConfiguration) (bool, error) {
log := logf.FromContext(ctx)
if len(webhook.Webhooks) == 0 {
return false, errors.New("mutatingwebhookconfiguration contains no webhooks")
}
clientConfig := webhook.Webhooks[0].ClientConfig
if clientConfig.Service == nil {
return false, errors.New("missing webhooks[].clientConfig.service")
}
if len(clientConfig.CABundle) == 0 {
return false, errors.New("webhooks[].clientConfig.caBundle hasn't been set; check if the remote istiod can access this cluster")
}
caCertPool := x509.NewCertPool()
if ok := caCertPool.AppendCertsFromPEM(clientConfig.CABundle); !ok {
return false, errors.New("failed to append CA bundle to cert pool")
}
httpClient := http.Client{
Timeout: getTimeout(webhook),
Transport: &http.Transport{
DialContext: customDialContext,
TLSClientConfig: &tls.Config{
RootCAs: caCertPool,
MinVersion: tls.VersionTLS12,
},
},
}
url, err := getReadinessProbeURL(clientConfig)
if err != nil {
return false, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return false, err
}
log.V(3).Info("Executing readiness probe on remote control plane", "url", req.URL.String())
resp, err := httpClient.Do(req)
if err != nil {
log.V(3).Info("Probe failed", "error", err)
return false, err
}
log.V(3).Info("Probe response", "response", resp.StatusCode)
return resp.StatusCode == http.StatusOK, nil
}

The result is written back to annotations:

func (r *Reconciler) Reconcile(ctx context.Context, webhook *admissionv1.MutatingWebhookConfiguration) (ctrl.Result, error) {
log := logf.FromContext(ctx)
isReady, err := r.probe(ctx, webhook)
reason := ""
if err != nil {
log.V(3).Error(err, "Probe failed")
reason = err.Error()
}
if webhook.Annotations == nil {
webhook.Annotations = make(map[string]string)
}
webhook.Annotations[constants.WebhookReadinessProbeStatusAnnotationKey] = strconv.FormatBool(isReady)
webhook.Annotations[constants.WebhookReadinessProbeStatusReasonAnnotationKey] = reason
err = r.Client.Update(ctx, webhook)
if err != nil {
return ctrl.Result{}, err
}
return ctrl.Result{RequeueAfter: getPeriod(webhook)}, nil
}

Suggested Fix

Restrict target services to the expected Istio control plane namespace(s), or validate the webhook configuration before probing.


From code inspection (2026-07-05, main @ 86fac7a). Severity: 🟠 Medium

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    ai-generatedIssue generated by AI analysisbugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions