Frontend Labs
⋯

Chapter 53 — Firewalls in the real world: host firewalls, cloud security groups, NACLs

In one sentence

Firewalls are policy checkpoints. Debugging them means finding which checkpoint made which decision, and operating them means expressing policy so that it can be reviewed, tested, and rolled back.

The problem

A packet from a client to a service in the cloud can pass through five or more independent policy points: the client's corporate firewall, the cloud provider's network ACL, the instance's security group, the host's nftables, and a Kubernetes NetworkPolicy. Each has different rules, an owner, and logs (or none). "The firewall is blocking it" isn't a diagnosis until you can say which one.

The three layers you will meet

Layer Examples Typical behavior
Host firewall nftables, iptables, Windows Defender Firewall, macOS PF Runs on the machine itself; stateful via conntrack
Cloud security group AWS SG, GCP firewall rules, Azure NSG Stateful, attached to a VM/NIC/workload; allow-rules only
Network ACL / appliance AWS NACL, corporate firewall, router ACL Subnet or perimeter level; often stateless; ordered allow and deny

Do not say "the firewall" until you know which one.

How it actually works: stateful vs stateless

A stateful firewall uses connection tracking (Chapter 52). Once it has allowed the first packet of a flow, it recognises every later packet in both directions as part of an ESTABLISHED connection. You write one rule ("allow TCP 443 inbound"), and replies are allowed automatically.

A stateless filter judges every packet on its own. To allow inbound HTTPS you need two rules: inbound to port 443, and outbound from 443 to the client's ephemeral port range (Chapter 19). Forgetting the return rule is the classic NACL outage: the SYN gets in, the SYN-ACK is dropped on the way out, and the client sees a timeout.

 STATEFUL (security group)            STATELESS (network ACL)
 inbound:  tcp 443 from 0.0.0.0/0     inbound:  100 allow tcp 443        from 0.0.0.0/0
 outbound: (replies allowed           outbound: 100 allow tcp 1024-65535 to   0.0.0.0/0   <- the return path!
            automatically)                       * deny all

A real host firewall, line by line

This is a complete default-deny nftables policy for a web server. It was syntax-checked and tested in a lab:

# /etc/nftables.d/server.nft -- a default-deny host firewall for a web server
table inet filter {
    chain input {
        type filter hook input priority 0; policy drop;

        ct state established,related accept   # replies to traffic we allowed
        ct state invalid drop                 # packets that fit no known flow
        iif "lo" accept                       # local processes talking to each other

        meta l4proto { icmp, ipv6-icmp } accept   # ping, PMTU discovery (Chapter 15!)
        tcp dport 22 ip saddr 10.0.0.0/8 accept   # SSH only from the internal network
        tcp dport { 80, 443 } accept              # the actual service

        tcp dport 8080 reject with tcp reset      # fail FAST for a retired port
        counter comment "default drop"            # count what the policy drops
    }
}
sudo nft -c -f server.nft     # check syntax WITHOUT applying: always do this first
sudo nft -f server.nft        # apply atomically: the whole file or nothing
sudo nft list ruleset         # what's actually loaded

Things that make this policy production-grade:

  • policy drop plus ct state established,related accept first. Default deny, with replies allowed cheaply before any other rule runs.
  • ICMP is allowed. Blocking it breaks Path MTU Discovery (Chapter 15) and makes failures much harder to debug.
  • Source restrictions on admin ports. SSH is open only to the internal range, not the internet.
  • A deliberate reject for a retired port, so old clients fail in 1 ms instead of hanging.
  • A counter on the default drop, so you can see whether the policy is dropping anything at all.

Drop vs reject: measured

The same lab, connecting to the server with nc -z -w 3 from 10.9.0.1:

port 443       2 ms  succeeded                            <- allowed, listening
port 8080      1 ms  failed: Connection refused           <- "reject with tcp reset"
port 9000   3012 ms  timed out                            <- listening, but policy DROP
port 22        2 ms  failed: Connection refused           <- ALLOWED (10.0.0.0/8), nothing listening
  • Reject sends an error (TCP RST or ICMP unreachable), so clients fail fast.
  • Drop says nothing, so clients retry SYNs until their own timeout.
  • Port 22 is the trap: the firewall allowed it, and the refusal came from the kernel because no SSH daemon was running. "Refused" proves you got through every firewall on the path.

This is why "connection refused" and "connection timed out" are different fingerprints, not two wordings for the same thing.

Real troubleshooting workflow

# 1. Is anything listening, and on which address?
ss -tulpn | grep ':443'

# 2. Can localhost reach it? (bypasses every network firewall)
curl -vk https://127.0.0.1:443/

# 3. Can another host reach the port?
nc -vz server.example.com 443

# 4. Does the packet arrive at the server?
sudo tcpdump -ni any 'tcp port 443'

# 5. Which rule matched? Watch counters change while you retry
sudo nft list ruleset | grep -n counter
sudo nft monitor trace     # after adding: meta nftrace set 1 to a rule in a prerouting chain

Interpretation:

Observation Meaning
Packet never arrives (step 4 silent) route, upstream firewall, cloud SG/NACL, wrong IP
SYN arrives, no SYN-ACK leaves host firewall, service not listening on that IP, local policy
SYN-ACK leaves, client never gets it stateless ACL missing the return rule, asymmetric routing
Handshake completes, app fails later not a firewall problem; move up the stack

Build it in Go (30 min) — firewall policy as executable tests

Firewall rules drift: someone adds a "temporary" rule and forgets it, or a migration loses a path. The fix is to write the intent down as tests and run them after every change, from every network segment that matters. This program reads allow/deny expectations and checks them in parallel:

// policytest: firewall policy as executable tests (Chapter 53).
//
// Write down what SHOULD be reachable and what should NOT, then let the
// program prove it -- from every network segment that matters, before and
// after every firewall change. Exit code 1 on any mismatch, so it fits in CI.
//
//	cat > policy.txt <<'EOF'
//	# expect   target               why
//	allow      example.com:443      public website
//	deny       example.com:22       SSH must not be exposed
//	allow      1.1.1.1:53           DNS resolver
//	EOF
//	go run ./policytest policy.txt
package main

import (
	"bufio"
	"errors"
	"fmt"
	"net"
	"os"
	"strings"
	"sync"
	"syscall"
	"time"
)

type rule struct {
	line   int
	expect string // "allow" or "deny"
	target string
	why    string
}

type result struct {
	rule
	outcome string // connected | refused | timeout | error
	ok      bool
}

func main() {
	if len(os.Args) != 2 {
		fmt.Println("usage: policytest policy.txt")
		os.Exit(2)
	}
	rules, err := load(os.Args[1])
	if err != nil {
		fmt.Println(err)
		os.Exit(2)
	}

	results := make([]result, len(rules))
	var wg sync.WaitGroup
	for i, r := range rules {
		wg.Add(1)
		go func() { // probes run in parallel: a "deny" waits for a timeout
			defer wg.Done()
			out := probe(r.target, 3*time.Second)
			// "deny" is satisfied by refused OR timeout: either way, no connection.
			ok := (r.expect == "allow") == (out == "connected")
			results[i] = result{r, out, ok}
		}()
	}
	wg.Wait()

	failed := 0
	for _, r := range results {
		mark := "PASS"
		if !r.ok {
			mark, failed = "FAIL", failed+1
		}
		fmt.Printf("%s  line %-3d expect %-5s %-24s got %-9s  %s\n", mark, r.line, r.expect, r.target, r.outcome, r.why)
	}
	fmt.Printf("\n%d rules, %d failed\n", len(results), failed)
	if failed > 0 {
		os.Exit(1)
	}
}

func probe(target string, timeout time.Duration) string {
	c, err := net.DialTimeout("tcp", target, timeout)
	var ne net.Error
	switch {
	case err == nil:
		c.Close()
		return "connected"
	case errors.Is(err, syscall.ECONNREFUSED):
		return "refused" // a REJECT rule, or nothing listening
	case errors.As(err, &ne) && ne.Timeout():
		return "timeout" // a DROP rule, or no route
	default:
		return "error"
	}
}

func load(path string) ([]rule, error) {
	f, err := os.Open(path)
	if err != nil {
		return nil, err
	}
	defer f.Close()
	var rules []rule
	sc := bufio.NewScanner(f)
	for n := 1; sc.Scan(); n++ {
		line := strings.TrimSpace(sc.Text())
		if line == "" || strings.HasPrefix(line, "#") {
			continue
		}
		fields := strings.Fields(line)
		if len(fields) < 2 || (fields[0] != "allow" && fields[0] != "deny") {
			return nil, fmt.Errorf("line %d: want 'allow|deny host:port [reason]'", n)
		}
		rules = append(rules, rule{n, fields[0], fields[1], strings.Join(fields[2:], " ")})
	}
	return rules, sc.Err()
}
$ cat policy.txt
# expect target why
allow example.com:443 public website
deny example.com:22 SSH must not be exposed
allow 1.1.1.1:53 DNS resolver
allow 127.0.0.1:1 this one should fail

$ go run ./policytest policy.txt
PASS  line 2   expect allow example.com:443          got connected  public website
PASS  line 3   expect deny  example.com:22           got timeout    SSH must not be exposed
PASS  line 4   expect allow 1.1.1.1:53               got connected  DNS resolver
FAIL  line 5   expect allow 127.0.0.1:1              got refused    this one should fail

4 rules, 1 failed
exit status 1

What to notice: "deny" passes on either refused or timeout, because both mean no connection was made. The output still records which one happened, and that tells you whether something listens behind the rule. The probes run concurrently because each "deny" waits for its full timeout; done one after another, 50 rules would take minutes.

Exercises:

  1. Run the same policy file from three places: your laptop, a VM in the same VPC, and a VM in a different subnet. The results should differ. That difference is your segmentation, verified.
  2. Add a -junit flag that writes JUnit XML, and run it in CI after every Terraform change to security groups.
  3. Extend the format to allow-from <cidr> assertions by running the tool inside network namespaces (Chapter 52) with different source addresses.

Safety rule

Before changing firewall policy, write down:

  • source IP/prefix;
  • destination IP;
  • protocol/port;
  • direction;
  • expected rollback command;
  • how you'll keep your own access (on a remote host, schedule an automatic rollback first: sudo nft -f server.nft; sleep 60 && sudo nft -f previous.nft &, then cancel it once you've confirmed you can still connect).

Most firewall incidents are caused by "temporary" broad rules that nobody removes, or by narrow rules that forgot one path.

Common confusions

  • "I allowed it in the security group, so it's allowed." Every other layer (NACL, host firewall, NetworkPolicy) must allow it too. Policy is the intersection of all of them.
  • "The firewall must be dropping replies." With stateful firewalls, replies are allowed automatically. If replies vanish, look for a stateless ACL or asymmetric routing (Chapter 13).
  • "REJECT is less secure than DROP." Hiding a closed port gains you very little: scanners notice drops too. Use reject inside your own networks, where fast failure helps your engineers. Drop at the internet edge if you want to slow scanners down.

Check yourself

  1. Why does a stateless NACL need an outbound rule for inbound HTTPS to work?
  2. A client gets "connection refused" from your server. Which firewalls can you rule out?
  3. What do policy drop and ct state established,related accept each do, and why does their order matter?
  4. Why should firewall intent be written as tests rather than only as rules?

Further reading

  • Docs: the nftables wiki (wiki.nftables.org): "Quick reference" and "Ruleset debug/tracing".
  • Docs: AWS VPC documentation, "Compare security groups and network ACLs".
  • Across the series: the Linux guide's hardening chapter covers host-level least privilege, and the HTTPS guide's security chapter covers application-layer controls (WAF, rate limiting) that sit above these firewalls. The Go plan builds a WAF in Week 10.

Generated from real-life-guide-v1.md · 8 Oct 2026 16:36