In the last post you locked down network egress by restricting DNS resolution to an allowlist of domains. That’s a great first step, but it doesn’t fully solve the problem. An application can still bypass the DNS filter by connecting directly to an IP address or by using an external DNS resolver. In this post you’ll close those loopholes.
As before, these techniques apply to any Linux environment. If you need deeper control on GitHub, consider VNet‑injected larger hosted runners for broader network governance.
Restricting DNS traffic
Here’s the problem: even though you configured the system to use the filtering DNS resolver, nothing stops a process from making DNS queries directly to external DNS servers. A determined script can bypass Unbound by talking to 8.8.8.8 (Google) or 1.1.1.1 (Cloudflare) directly.
Linux gives you a powerful tool to control traffic at the packet level: iptables
. You can block outbound DNS traffic that doesn’t originate from the Unbound service. These queries normally use the standard port for DNS, port 53 (UDP and TCP).
Use firewall rules to enforce DNS restrictions at the network layer:
1# Allow the 'unbound' user to make outbound DNS queries
2iptables -A OUTPUT -p udp -m owner --uid-owner unbound -d 0/0 --dport 53 -j ACCEPT
3iptables -A OUTPUT -p tcp -m owner --uid-owner unbound -d 0/0 --dport 53 -j ACCEPT
These rules allow the unbound
user (the system user that runs the Unbound service) to make DNS queries to any destination. Here’s what each part means:
-A OUTPUT
- Append to the OUTPUT chain (rules for outbound traffic from this machine)
-p udp
/-p tcp
- Match UDP or TCP protocol (DNS uses both)
-m owner --uid-owner unbound
- Match packets from processes running as the
unbound
user
-d 0/0
- Destination of any IP address (0/0 is CIDR notation for “all addresses”)
--dport 53
- Destination port 53 (the standard DNS port)
-j ACCEPT
- Jump to ACCEPT (allow the packet)
At this point Unbound can query upstream servers, but nothing else can. Next allow applications on the system itself to query Unbound:
1# Allow DNS queries on the loopback interface (system -> unbound)
2iptables -A OUTPUT -p udp -d 127.0.0.1 --dport 53 -j ACCEPT
3iptables -A OUTPUT -p tcp -d 127.0.0.1 --dport 53 -j ACCEPT
These rules allow any process to query localhost port 53 — where Unbound listens. That’s how applications on the runner will send DNS queries through the filter.
1# Block all other outbound DNS traffic
2iptables -A OUTPUT -p udp --dport 53 -j REJECT
3iptables -A OUTPUT -p tcp --dport 53 -j REJECT
The final rules enforce the policy: reject any other DNS traffic. If a process queries an external DNS server directly, the rules block it.
The REJECT
target matters. It sends a quick failure (connection refused) so applications fail fast. DROP
would make them linger until a timeout before they ultimately fail the request.
Handling IPv6 DNS queries
There’s another consideration: IPv6. Modern systems are dual-stack. The iptables
rules only cover IPv4. As a result, you need to add equivalent rules for IPv6 using ip6tables
:
1# Allow the 'unbound' user to make outbound DNS queries over IPv6
2ip6tables -A OUTPUT -p udp -m owner --uid-owner unbound -d ::/0 --dport 53 -j ACCEPT
3ip6tables -A OUTPUT -p tcp -m owner --uid-owner unbound -d ::/0 --dport 53 -j ACCEPT
4
5# Allow DNS queries to localhost over IPv6 (system -> unbound)
6ip6tables -A OUTPUT -p udp -d ::1 --dport 53 -j ACCEPT
7ip6tables -A OUTPUT -p tcp -d ::1 --dport 53 -j ACCEPT
8
9# Block all other outbound IPv6 DNS traffic
10ip6tables -A OUTPUT -p udp --dport 53 -j REJECT
11ip6tables -A OUTPUT -p tcp --dport 53 -j REJECT
These rules are nearly identical to the IPv4 version, with two key differences:
::/0
instead of0/0
- This is IPv6 CIDR notation for “all addresses”
::1
instead of127.0.0.1
- This is the IPv6 loopback address (equivalent to 127.0.0.1 in IPv4)
Without these rules, an application can bypass filtering by sending DNS queries over IPv6.
Closing the DNS-over-HTTPS loophole
It turns out there’s another bypass path: DNS-over-HTTPS (DoH). Some tools (including browsers and security agents) now encrypt DNS queries and send them over HTTPS to providers like Cloudflare or Google. This improves privacy and security, but it also bypasses local DNS settings in a way that’s tough to control.
Since DoH uses port 443, it looks like ordinary HTTPS. That means you can’t just block 443. Instead, you will need to block well-known DoH provider IP addresses.
Let’s add rules to block common DoH providers for both IPv4 and IPv6:
1# Block common DNS-over-HTTPS providers (IPv4)
2echo "Blocking DNS-over-HTTPS providers..."
3
4# Cloudflare DoH
5iptables -A OUTPUT -d 1.1.1.1 -p tcp --dport 443 -j REJECT
6iptables -A OUTPUT -d 1.0.0.1 -p tcp --dport 443 -j REJECT
7
8# Google DoH
9iptables -A OUTPUT -d 8.8.8.8 -p tcp --dport 443 -j REJECT
10iptables -A OUTPUT -d 8.8.4.4 -p tcp --dport 443 -j REJECT
11
12# Quad9 DoH
13iptables -A OUTPUT -d 9.9.9.9 -p tcp --dport 443 -j REJECT
14
15# Block common DNS-over-HTTPS providers (IPv6)
16ip6tables -A OUTPUT -d 2606:4700:4700::1111 -p tcp --dport 443 -j REJECT
17ip6tables -A OUTPUT -d 2606:4700:4700::1001 -p tcp --dport 443 -j REJECT
18ip6tables -A OUTPUT -d 2001:4860:4860::8888 -p tcp --dport 443 -j REJECT
19ip6tables -A OUTPUT -d 2001:4860:4860::8844 -p tcp --dport 443 -j REJECT
20ip6tables -A OUTPUT -d 2620:fe::fe -p tcp --dport 443 -j REJECT
These rules target the well-known IP addresses of popular DoH services. When an application connects to these IPs on port 443, the connection is rejected.
This approach isn’t perfect. There are many DoH providers and applications can pick custom endpoints. Blocking major providers removes the most common bypass paths, but a determined user can still find others.
You mean there’s more?
The original goal was to restrict access to a specific set of domains. At this point DNS resolution is locked down, direct DNS queries are blocked, and common DoH providers are rejected. You could go further by allowing outbound traffic only to the IP addresses for your allowed domains. Using dig
(from dnsutils
), query IP addresses for each allowed domain and build iptables rules from that data. For example:
1$ dig +short A kenmuse.com
2 172.67.154.80
3 104.21.64.186
4
5$ dig +short AAAA kenmuse.com
62606:4700:3030::6815:40ba
72606:4700:3034::ac43:9a50
Downside: many domains use CDNs, load balancers, dynamic IPs, or AnyCast (which provides IPs that route to the closest available server). IPs can change frequently or differ by region. A domain could also map to a large rotating pool, making it impractical to enumerate everything.
If jobs are short, you might start and finish before the domain cache expires or the addresses could change. Long-running jobs face a higher risk of mid-run IP changes. This adds complexity and maintenance overhead. You’ll need to weigh security benefits versus the operational challenges. You have full admin control, so you can script dynamic updates as needed.
Locking down files
To harden further, lock down the DNS configuration files by marking them immutable with chattr
:
1chattr +i /etc/unbound/unbound.conf
2chattr +i /etc/unbound/allowlist.conf
3chattr +i /etc/resolv.conf
4chattr +i /etc/systemd/resolved.conf
This prevents changes (even by root) until the immutable flag is removed. Root can still revert it, but it adds friction against accidental or opportunistic edits.
A complete workflow
Putting this together, the workflow looks like this:
The complete script
Here’s a script that implements the DNS filtering (from the prior post) plus the firewall restrictions. (If you prefer zero downtime during the switch, start Unbound before re-pointing systemd-resolved
.)
1#!/bin/bash
2
3# Exit immediately if a command exits with a non-zero status.
4set -e
5export DEBIAN_FRONTEND=noninteractive
6
7echo "🚀 Starting Unbound DNS firewall setup for Ubuntu 24.04..."
8
9# --- Step 1: Install dependencies ---
10echo "Installing unbound, curl, and jq..."
11apt-get update > /dev/null
12apt-get install -y unbound curl jq dnsutils
13
14# --- Step 2: Discover original DNS configuration ---
15echo "🔍 Detecting original upstream DNS servers from systemd-resolved..."
16RESOLVECTL_STATUS=$(resolvectl status)
17UPSTREAM_DNS=$(echo "$RESOLVECTL_STATUS" | grep -oP 'DNS Servers: \K.*')
18DNS_DOMAIN=$(echo "$RESOLVECTL_STATUS" | grep -oP 'DNS Domain: \K.*')
19
20if [ -z "$UPSTREAM_DNS" ]; then
21 echo "❌ ERROR: Could not determine upstream DNS servers. Exiting."
22 exit 1
23fi
24echo "✅ Found upstream DNS servers: $UPSTREAM_DNS"
25if [ -n "$DNS_DOMAIN" ]; then
26 echo "✅ Found search domain: $DNS_DOMAIN"
27fi
28
29# --- Step 3: Configure Unbound ---
30echo "Configuring Unbound to forward allowed traffic..."
31
32cat <<EOF > /etc/unbound/unbound.conf
33server:
34 interface: 127.0.0.1
35 access-control: 127.0.0.0/8 allow
36 hide-identity: yes
37 hide-version: yes
38 harden-dnssec-stripped: yes
39 auto-trust-anchor-file: "/var/lib/unbound/root.key"
40 # Include our custom allowlist rules
41 include: "/etc/unbound/allowlist.conf"
42
43 # Forward all allowed queries to the original upstream DNS servers.
44 forward-zone:
45 name: "."
46EOF
47
48for ip in $UPSTREAM_DNS; do
49 echo " forward-addr: $ip" >> /etc/unbound/unbound.conf
50done
51
52# --- Step 4: Create the allowlist ---
53echo "Creating domain allowlist..."
54cat <<EOF > /etc/unbound/allowlist.conf
55# Deny all domains by default
56local-zone: "." refuse
57
58# Static allowlist rules
59local-zone: "kenmuse.com" transparent
60EOF
61
62# If an internal search domain was found, add it to the allowlist
63if [ -n "$DNS_DOMAIN" ]; then
64 echo "# Allow internal search domain" >> /etc/unbound/allowlist.conf
65 echo "local-zone: \"$DNS_DOMAIN\" transparent" >> /etc/unbound/allowlist.conf
66fi
67
68# --- Step 5: Add GitHub Actions domains to allowlist ---
69echo "Fetching and adding GitHub Actions domains..."
70curl -s https://api.github.com/meta | \
71 jq -r '.domains.actions_inbound.full_domains[] | "local-zone: \"\(.)\" transparent"' \
72 >> /etc/unbound/allowlist.conf
73
74# --- Step 6: (Optional) Validate and start Unbound early
75echo "Validating Unbound config syntax..."
76unbound-checkconf /etc/unbound/unbound.conf
77systemctl restart unbound
78
79# --- Step 7: Configure systemd-resolved ---
80echo "Configuring systemd-resolved to use local Unbound..."
81# This tells the OS to send all DNS queries to our Unbound instance
82cat <<EOF > /etc/systemd/resolved.conf
83[Resolve]
84DNS=127.0.0.1
85DNSStubListener=no
86EOF
87
88systemctl restart systemd-resolved
89rm -f /etc/resolv.conf
90ln -s /run/systemd/resolve/resolv.conf /etc/resolv.conf
91
92# If you did not start Unbound earlier, start now
93echo "Ensuring Unbound is running..."
94systemctl restart unbound
95echo "✅ Unbound configuration complete!"
96
97# --- Step 8: Configure iptables for IPv4 DNS ---
98echo "Configuring iptables rules..."
99
100# Allow the 'unbound' user to make outbound DNS queries
101iptables -A OUTPUT -p udp -m owner --uid-owner unbound -d 0/0 --dport 53 -j ACCEPT
102iptables -A OUTPUT -p tcp -m owner --uid-owner unbound -d 0/0 --dport 53 -j ACCEPT
103
104# Allow DNS queries on the loopback interface (system -> unbound)
105iptables -A OUTPUT -p udp -d 127.0.0.1 --dport 53 -j ACCEPT
106iptables -A OUTPUT -p tcp -d 127.0.0.1 --dport 53 -j ACCEPT
107
108# Block all other outbound DNS traffic
109iptables -A OUTPUT -p udp --dport 53 -j REJECT
110iptables -A OUTPUT -p tcp --dport 53 -j REJECT
111
112echo "✅ iptables configuration complete!"
113
114# --- Step 9: Configure ip6tables for IPv6 DNS ---
115echo "Configuring ip6tables rules..."
116
117# Allow the 'unbound' user to make outbound DNS queries over IPv6
118ip6tables -A OUTPUT -p udp -m owner --uid-owner unbound -d ::/0 --dport 53 -j ACCEPT
119ip6tables -A OUTPUT -p tcp -m owner --uid-owner unbound -d ::/0 --dport 53 -j ACCEPT
120
121# Allow DNS queries to localhost over IPv6 (system -> unbound)
122ip6tables -A OUTPUT -p udp -d ::1 --dport 53 -j ACCEPT
123ip6tables -A OUTPUT -p tcp -d ::1 --dport 53 -j ACCEPT
124
125# Block all other outbound IPv6 DNS traffic
126ip6tables -A OUTPUT -p udp --dport 53 -j REJECT
127ip6tables -A OUTPUT -p tcp --dport 53 -j REJECT
128
129echo "✅ ip6tables configuration complete!"
130
131# --- Step 10: Block DNS-over-HTTPS ---
132echo "Blocking DNS-over-HTTPS providers..."
133
134# Cloudflare DoH (IPv4)
135iptables -A OUTPUT -d 1.1.1.1 -p tcp --dport 443 -j REJECT
136iptables -A OUTPUT -d 1.0.0.1 -p tcp --dport 443 -j REJECT
137
138# Google DoH (IPv4)
139iptables -A OUTPUT -d 8.8.8.8 -p tcp --dport 443 -j REJECT
140iptables -A OUTPUT -d 8.8.4.4 -p tcp --dport 443 -j REJECT
141
142# Quad9 DoH (IPv4)
143iptables -A OUTPUT -d 9.9.9.9 -p tcp --dport 443 -j REJECT
144
145# Cloudflare DoH (IPv6)
146ip6tables -A OUTPUT -d 2606:4700:4700::1111 -p tcp --dport 443 -j REJECT
147ip6tables -A OUTPUT -d 2606:4700:4700::1001 -p tcp --dport 443 -j REJECT
148
149# Google DoH (IPv6)
150ip6tables -A OUTPUT -d 2001:4860:4860::8888 -p tcp --dport 443 -j REJECT
151ip6tables -A OUTPUT -d 2001:4860:4860::8844 -p tcp --dport 443 -j REJECT
152
153# Quad9 DoH (IPv6)
154ip6tables -A OUTPUT -d 2620:fe::fe -p tcp --dport 443 -j REJECT
155
156echo "✅ DNS-over-HTTPS blocking complete!"
157
158echo "🔐 Locking down configuration files..."
159
160chattr +i /etc/unbound/unbound.conf
161chattr +i /etc/unbound/allowlist.conf
162chattr +i /etc/systemd/resolved.conf
163
164echo "🎉 Network lockdown complete!"
Making it practical
Locking down network access on a runner is work, but it meaningfully improves security. By controlling which domains workflows can reach, you reduce the ability for malicious code to exfiltrate data or reach out to command-and-control servers.
There are many ways to manage and extend and improve these solutions. Tools like ipset
manage large IP collections efficiently while VNet‑injected runners can leverage Network Security Groups (NSGs), firewalls, or appliances to move enforcement off the host or add extra layers to the security.
One of the most important aspects of this is making it easy to adopt, package and distribute. There are two good options for GitHub-hosted runners:
GitHub Action – Create a composite action that runs this script. You could even create a traditional Node.js Action and run your script as a pre-step, ensuring it runs before any other steps. Teams can then add a single step to their workflows, such as:
uses: your-org/restrict-network@v1
. You can even enhance the script to accept a list of allowed domains as input parameters. This makes it flexible enough to use across different projects with different external dependencies.Pre-start Script – If you’re using self-hosted runners or custom runner images, you can include this as part of the runner initialization process. A pre-job script can be configured to run before any workflow jobs, ensuring every job starts with restricted network access.
The key is making it easy to adopt. If teams have to manually include and run a complex script, they’ll skip it. Security should be easy to adopt and integrate whenever possible. And while it’s hard to lock down a system completely – there are always edge cases – defense-in-depth is about layers. This approach adds a strong layer that makes your CI/CD environment more resilient.
And in today’s thread landscape, every layer counts.