A monitoring agent with nodes/proxy GET — the permission most people grant believing it's "read metrics, nothing else" — can be turned into a shell inside any pod on the node. Not through a bug in the agent, and not through a leaked token with more scope than intended. The permission itself, used exactly as designed, gets you there: send the kubelet's /exec endpoint a WebSocket handshake instead of a normal HTTP request, and the kubelet authorizes the GET that opens the connection — not the command execution that follows over it. Security researchers reported this to the Kubernetes Security Team in November 2025. The team's answer was blunt: working as intended, no CVE, because the real fix means the kubelet and the API server both re-checking authorization mid-handshake — "double authorization" they call architecturally wrong to bolt on after the fact.
Kubernetes 1.36, GA'd April 22, 2026, is that real fix — for part of the problem. KubeletFineGrainedAuthz graduated from a beta feature gate to locked-on-by-default, splitting the kubelet's one all-purpose nodes/proxy permission into eight new, narrow subresources. A metrics scraper can now hold a permission that reads metrics and nothing else. That's the win, and it's real. But /exec, /attach, /portforward, and /run — the four kubelet endpoints that actually run commands inside a container — got none of it. They're still gated by the same broad nodes/proxy permission they always were, for the same architectural reason the WebSocket bug won't get a CVE. If your multi-tenant platform runs anything that legitimately needs to exec into a container, 1.36 doesn't shrink that blast radius at all. Here's exactly what changed, what didn't, and the RBAC audit that follows from both.
Why nodes/proxy became a junk-drawer permission
The kubelet exposes an HTTPS API on every node — /stats, /metrics, /logs, /healthz, /configz, /pods, and the interactive set (/exec, /attach, /portforward, /run). Until 1.36, almost all of it sat behind one RBAC resource: nodes/proxy. A Prometheus-style scraper reading /stats/summary needed nodes/proxy. So did a health-check aggregator hitting /healthz. So did kubectl exec. There was no way to grant one without implicitly granting all of it, because RBAC doesn't see inside the URL path — it sees a single resource name on the nodes object, and proxy was the only subresource most of the API answered to.
That's the shape of the bug researchers exploited: a nodes/proxy GET scope, intended for a metrics dashboard, doubles as everything the kubelet can do, including running arbitrary commands. KEP-2862, the enhancement proposal behind this release, calls this out directly as a decade-old least-privilege gap — not a new discovery, but one the WebSocket exec case made impossible to keep deferring.
The fix shipped in stages, not all at once in 1.36:
| Kubernetes version | KubeletFineGrainedAuthz status |
|---|---|
| v1.32 (late 2024) | Alpha, opt-in via feature gate |
| v1.33 (2025) | Beta, enabled by default |
| v1.36 "Haru" (April 22, 2026) | GA, feature gate locked to true — can no longer be disabled |
The upgrade path is deliberately non-breaking: the kubelet checks the fine-grained subresource first, and if that check fails — because an existing ClusterRole was never updated — it falls back to the old nodes/proxy check. Nothing granted under the old model stops working the day you upgrade. The fallback exists so migration is opt-in on the RBAC side, which also means nothing forces you to actually narrow your existing grants. That part's on you.
What actually got split — and what deliberately didn't
Kubernetes' own v1.36 release notes lay out the full API-path-to-subresource mapping. Eight kubelet API groups get their own dedicated RBAC subresource for the first time:
| Kubelet API path | New RBAC subresource |
|---|---|
/stats/* | nodes/stats |
/metrics/* | nodes/metrics |
/logs/* | nodes/log |
/pods, /runningPods/ | nodes/pods |
/healthz | nodes/healthz |
/configz | nodes/configz |
/spec/* | nodes/spec |
/checkpoint/* | nodes/checkpoint |
Add those eight to the nodes/proxy permission that's still there for everything else, and that's the "nine" in this post's title: one coarse-grained catch-all where there used to be exactly one option, now sitting alongside eight ways to grant only the specific thing a caller actually needs.
But read that table again for what's missing. /exec, /attach, /portforward, and /run — the endpoints that start a process, attach to one, forward a port into a container, or run an arbitrary command — have no fine-grained subresource. They stay mapped to nodes/proxy, exactly as before 1.36. This isn't an oversight the project plans to close in 1.37; it's the same tradeoff the security team made when they declined a CVE for the WebSocket bug. Splitting exec into its own subresource would still leave the authorization decision made at the WebSocket GET handshake, before the actual command is known — the kubelet would need to re-authorize partway through an already-open connection, which is the "brittle and architecturally incorrect" double-check the team ruled out. GA closes the observability gap. It does not touch the interactive-access gap, and nothing in the KEP suggests that's changing soon.
The migration checklist: four services, one honest exception
Every self-hosted multi-tenant PaaS runs some version of the same four in-cluster service categories against the kubelet API. Here's the before/after for each — and the one that can't be narrowed no matter which RBAC subresource you reach for.
1. Metrics/stats scraper (Prometheus node-exporter-style collectors, cAdvisor-based dashboards):
# Before — grants exec as a side effect of granting metrics
rules:
- apiGroups: [""]
resources: ["nodes/proxy"]
verbs: ["get"]
# After — reads stats and metrics, nothing else
rules:
- apiGroups: [""]
resources: ["nodes/stats", "nodes/metrics"]
verbs: ["get"]2. Health-check aggregator (a controller polling kubelet liveness across the fleet):
rules:
- apiGroups: [""]
resources: ["nodes/healthz"]
verbs: ["get"]3. Log shipper (anything pulling /logs directly from the kubelet rather than through the container runtime's log driver):
rules:
- apiGroups: [""]
resources: ["nodes/log"]
verbs: ["get"]4. The one component that legitimately execs — a "stream my build output" or "shell into my container" feature, which is table stakes for a platform that runs a git push-to-running-container pipeline:
# No fine-grained option exists — this is the honest exception
rules:
- apiGroups: [""]
resources: ["nodes/proxy"]
verbs: ["get", "create"]That fourth grant is exactly as broad after 1.36 as it was before. GA doesn't shrink it, so the only lever left is compensating controls around it, not narrower RBAC:
- Bind it to nothing else. The
ServiceAccountholdingnodes/proxyshould have zero other cluster-scoped grants — no read access to secrets, no list on pods across namespaces — so a compromise of that one component doesn't cascade. - Short-lived, per-request tokens instead of a long-lived
ServiceAccounttoken mounted into a pod that stays up for weeks. A token that expires in minutes shrinks the exploitation window even though it doesn't shrink the permission itself. - Audit every call. Log the source pod/user and the target node/container for every
nodes/proxyrequest that hits/execor/attach, and alert on any caller invoking it outside the one code path that's supposed to issue it. Since RBAC can't distinguish "the platform's shell feature" from "arbitrary exec" at the permission level, the audit log is the only place that distinction gets made. - Namespace the binding, not just the role. A
RoleBindingscoped to the specific tenant namespace the exec request targets is still coarser than the API deserves, but it's strictly better than a cluster-wideClusterRoleBindingthat can reach every tenant's containers from one credential.
Applying this to a self-hosted multi-tenant PaaS's control plane
For a platform running Cluster API-managed clusters with genuinely multiple tenants sharing node capacity, this is worth treating as a completed audit item, not a someday cleanup. Pull every ClusterRole and ClusterRoleBinding that currently grants nodes/proxy, and sort them into the four buckets above. Most of them — the metrics path, the health checks, the log collection — have zero reason to still hold exec-capable permissions after this migration, and 1.36's dual-authorization fallback means you can cut over one ClusterRole at a time without a flag day. What's left over, the genuinely exec-needing component, gets the compensating controls instead of a false sense of security from a permission name that sounds narrower than it is.
That's the same posture Bex.co takes with its own control plane: the platform's build and deploy pipeline is the one component that needs to reach into a tenant's container, and everything else — the metrics collectors, the health probes that decide when to reschedule a Machine — runs on the narrowest kubelet subresource that does the job, audited and scoped to nothing more. Bex.co is the open-source, AI-native Render alternative — push a git repo, get a running HTTPS service on machines you own, orchestrated by Cluster API on hardware you actually control. Star the repo on GitHub or deploy your first app today.
What's still open
The interactive kubelet API — exec, attach, portforward, run — is the permission the WebSocket bug actually exploited, and it's the one piece 1.36 leaves exactly where it found it. That's not a knock on this release; splitting eight read-and-observe paths out of a single catch-all closes a real, decade-old gap for the overwhelming majority of services that only ever needed to read the kubelet, not command it. But if your threat model includes "what happens if the metrics scraper's credentials leak," the honest answer after 1.36 is: nothing, because that credential can no longer reach /exec. If your threat model includes "what happens if the component that legitimately execs gets compromised," the answer is unchanged from before this release, and no RBAC subresource is coming to fix it. Audit your ClusterRoles for nodes/proxy this week — most of the grants you find won't need to stay that broad, and the one that does needs the compensating controls, not a false sense of security from a version bump.
Sources:
- Kubernetes v1.36: Fine-Grained Kubelet API Authorization Graduates to GA
- Kubernetes v1.36: Haru release announcement
- KEP-2862: Fine-grained Kubelet API Authorization
- Kubernetes Remote Code Execution Via Nodes/Proxy GET Permission
- Kubernetes nodes/proxy RCE: When Monitoring Becomes an Attack Vector
- When "Read-Only" Isn't: K8s nodes/proxy GET to RCE



