Sandboxing untrusted code

Pods with no network at all, the secure way

The job only needs its input file and its output file, so you gave it a deny-all NetworkPolicy and went home. The policy was accepted, listed, and enforced by nobody, because the cluster's CNI never read it.

The short answer

First confirm your CNI enforces NetworkPolicy. Then select no-network pods by label with a clusterwide deny rule for all ingress and egress, which no namespace allow rule can override. For code you trust least, add a gVisor RuntimeClass with networking disabled, so the sandbox has only a loopback. Test from inside the pod every time.

Updated Houssam Hammoudi, CTOTested with Kubernetes 1.34.0 (kind), Cilium 1.20.2, gVisor release-20260921

On this page
  1. What goes wrong
  2. What the docs say
  3. The secure configuration
  4. Prove it
  5. Mistakes people make
  6. Checklist

What goes wrong

Kubernetes has no "no network" switch for a pod. Every pod gets an interface and an address from the CNI plugin. To take the network away you write a NetworkPolicy that selects the pod and allows nothing, and three things can make that policy a comment:

The CNI does not enforce policy. The API server stores NetworkPolicy objects whether or not anything implements them. The flanneld binary does not enforce them. Talos deploys Flannel by default and can add the kube-network-policies controller next to it (kubeNetworkPoliciesEnabled in its KubeFlannelCNIConfig document), but a config from talosctl gen config leaves that off. Without a controller, the policy is visible in kubectl get netpol and has no effect on packets.

Someone adds an allow rule. Kubernetes NetworkPolicies only allow. A second policy in the same namespace that allows egress to DNS or the internet quietly punches through your deny-all.

Timing. A pod created before the plugin has processed the policy may start unprotected, and the Kubernetes API does not tell you when processing is done. A connection opened before the policy existed may or may not be cut, depending on the plugin.

And even an enforced policy leaves the pod with a network stack: an interface, a route, and a kernel that parses whatever packets reach it.

What the docs say

Creating a NetworkPolicy resource without a controller that implements it will have no effect.

Source: Kubernetes docs, Network Policies

it is implementation defined as to whether the change will take effect for that existing connection or not.

Source: Kubernetes docs, Network Policies (impact on existing connections)

Deny policies take precedence over allow policies, regardless of whether they are a Cilium Network Policy, a Clusterwide Cilium Network Policy or even a Kubernetes Network Policy.

Source: Cilium docs, Deny Policies

To completely isolate the host and network from the sandbox, external networking can be disabled. The sandbox will still contain a loopback provided by netstack.

Source: gVisor docs, Networking

Kubernetes documents that an unenforced policy does nothing, but nothing warns you when you create one. gVisor's networking page shows --network=none only in Docker's daemon.json. Its containerd configuration page says any runsc flag can go under [runsc_config] in the shim's config file, which is how a separate RuntimeClass handler gets the flag.

The secure configuration

1. Check that policies are enforced. Before anything else:

bash
kubectl get pods -n kube-system -o name | grep -E 'cilium|calico|kube-router|antrea|kube-network-policies' || echo "no policy-enforcing CNI found"

If the answer is Flannel alone, NetworkPolicy does nothing on this cluster. Install an enforcing CNI, or on Talos enable kube-network-policies, before you rely on the rest of this page. The grep only finds candidates; test step 1 under Prove it is the real check.

2. A namespace-level deny-all, for any enforcing CNI.

yaml
# k8s-deny-all.yaml
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: deny-all
  namespace: batch
spec:
  podSelector:
    matchLabels:
      network.example.com/none: "true"
  policyTypes: ["Ingress", "Egress"]   # both listed, neither allowed: nothing in, nothing out

3. With Cilium, a clusterwide deny that no allow rule can undo. Deny rules win over every allow, including Kubernetes NetworkPolicies written by namespace owners.

yaml
# cilium-no-network.yaml
apiVersion: cilium.io/v2
kind: CiliumClusterwideNetworkPolicy
metadata:
  name: no-network
spec:
  endpointSelector:
    matchLabels:
      network.example.com/none: "true"   # any namespace
  ingressDeny:
    - fromEntities: ["all"]
  egressDeny:
    - toEntities: ["all"]

Create the policy before the first pod that carries the label, so no pod starts before the policy is in place.

4. For the least trusted code, no network stack at all. A second gVisor handler that starts sandboxes with --network=none. On Talos with the gVisor extension, an EtcFileConfig document writes the shim's config file and a CRICustomizationConfig document adds the handler to containerd:

yaml
# talos-runsc-nonet.yaml
# Sandbox worker pool only (needs the siderolabs/gvisor extension).
apiVersion: v1alpha1
kind: EtcFileConfig
name: runsc/nonet.toml          # written to /etc/runsc/nonet.toml; Talos rejects paths under cri/
mode: 0o644
contents: |
  [runsc_config]
    network = "none"            # passed to runsc as --network="none"; loopback only
    # allow-flag-override stays unset (default false), so pod annotations cannot change flags
---
apiVersion: v1alpha1
kind: CRICustomizationConfig
name: 30-runsc-nonet
content: |
  [plugins."io.containerd.cri.v1.runtime".containerd.runtimes.runsc-nonet]
    runtime_type = "io.containerd.runsc.v1"
  [plugins."io.containerd.cri.v1.runtime".containerd.runtimes.runsc-nonet.options]
    TypeUrl = "io.containerd.runsc.v1.options"
    ConfigPath = "/etc/runsc/nonet.toml"
yaml
# k8s-runtimeclass-nonet.yaml
apiVersion: node.k8s.io/v1
kind: RuntimeClass
metadata:
  name: gvisor-nonet
handler: runsc-nonet
scheduling:
  nodeSelector:
    sandbox.example.com/gvisor: "true"
  tolerations:
    - key: sandbox.example.com/gvisor
      operator: Exists
      effect: NoSchedule
---
apiVersion: v1
kind: Pod
metadata:
  name: convert-4821
  namespace: batch
  labels:
    network.example.com/none: "true"      # the policies above apply as well
spec:
  runtimeClassName: gvisor-nonet
  automountServiceAccountToken: false
  enableServiceLinks: false
  restartPolicy: Never
  activeDeadlineSeconds: 600
  containers:
    - name: convert
      image: ghcr.io/example/converter@sha256:0000000000000000000000000000000000000000000000000000000000000000
      securityContext:
        allowPrivilegeEscalation: false
        capabilities:
          drop: ["ALL"]
      resources:
        limits:
          cpu: "1"
          memory: 512Mi

The extension's own handlers read their config from /etc/cri/conf.d/, but Talos rejects user files under cri/, so this file lives in /etc/runsc/. The Talos docs do not say whether the containerd shim can read a user file at that path. Check it on one node before you rely on it: talosctl -n <node> read /etc/runsc/nonet.toml shows the file, and step 3 under Prove it shows whether the sandbox really started without a network. If the pod starts with an interface other than lo, the handler did not get the flag.

The pod still gets an IP from the CNI; the sandbox never uses it. HTTP probes cannot reach such a pod, so use exec probes or none. Logs and kubectl exec keep working, because they do not use the pod network.

Prove it

Run on a lab cluster (Kubernetes 1.34, Cilium 1.20.2, gVisor release-20260921) with the policies and the gvisor-nonet RuntimeClass above. The converter's placeholder image was replaced by busybox:1.37. On the lab, containerd uses the older version 2 configuration, so the runtime section was named plugins."io.containerd.grpc.v1.cri".containerd.runtimes.runsc-nonet; the Talos patch above uses the version 3 name. Real output:

1. No network through policy:

bash
kubectl -n batch run nonet-check --image=busybox:1.37 --restart=Never \
  --labels=network.example.com/none=true -- sleep 600
kubectl -n batch exec nonet-check -- wget -qO- -T 3 http://example.com/; echo "exit $?"
kubectl -n batch exec nonet-check -- nslookup kubernetes.default; echo "exit $?"
text
wget: bad address 'example.com'
command terminated with exit code 1
exit 1
;; connection timed out; no servers could be reached
command terminated with exit code 1
exit 1

2. Cilium shows the drops:

bash
hubble observe --namespace batch --pod nonet-check --verdict DROPPED --last 10
text
Sep 24 23:23:41.524: batch/nonet-check:44143 (ID:29670) <> kube-system/coredns-66bc5c9577-ssrvf:53 (ID:1734) policy-verdict:all EGRESS DENIED (UDP)
Sep 24 23:23:41.524: batch/nonet-check:44143 (ID:29670) <> kube-system/coredns-66bc5c9577-ssrvf:53 (ID:1734) Policy denied by denylist DROPPED (UDP)

Even DNS is refused: the pod has no network at all, not a network with a short allowlist.

3. No network in the sandbox itself (gvisor-nonet):

bash
kubectl -n batch exec convert-4821 -- cat /proc/net/dev
kubectl -n batch exec convert-4821 -- dmesg | head -1
kubectl -n batch exec convert-4821 -- wget -qO- -T 3 http://1.1.1.1/
text
Inter-|   Receive                                                |  Transmit
 face |bytes    packets errs drop fifo frame compressed multicast|bytes    packets errs drop fifo colls carrier compressed
    lo:       0       0    0    0    0     0          0         0        0       0    0    0    0     0       0          0
[   0.000000] Starting gVisor...
wget: can't connect to remote host (1.1.1.1): Network is unreachable
command terminated with exit code 1

Only lo exists inside the sandbox. There is nothing for a policy to forget: the network stack has no interface to send on.

Mistakes people make

Trusting kubectl get netpol

A listed policy is a stored object, not an enforced rule. Test from inside a pod after every CNI change.

A deny-all in a namespace others can edit

Anyone who can create NetworkPolicies in the namespace can add an allow rule next to yours. Use a clusterwide deny rule, or keep policy write access away from namespace owners.

Starting pods before the policy

A pod created before the plugin handles the policy can start with full network access, and the Kubernetes API gives no signal when the plugin is done. Create policies well before the pods, and in pipelines test from a labeled canary pod before creating the real ones.

Forgetting existing connections

If you add the policy to running pods, whether open connections are cut is up to the plugin. Restart the pods after tightening a policy.

HTTP probes on a pod with no network

A readiness probe over HTTP fails when the sandbox has no network, and teams "fix" it by removing the isolation. Use exec probes.

Checklist

  • The cluster runs a CNI that enforces NetworkPolicy, verified by a test pod.
  • No-network pods carry a label that a deny-all policy selects, for both ingress and egress.
  • On Cilium, a clusterwide ingressDeny and egressDeny covers that label.
  • Policies exist before the pods they protect; pods are restarted after policy changes.
  • The least trusted code runs in a gVisor handler with network = "none".
  • /proc/net/dev inside those pods lists only lo.
  • No-network pods use exec probes or none.
  • No-network pods have no ServiceAccount token and no Service links.

The only packet that cannot hurt you is the one with nowhere to go. Make sure something actually enforces the "nowhere".

H2-CSPE

Learn it on a live range

Cluster networking and policy, in Secure Platform Engineering: a real host in your browser, and every objective checked on the machine.

Start free

The Secure Way

More on sandboxing untrusted code

gVisor, pods with no network, and running other people's code without handing them your cluster.

All sandboxing untrusted code guides