You Shall Not Pass – Segmenting Pod Traffic with NetworkPolicies

Duration: ~15 minutes

Overview

Move from open pod-to-pod traffic to NetworkPolicies that deny by default and allow only the flows you need.

Show open pod-to-pod traffic, install a default-deny NetworkPolicy, add a narrow allow rule, and confirm which flows still work.

Why it matters

By default, pods in a namespace can usually reach each other. If one pod is compromised, that becomes a free path to databases and internal APIs. NetworkPolicies flip the model: deny first, then allow only the clients and ports you choose. That limits how far an incident spreads and gives responders a concrete answer to what a pod was allowed to talk to.

What does it solve

  • Prevents every compromised pod becoming a pivot

  • Reduces accidental exposure of internal services

  • Eases incident scoping (“what could it reach?”)

  • Supports regulated segmentation requirements

Your Mission

A foothold in any pod can scan sideways to databases and internal APIs when east–west traffic is open.

Your mission: prove the open path exists, slam the door with default deny, then reopen only the client you trust.

Click each step only if you need a hint.

Create project 101-05-n-netpol-demo

Stay in this project for every command. You will add an API and two clients before any NetworkPolicy exists.

oc new-project 101-05-n-netpol-demo
Now using project "101-05-n-netpol-demo".
Deploy api on port 8080 and expose the Service

Python http.server is the stand-in API. Expose it so clients can use the Service IP.

oc create deployment api --image=registry.access.redhat.com/ubi9/python-311 -- python3 -m http.server 8080
oc expose deployment api --port=8080 --target-port=8080
oc get svc api should show port 8080.
Run client-allowed and client-denied

Two sleep pods with different role labels. Labels do not matter until you add a NetworkPolicy.

oc run client-allowed --image=registry.access.redhat.com/ubi9/ubi -l role=allowed -- sleep infinity
oc run client-denied --image=registry.access.redhat.com/ubi9/ubi -l role=denied -- sleep infinity
oc get pods should show both clients Running.
Save the api Service IP as SVC

Later curl commands use $SVC. Recapture it if you open a new terminal.

SVC=$(oc get svc api -o jsonpath='{.spec.clusterIP}')
echo "$SVC"
Echo should be a cluster IP, not empty.
Curl the API from both clients with no NetworkPolicy

Both clients should reach the API. That is the blast radius you must shrink.

oc exec client-allowed -- curl -s -o /dev/null -w '%{http_code}\n' http://$SVC:8080
oc exec client-denied -- curl -s -o /dev/null -w '%{http_code}\n' http://$SVC:8080
Both commands print 200. That is the correct result—there is no NetworkPolicy yet, so every pod can call the API.
200
200

What to explain: labels do not matter yet. role=allowed and role=denied are only names. Either pod can reach the API, so a foothold in any client is a pivot.

flowchart LR
  CA["client-allowed<br/>role=allowed"]
  CD["client-denied<br/>role=denied"]
  API["api :8080"]
  CA -->|"curl → 200"| API
  CD -->|"curl → 200"| API
Apply NetworkPolicy default-deny-ingress

Close every inbound door. Compromised pods should time out instead of probing freely.

oc apply -f - <<'EOF'
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: default-deny-ingress
spec:
  podSelector: {}
  policyTypes:
  - Ingress
EOF
networkpolicy.networking.k8s.io/default-deny-ingress created.
Confirm default-deny-ingress exists
oc get networkpolicy default-deny-ingress
POD-SELECTOR should be <none> (selects all pods).
Curl the API from both clients after default deny

Both clients should time out. Good — lateral movement is blocked until you explicitly reopen a path.

oc exec client-allowed -- sh -c "curl -s --max-time 3 -o /dev/null http://$SVC:8080" >/dev/null 2>&1 || echo TIMEOUT
oc exec client-denied -- sh -c "curl -s --max-time 3 -o /dev/null http://$SVC:8080" >/dev/null 2>&1 || echo TIMEOUT
Both commands print TIMEOUT. That is the correct result—default deny closed inbound traffic for every pod, including the client you will trust next.
TIMEOUT
TIMEOUT

What to explain: a default-deny Ingress policy selects all pods in the namespace. Timeouts are the win. You have not broken DNS or the API process—you blocked the path.

flowchart LR
  CA["client-allowed<br/>role=allowed"]
  CD["client-denied<br/>role=denied"]
  API["api :8080"]
  CA -.->|"curl → TIMEOUT"| API
  CD -.->|"curl → TIMEOUT"| API
Apply NetworkPolicy allow-api-from-allowed

Allow TCP/8080 solely from pods labeled role=allowed. Everything else stays locked out.

oc apply -f - <<'EOF'
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: allow-api-from-allowed
spec:
  podSelector:
    matchLabels:
      app: api
  ingress:
  - from:
    - podSelector:
        matchLabels:
          role: allowed
    ports:
    - protocol: TCP
      port: 8080
  policyTypes:
  - Ingress
EOF
networkpolicy.networking.k8s.io/allow-api-from-allowed created.
List NetworkPolicies in the project
oc get networkpolicy
You should see both default-deny-ingress and allow-api-from-allowed.
Curl from client-allowed then client-denied

Allowed client returns 200; denied client times out. Business traffic works; the untrusted client does not.

oc exec client-allowed -- curl -s -o /dev/null -w '%{http_code}\n' http://$SVC:8080
oc exec client-denied -- sh -c "curl -s --max-time 3 -o /dev/null http://$SVC:8080" >/dev/null 2>&1 || echo BLOCKED
Expected output is 200 then BLOCKED. The trusted client is back; the untrusted client is still dark.
200
BLOCKED

What to explain: labels now enforce the boundary. The allow rule matches role=allowed to app=api on TCP/8080 only. role=denied has no matching ingress rule, so it still times out.

flowchart LR
  CA["client-allowed<br/>role=allowed"]
  CD["client-denied<br/>role=denied"]
  API["api :8080"]
  CA -->|"curl → 200"| API
  CD -.->|"curl → BLOCKED"| API
Delete project 101-05-n-netpol-demo

Optional if you will run the module cleanup script at the bottom instead.

oc delete project 101-05-n-netpol-demo --wait=false
The project should show Terminating.

Debrief

You started from a flat, open namespace network, applied default-deny ingress, then reopened only the trusted client path—shrinking east-west blast radius.

What breaks without this:

  • No NetworkPolicy → every pod can reach every other pod by default

  • Over-broad labels → accidental allows for the wrong workloads

  • Missing egress rules when needed → exfil or C2 paths stay open

default-deny in sensitive namespaces, smallest allow set that keeps the app working, consistent app/role/tier/env labels, and validation of allow vs deny paths.
policies select pods; once selected, only explicit allows pass. CNI enforces at the pod interface—not a substitute for node firewalls. Optional pattern notes below can help you design the next rules.

Extra: Network Policy Patterns (Reading)

Details

Reference patterns adapted from docs/network-policies.adoc. Use them to reason about design; keep YAML only in the hands-on section above.

Each pattern includes an explanation, use case, risk, implementation checklist, and a mermaid sketch.

DENY all non-whitelisted traffic to a namespace

flowchart LR
  subgraph ns_other ["namespace other"]
    Blog[app=blog]
  end
  subgraph ns_default ["namespace default"]
    API[app=api]
    Guest[app=guestbook]
  end
  Blog -.-> Guest
  Blog -. ❌ .-> API
  API -. ❌ .-> Guest

Only an approved cross-namespace flow (blog → guestbook) is permitted; other cross or internal flows are blocked.

  • Use case: multi-tenant cluster; restrict which external namespace may call a frontend.

  • Why it matters: reduces lateral movement between namespaces.

  • Checklist: NetworkPolicy selecting protected pods; ingress from with namespaceSelector + podSelector; specify ports; policyTypes: [Ingress].

LIMIT traffic to an application

flowchart LR
  Coffee[app=coffeeshop\\nrole=api]
  BookAPI[app=bookstore\\nrole=api]
  BookFE[app=bookstore\\nrole=frontend]
  BookAPI -.-> BookFE
  Coffee -. ❌ .-> BookAPI

Frontend (role=frontend) may call the bookstore API; other APIs are denied.

  • Use case: intra-namespace microservice boundaries.

  • Why it matters: prevents accidental or malicious calls to internal APIs.

  • Checklist: podSelector for API pods; ingress from frontend label; restrict ports; policyTypes: [Ingress].

DENY all traffic from other namespaces

flowchart LR
  subgraph ns_foo ["namespace: foo"]
    FooPod[Any Pod]
  end
  subgraph ns_default ["namespace: default"]
    Web[app=web]
    DB[app=db]
  end
  subgraph ns_bar ["namespace: bar"]
    BarPod[Any Pod]
  end
  Web -.-> DB
  DB -.-> Web
  FooPod -. ❌ .-> Web
  FooPod -. ❌ .-> DB
  BarPod -. ❌ .-> Web
  BarPod -. ❌ .-> DB

Only in-namespace communication is permitted.

  • Use case: tenant isolation; environment boundary.

  • Why it matters: prevents privilege creep and meets audit separation requirements.

  • Checklist: policy selecting web and db; ingress limited to the same namespace (no namespaceSelector) unless you add a trusted namespace.

ALLOW traffic only to a metrics port

flowchart LR
  Prom[app=prometheus\\nrole=monitoring]
  subgraph API ["app=api"]
    Metrics[":5000 (metrics)"]
    HTTP[":8000 (http)"]
  end
  Prom -.-> Metrics
  Prom -. ❌ .-> HTTP

Prometheus may scrape the metrics port; the general HTTP port is blocked.

  • Use case: observability access minimization.

  • Why it matters: monitoring credentials cannot reach non-observability endpoints.

  • Checklist: ingress from monitoring pods; allow port 5000 only.

DENY external egress traffic

flowchart LR
  subgraph ns_default ["namespace: default"]
    App1[app=web]
    App2[app=db]
  end
  External[External services / Internet]
  App1 -.-> App2
  App2 -.-> App1
  App1 -. ❌ .-> External
  App2 -. ❌ .-> External

Internal communication is allowed; outbound to external networks is denied.

  • Use case: regulated workloads (PCI, OT) that need strict egress control.

  • Why it matters: reduces exfil and command-and-control callbacks.

  • Checklist: egress policy; allow only explicit internal destinations (DNS, logging); policyTypes: [Egress].

DENY all inbound to an application (except a specific source)

flowchart LR
  subgraph ns_default ["namespace: default"]
    Web[app=web]
  end
  subgraph ns_foo ["namespace: foo"]
    FooPod[Any Pod]
  end
  AnyOther[Any Pod]
  FooPod -.-> Web
  Web -.-> AnyOther
  Web -. ❌ .-> FooPod
  Web -. ❌ .-> AnyOther

Web can make outbound calls, but only FooPod can reach it inbound.

  • Use case: backend reachable only via a controlled proxy or connector.

  • Why it matters: prevents accidental exposure and narrows the attack surface.

  • Checklist: policy selecting web; ingress permitting only the proxy label; add policyTypes: [Ingress,Egress] if you control both directions.

Operational notes

  • Pods not selected by any policy remain open. Once selected, only explicit allows pass.

  • Policies do not cross namespaces without namespaceSelector.

  • Default deny: add a policy that selects the pods, then add granular allows.

Validation snippets

# Test an allowed path
oc exec pod/frontend -- curl -s -o /dev/null -w '%{http_code}\n' http://api:8080

# Test a blocked path with timeout fallback
oc exec pod/untrusted -- curl -s --max-time 3 http://api:8080 || echo BLOCKED
giphy

Cleanup

Before moving to the next module, run the lab cleanup script to reset transient resources from this module.

cd ~/ocp5-rhacs-showroom
bash setup/lab-cleanup.sh --module 101-05