Key Takeaway
Kubernetes 1.36 shipped four security changes: user namespaces and fine-grained kubelet authorization reached GA, the gitRepo volume was permanently disabled, and Service externalIPs was deprecated with removal planned for v1.43. Only gitRepo breaks existing manifests immediately. The rest are opt-in or dated, so audit nodes/proxy grants, gitRepo volumes and externalIPs Services before upgrading.
The release post for Kubernetes 1.36 lists 70 enhancements, and the security work is spread across them like loose change in a coat. User namespaces in one post, kubelet authorization in another, a volume type quietly deleted in a third, an old Service field deprecated in a fourth. I wanted one page that says which of these four can break a manifest on upgrade day and which are only opportunities.
This is a read of the primary sources: the official 1.36 release post, the sneak peek, the three feature posts from the Kubernetes blog, and the current docs pages for user namespaces, kubelet authorization and volumes. I did not run any of this on a cluster for this post, so every number and behaviour below is attributed to the page it came from, and the scripts are mine to adapt, not benchmarks. It is deliberately narrower than the broad 1.37 release read on this site: it covers only what 1.36 did to your attack surface.
Four changes, one theme: stop trusting the default
The 1.36 release announcement counts 70 enhancements: 18 graduated to Stable, 25 entering Beta and 25 in Alpha, and it points readers at the deprecations and removals separately. Four of the security items fit one pattern. Each one takes something that used to be broad by default, whether root in a container, a read-only looking kubelet permission, a volume type that ran git as root on the node, or a Service field that lets any user claim any IP, and replaces it with something narrower.
The table is the whole post in one screen. The important column is the third: only one of the four changes an existing workload on the day you upgrade.
| Change | State in 1.36 | Effect on existing clusters | Your action |
|---|---|---|---|
| User namespaces | Stable. Gate UserNamespacesSupport is locked. Opt in per pod with hostUsers set to false | None until a pod opts in | Pilot on a node pool that meets the kernel and runtime minimums |
| Fine-grained kubelet authorization | Stable. Gate KubeletFineGrainedAuthz is locked to enabled | None. Existing nodes/proxy grants keep working through a fallback | Move monitoring RBAC to narrow subresources, then remove nodes/proxy |
| gitRepo volume | Permanently disabled, cannot be turned back on | Pods that declare a gitRepo volume break | Find them before the upgrade and move the clone into an init container |
| Service externalIPs | Deprecated. Warnings now, removal planned for v1.43 | Still works, but emits warnings | Inventory it, plan a replacement, block new use with an admission controller |
Fine-grained kubelet authorization: get on nodes/proxy was never read-only
The most useful change for most platform teams is the least glamorous. The kubelet exposes an HTTPS API on the node with pod listings, metrics, logs and the ability to execute commands inside containers. With webhook authorization on, almost every path used to map to a single nodes/proxy subresource, so a monitoring agent that only wanted metrics needed the same permission that can exec into any container on the node.
The kubelet authentication and authorization page now says it plainly: some of these endpoints use Websocket protocols over an HTTP GET, which is authorized with the get verb, so get on nodes/proxy is not a read-only permission and authorizes executing commands in any container on the node. The Kubernetes blog post on the GA adds that this was demonstrated in early 2026 and that the pattern appears in the default RBAC of dozens of widely deployed Helm charts. The feature itself came in as alpha in v1.32, went to beta enabled by default in v1.33, and is GA in v1.36 under KEP-2862.
How it works: for the listed paths the kubelet sends a SubjectAccessReview for the specific subresource first, and only if that fails does it retry with the coarse nodes/proxy for backward compatibility. That fallback is why nothing breaks on upgrade, and it is also why nothing gets safer until you remove the old grant.
| Kubelet path | Subresource checked first | What it means for RBAC |
|---|---|---|
/stats/* | nodes/stats | Read stats without any exec capability |
/metrics/* | nodes/metrics | The grant a Prometheus-style scraper actually needs |
/logs/* | nodes/log | Log access, again without exec |
/pods | nodes/pods, then nodes/proxy | Falls back to nodes/proxy if the narrow check fails |
/healthz | nodes/healthz, then nodes/proxy | Same dual check, fine-grained first |
/exec/* and all others | nodes/proxy | Everything else, including exec, still needs nodes/proxy |
# Wrong: what most monitoring charts shipped. "get" on nodes/proxy is not
# read-only, because a Websocket handshake is an HTTP GET to the kubelet.
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: monitoring-agent
rules:
- apiGroups: [""]
resources: ["nodes/proxy"]
verbs: ["get"]
---
# Right: only what a scraper reads. Nothing here can exec into a container.
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: monitoring-agent
rules:
- apiGroups: [""]
resources: ["nodes/metrics", "nodes/stats"]
verbs: ["get"]
# Find the ClusterRoles that still grant nodes/proxy (or a wildcard that
# includes it). Built-in admin roles will show up; read the rest.
kubectl get clusterroles -o json | jq -r '
.items[]
| select(any(.rules[]?; any((.resources // [])[];
. == "nodes/proxy" or . == "nodes/*" or . == "*")))
| .metadata.name'
# Confirm the gate is on, from a pod whose ServiceAccount may read nodes/metrics.
# The docs print: kubernetes_feature_enabled{name="KubeletFineGrainedAuthz",stage="GA"} 1
curl -sk -H "Authorization: Bearer $TOKEN" https://$NODE_IP:10250/metrics \
| grep kubernetes_feature_enabled | grep KubeletFineGrainedAuthzThe docs also note that the built-in system:kubelet-api-admin ClusterRole is updated to cover the new subresources, so the API server's own kubelet client is unaffected whatever the gate state. The Kubernetes post suggests the longer-term direction is ecosystem adoption, where agents such as Prometheus and Datadog ship narrower default RBAC, and policy engines that flag nodes/proxy bindings when a fine-grained alternative exists. That is a prediction on their part, not something I have verified in any chart.
A narrow grant added next to an old nodes/proxy grant removes nothing. The kubelet checks the narrow subresource first and falls back to nodes/proxy, so a compromised agent that still holds nodes/proxy can still exec. The risk only drops when the old grant is deleted.
User namespaces: root in the container stops being root on the node
User namespaces went GA in 1.36, and the docs list the UserNamespacesSupport gate as first available in v1.28. The problem it fixes is stated in the Kubernetes blog: a process running as root inside a container is seen by the kernel as root on the host, so a breakout through a kernel bug or a bad mount lands as root. With hostUsers set to false the container still believes it is root, so tools such as apt and dnf keep working, but the host sees a non-privileged user, and the kubelet picks a mapping that no other pod on the node shares.
The docs say several vulnerabilities rated HIGH or CRITICAL were not exploitable with user namespaces active, and that capabilities such as CAP_SYS_ADMIN are limited to the pod's own user namespace. Volume ownership is the part that took years: ID-mapped mounts, introduced in Linux 5.12, let the kernel remap ownership at mount time instead of the kubelet recursively chowning a large volume. Those mounts are the reason the node requirements below are what they are.
| Component | Minimum per the docs | Why it matters |
|---|---|---|
| Linux kernel | 6.3 | tmpfs gained idmap mount support in 6.3, and service account tokens and Secrets use tmpfs |
| runc | 1.2 | The OCI runtime must support user namespaces |
| crun | 1.9 (1.13+ advised) | The docs recommend 1.13 or newer |
| containerd | 2.0 | The CRI runtime must support them too |
| CRI-O | 1.25 | The other supported CRI runtime in the docs |
The pod side is one field. The node side has one trap worth knowing before you enable it widely: by default the kubelet gives pods IDs above the 0 to 65535 range, which avoids overlap with host users, and only if you want a custom range do you need a kubelet user, getsubids and subordinate ID files. The docs require the range to start at a multiple of 65536 that is at least 65536, and the count to be at least 65536 times your maxPods.
# Pod side: one field. The container still sees uid 0, the host does not.
apiVersion: v1
kind: Pod
metadata:
name: isolated-workload
spec:
hostUsers: false # the opt-in; hostNetwork/hostPID/hostIPC must stay unset
containers:
- name: app
image: fedora:42
securityContext:
runAsUser: 0
# Node side, preflight. None of this is needed for the default ID range.
uname -r # needs 6.3+ for idmap mounts on tmpfs
runc --version # 1.2+, or: crun --version (1.9+)
containerd --version # 2.0+
# Only if you want a CUSTOM range: a user literally named "kubelet", and
# getsubids (shadow-utils) on the kubelet's PATH. Format is name:firstID:count.
# 65536 is the smallest legal start; 110 pods * 65536 = 7208960.
# /etc/subuid and /etc/subgid, one line each, identical:
kubelet:65536:7208960
# KubeletConfiguration, v1.33+: IDs per pod, a multiple of 65536 (default 65536).
userNamespaces:
idsPerPod: 1048576
# Verify: "id" inside the container says uid=0, but the process on the host
# (ps -o user= -p <host pid>) must NOT be root.
kubectl exec isolated-workload -- idWith hostUsers set to false, capabilities such as CAP_NET_ADMIN become namespaced. The Kubernetes blog calls out that this enables workloads that previously needed a fully privileged container, which is worth a look if you run network tooling in pods.
gitRepo is gone, and the migration is an init container
The gitRepo volume type has been deprecated since v1.11. In 1.36 the plugin is permanently disabled and cannot be turned back on, because using it could let an attacker run code as root on the node. The current volumes docs, written for 1.37, say the last version that provided a way to use the driver was v1.35, which is consistent: there is no flag to flip back.
Both the release post and the volumes docs give the same replacement: mount an emptyDir into an init container that clones the repository, then mount the same emptyDir into the application container. The release post also names git-sync style tools for the case where the content must keep updating. The cost is real, and worth stating: gitRepo needed no image of your own, while an init container needs an image with git in it and a decision about credentials.
Do the scan before the upgrade, because a Deployment whose pod template still declares gitRepo will create pods that fail. The ValidatingAdmissionPolicy expression at the end of the block is taken from the volumes docs and rejects any new use.
# Before: removed in 1.36. A pod declaring this will not start.
volumes:
- name: src
gitRepo:
repository: https://git.example.com/team/site.git
revision: v1.4.0
# After: clone once in an init container, share through emptyDir.
spec:
volumes:
- name: src
emptyDir: {}
initContainers:
- name: clone
image: registry.example.com/tools/git:PINNED_TAG # your own pinned image
args: ["clone", "--depth=1", "--branch", "v1.4.0",
"https://git.example.com/team/site.git", "/src"]
volumeMounts:
- name: src
mountPath: /src
containers:
- name: app
image: registry.example.com/team/site:1.4.0
volumeMounts:
- name: src
mountPath: /srv/site
readOnly: true
# Find offenders in running objects AND controller templates. A scaled-to-zero
# Deployment has no pods but will fail the moment it scales up.
kubectl get pods,deploy,sts,ds,rs,job,cronjob -A -o json | jq -r '
.items[]
| select([.. | objects | select(has("gitRepo"))] | length > 0)
| .kind + " " + .metadata.namespace + "/" + .metadata.name'
# Block re-introduction: CEL for a ValidatingAdmissionPolicy, from the volumes docs.
!has(object.spec.volumes) || !object.spec.volumes.exists(v, has(v.gitRepo))externalIPs: deprecated now, three dated steps
The externalIPs field on a Service was an early attempt at load-balancer behaviour for non-cloud clusters. The Kubernetes blog says the API assumes every user in the cluster is fully trusted, which makes man-in-the-middle exploitation possible where that is not true, as described in CVE-2020-8554. The project has recommended disabling it since 1.21 and added the DenyServiceExternalIPs admission controller for that purpose, but judged blocking it by default too large a break at the time.
The blog gives a rough timeline, and note the wording: v1.40 at the earliest for kube-proxy to stop supporting it with a way to opt back in, then v1.43 at the earliest for full removal with no way back. The release post states removal as planned for v1.43. Only the field named .spec.externalIPs is affected, not the Node address type called ExternalIP and not the EXTERNAL-IP column that kubectl prints for LoadBalancer Services.
- 1.36: field deprecated, warnings on use
- 1.40 at the earliest: kube-proxy support disabled, with an opt-in to restore it
- 1.43 at the earliest: support removed entirely, no opt-in
| Replacement | What the post says it gives you | The catch |
|---|---|---|
| Hand-managed LoadBalancer | Same behaviour, but the IP lives in status, which ordinary users cannot edit under default RBAC | The post calls it the easiest and worst option: permitted users can still replicate CVE-2020-8554, with no check that one user is not stealing another's IP |
| A load balancer controller such as MetalLB | The admin defines the address pools and the controller stops two Services sharing an IP | A new component to run, and loadBalancerIP, which MetalLB still honours, is itself a deprecated field |
| Gateway API | A Gateway resource carries the address, and RBAC can restrict who manages Gateways | You move from Service to Gateway and HTTPRoute, and you need a Gateway implementation installed |
# Inventory: every Service that still sets spec.externalIPs.
kubectl get svc -A -o json | jq -r '
.items[]
| select((.spec.externalIPs // []) | length > 0)
| .metadata.namespace + "/" + .metadata.name + " " + (.spec.externalIPs | join(","))'
# Block new use now. Append to your EXISTING plugin list, do not replace it.
# kube-apiserver flag:
--enable-admission-plugins=DenyServiceExternalIPs
# Replacement with MetalLB: the admin owns the pool, users just ask for a LoadBalancer.
apiVersion: metallb.io/v1beta1
kind: IPAddressPool
metadata:
name: production
namespace: metallb-system
spec:
addresses:
- 192.0.2.0/24
autoAssign: trueIf you do not set the field anywhere, the post says the deprecation does not apply to you, but it still suggests enabling DenyServiceExternalIPs as a precaution against future use. That is a one-line change worth making on a cluster where tenants can create Services.
Where this goes wrong: five failure modes the docs warn about
None of the four changes is dangerous on its own. The failures come from the gaps between them and your cluster, and every one below is stated in the sources rather than observed by me.
- Adding narrow RBAC without removing nodes/proxy. You have a better manifest and the same exposure, because of the fallback.
- Setting hostUsers to false on a pod that needs hostNetwork, hostIPC, hostPID or raw block volumeDevices. The docs disallow all four combinations.
- Enabling user namespaces on nodes without the filesystem support. The node filesystem used for the kubelet pods directory and every volume the pod mounts must support idmap mounts, which in practice means Linux 6.3 or newer.
- Changing subordinate ID ranges on a live node. The docs say to drain first, because the kubelet fails to start if it cannot honour the new configuration for existing user-namespace pods.
- Reading hostUsers false as a replacement for Pod Security. Kubernetes relaxes the runAsNonRoot and runAsUser checks for such pods even under Baseline and Restricted, on the reasoning that root inside never maps to a privileged host user. That is sound, but it means a policy audit that counts runAsNonRoot will now miss isolated pods.
The honest trade is that user namespaces buy defence in depth at the price of node requirements and a new class of scheduling failure, while the other three changes cost you migration work and buy a smaller blast radius immediately.
gitRepo is the only change here that fails loudly on upgrade day. If your pre-upgrade scan finds a gitRepo volume in a Deployment, StatefulSet or CronJob template, fix it first. A running pod may not show up in a pod-only scan if its controller has since been scaled to zero.
What to do on Monday
Six steps, in the order that gets the cheapest risk reduction first. The script below does the read-only audit for steps one to three and prints nothing destructive.
- Run the audit script against each cluster, before any upgrade, and attach the output to the change ticket.
- Replace every gitRepo volume with an emptyDir plus an init container, and deploy that change on the version you are running today.
- Add DenyServiceExternalIPs to the API server admission plugins, then open tickets for each Service the audit found with externalIPs.
- Rewrite monitoring ClusterRoles to nodes/metrics, nodes/stats and nodes/pods, roll them out, and only then delete the nodes/proxy rules.
- Check one node pool against the user namespaces requirements, and pilot hostUsers false on a stateless workload there.
- Add the gitRepo ValidatingAdmissionPolicy so the volume type cannot be re-introduced by a stale chart.
#!/usr/bin/env bash
# k8s-136-audit.sh: read-only. Run per cluster, attach output to the change ticket.
set -euo pipefail
echo "== ClusterRoles granting nodes/proxy (built-ins included) =="
kubectl get clusterroles -o json | jq -r '
.items[]
| select(any(.rules[]?; any((.resources // [])[];
. == "nodes/proxy" or . == "nodes/*" or . == "*")))
| .metadata.name'
echo "== Objects declaring a gitRepo volume =="
kubectl get pods,deploy,sts,ds,rs,job,cronjob -A -o json | jq -r '
.items[]
| select([.. | objects | select(has("gitRepo"))] | length > 0)
| .kind + " " + .metadata.namespace + "/" + .metadata.name'
echo "== Services using spec.externalIPs =="
kubectl get svc -A -o json | jq -r '
.items[]
| select((.spec.externalIPs // []) | length > 0)
| .metadata.namespace + "/" + .metadata.name'
echo "== Node kernels (user namespaces need 6.3+) =="
kubectl get nodes -o custom-columns=NAME:.metadata.name,KERNEL:.status.nodeInfo.kernelVersion,RUNTIME:.status.nodeInfo.containerRuntimeVersionSteps four and five are the ones that tend to slip, because nothing breaks if you skip them. That is the argument for scheduling them.
Rule I would carry out of this release: read the verb, not the name. A get on nodes/proxy sounded read-only for years and was not, a root user in a container sounded contained and was not, and a field called externalIPs sounded like plumbing and was a trust assumption. Kubernetes 1.36 gave you narrower tools for each, but only gitRepo forces the migration. Do the rest on your schedule, before someone else's incident sets it.
Sources and further reading
- Kubernetes blog: Kubernetes v1.36 release announcement (Haru)
- Kubernetes blog: v1.36 sneak peek, removals and deprecations
- Kubernetes blog: User Namespaces in Kubernetes are finally GA
- Kubernetes blog: Fine-Grained Kubelet API Authorization graduates to GA
- Kubernetes blog: Deprecation and removal of Service ExternalIPs
- Kubernetes docs: User Namespaces
- Kubernetes docs: Kubelet authentication and authorization
- Kubernetes docs: Volumes, including the disabled gitRepo driver

