Skip to content

Commit 9a21aaf

Browse files
CopilotMossaka
andauthored
fix: address ipv6 validation and filtering review comments (#69)
* Initial plan * fix: address pr review comments for ipv6 validation and filtering Co-authored-by: Mossaka <[email protected]> * fix: improve ipv6 chain comments for clarity Co-authored-by: Mossaka <[email protected]> * fix: add icmpv6 rules and ipv6 validation tests Co-authored-by: Mossaka <[email protected]> --------- Co-authored-by: copilot-swe-agent[bot] <[email protected]> Co-authored-by: Mossaka <[email protected]>
1 parent 6597f82 commit 9a21aaf

File tree

4 files changed

+122
-26
lines changed

4 files changed

+122
-26
lines changed

containers/agent/setup-iptables.sh

Lines changed: 49 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,13 @@ set -e
44
echo "[iptables] Setting up NAT redirection to Squid proxy..."
55
echo "[iptables] NOTE: Host-level DOCKER-USER chain handles egress filtering for all containers on this network"
66

7+
# Function to check if an IP address is IPv6
8+
is_ipv6() {
9+
local ip="$1"
10+
# Check if it contains a colon (IPv6 addresses always contain colons)
11+
[[ "$ip" == *:* ]]
12+
}
13+
714
# Get Squid proxy configuration from environment
815
SQUID_HOST="${SQUID_PROXY_HOST:-squid-proxy}"
916
SQUID_PORT="${SQUID_PROXY_PORT:-3128}"
@@ -18,55 +25,83 @@ if [ -z "$SQUID_IP" ]; then
1825
fi
1926
echo "[iptables] Squid IP resolved to: $SQUID_IP"
2027

21-
# Clear existing NAT rules
28+
# Clear existing NAT rules (both IPv4 and IPv6)
2229
iptables -t nat -F OUTPUT 2>/dev/null || true
30+
ip6tables -t nat -F OUTPUT 2>/dev/null || true
2331

2432
# Allow localhost traffic (for stdio MCP servers)
2533
echo "[iptables] Allow localhost traffic..."
2634
iptables -t nat -A OUTPUT -o lo -j RETURN
2735
iptables -t nat -A OUTPUT -d 127.0.0.0/8 -j RETURN
36+
ip6tables -t nat -A OUTPUT -o lo -j RETURN
37+
ip6tables -t nat -A OUTPUT -d ::1/128 -j RETURN
2838

2939
# Get DNS servers from environment (default to Google DNS)
3040
DNS_SERVERS="${AWF_DNS_SERVERS:-8.8.8.8,8.8.4.4}"
3141
echo "[iptables] Configuring DNS rules for trusted servers: $DNS_SERVERS"
3242

33-
# Allow DNS queries ONLY to trusted DNS servers (prevents DNS exfiltration)
43+
# Separate IPv4 and IPv6 DNS servers
44+
IPV4_DNS_SERVERS=()
45+
IPV6_DNS_SERVERS=()
3446
IFS=',' read -ra DNS_ARRAY <<< "$DNS_SERVERS"
3547
for dns_server in "${DNS_ARRAY[@]}"; do
3648
dns_server=$(echo "$dns_server" | tr -d ' ')
3749
if [ -n "$dns_server" ]; then
38-
echo "[iptables] Allow DNS to trusted server: $dns_server"
39-
iptables -t nat -A OUTPUT -p udp -d "$dns_server" --dport 53 -j RETURN
40-
iptables -t nat -A OUTPUT -p tcp -d "$dns_server" --dport 53 -j RETURN
50+
if is_ipv6 "$dns_server"; then
51+
IPV6_DNS_SERVERS+=("$dns_server")
52+
else
53+
IPV4_DNS_SERVERS+=("$dns_server")
54+
fi
4155
fi
4256
done
4357

58+
echo "[iptables] IPv4 DNS servers: ${IPV4_DNS_SERVERS[*]:-none}"
59+
echo "[iptables] IPv6 DNS servers: ${IPV6_DNS_SERVERS[*]:-none}"
60+
61+
# Allow DNS queries ONLY to trusted IPv4 DNS servers (prevents DNS exfiltration)
62+
for dns_server in "${IPV4_DNS_SERVERS[@]}"; do
63+
echo "[iptables] Allow DNS to trusted IPv4 server: $dns_server"
64+
iptables -t nat -A OUTPUT -p udp -d "$dns_server" --dport 53 -j RETURN
65+
iptables -t nat -A OUTPUT -p tcp -d "$dns_server" --dport 53 -j RETURN
66+
done
67+
68+
# Allow DNS queries ONLY to trusted IPv6 DNS servers
69+
for dns_server in "${IPV6_DNS_SERVERS[@]}"; do
70+
echo "[iptables] Allow DNS to trusted IPv6 server: $dns_server"
71+
ip6tables -t nat -A OUTPUT -p udp -d "$dns_server" --dport 53 -j RETURN
72+
ip6tables -t nat -A OUTPUT -p tcp -d "$dns_server" --dport 53 -j RETURN
73+
done
74+
4475
# Allow DNS to Docker's embedded DNS server (127.0.0.11) for container name resolution
4576
echo "[iptables] Allow DNS to Docker embedded DNS (127.0.0.11)..."
4677
iptables -t nat -A OUTPUT -p udp -d 127.0.0.11 --dport 53 -j RETURN
4778
iptables -t nat -A OUTPUT -p tcp -d 127.0.0.11 --dport 53 -j RETURN
4879

49-
# Allow return traffic to trusted DNS servers
80+
# Allow return traffic to trusted IPv4 DNS servers
5081
echo "[iptables] Allow traffic to trusted DNS servers..."
51-
for dns_server in "${DNS_ARRAY[@]}"; do
52-
dns_server=$(echo "$dns_server" | tr -d ' ')
53-
if [ -n "$dns_server" ]; then
54-
iptables -t nat -A OUTPUT -d "$dns_server" -j RETURN
55-
fi
82+
for dns_server in "${IPV4_DNS_SERVERS[@]}"; do
83+
iptables -t nat -A OUTPUT -d "$dns_server" -j RETURN
84+
done
85+
86+
# Allow return traffic to trusted IPv6 DNS servers
87+
for dns_server in "${IPV6_DNS_SERVERS[@]}"; do
88+
ip6tables -t nat -A OUTPUT -d "$dns_server" -j RETURN
5689
done
5790

5891
# Allow traffic to Squid proxy itself
5992
echo "[iptables] Allow traffic to Squid proxy (${SQUID_IP}:${SQUID_PORT})..."
6093
iptables -t nat -A OUTPUT -d "$SQUID_IP" -j RETURN
6194

62-
# Redirect HTTP traffic to Squid
95+
# Redirect HTTP traffic to Squid (IPv4 only - Squid runs on IPv4)
6396
echo "[iptables] Redirect HTTP (port 80) to Squid..."
6497
iptables -t nat -A OUTPUT -p tcp --dport 80 -j DNAT --to-destination "${SQUID_IP}:${SQUID_PORT}"
6598

66-
# Redirect HTTPS traffic to Squid
99+
# Redirect HTTPS traffic to Squid (IPv4 only - Squid runs on IPv4)
67100
echo "[iptables] Redirect HTTPS (port 443) to Squid..."
68101
iptables -t nat -A OUTPUT -p tcp --dport 443 -j DNAT --to-destination "${SQUID_IP}:${SQUID_PORT}"
69102

70103
echo "[iptables] NAT rules applied successfully"
71-
echo "[iptables] Current NAT OUTPUT rules:"
104+
echo "[iptables] Current IPv4 NAT OUTPUT rules:"
72105
iptables -t nat -L OUTPUT -n -v
106+
echo "[iptables] Current IPv6 NAT OUTPUT rules:"
107+
ip6tables -t nat -L OUTPUT -n -v 2>/dev/null || echo "[iptables] (ip6tables NAT not available)"

src/cli.test.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -703,12 +703,25 @@ describe('cli', () => {
703703
expect(isValidIPv6('2001:0db8:85a3:0000:0000:8a2e:0370:7334')).toBe(true);
704704
});
705705

706+
it('should accept IPv4-mapped IPv6 addresses', () => {
707+
expect(isValidIPv6('::ffff:192.0.2.1')).toBe(true);
708+
expect(isValidIPv6('::ffff:8.8.8.8')).toBe(true);
709+
expect(isValidIPv6('::ffff:127.0.0.1')).toBe(true);
710+
});
711+
706712
it('should reject invalid IPv6 addresses', () => {
707713
expect(isValidIPv6('8.8.8.8')).toBe(false);
708714
expect(isValidIPv6('localhost')).toBe(false);
709715
expect(isValidIPv6('')).toBe(false);
710716
expect(isValidIPv6('2001:4860:4860:8888')).toBe(false); // Missing ::
711717
});
718+
719+
it('should reject malformed input', () => {
720+
expect(isValidIPv6('not-an-ip')).toBe(false);
721+
expect(isValidIPv6('192.168.1.1')).toBe(false);
722+
expect(isValidIPv6(':::1')).toBe(false);
723+
expect(isValidIPv6('2001:db8::g')).toBe(false); // Invalid hex character
724+
});
712725
});
713726

714727
describe('DNS servers parsing', () => {

src/cli.ts

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { Command } from 'commander';
44
import * as path from 'path';
55
import * as os from 'os';
66
import * as fs from 'fs';
7+
import { isIPv6 } from 'net';
78
import { WrapperConfig, LogLevel } from './types';
89
import { logger } from './logger';
910
import {
@@ -89,19 +90,12 @@ export function isValidIPv4(ip: string): boolean {
8990
}
9091

9192
/**
92-
* Validates that a string is a valid IPv6 address
93+
* Validates that a string is a valid IPv6 address using Node.js built-in net module
9394
* @param ip - String to validate
9495
* @returns true if the string is a valid IPv6 address
9596
*/
9697
export function isValidIPv6(ip: string): boolean {
97-
// Comprehensive IPv6 validation covering:
98-
// - Full form: 2001:0db8:85a3:0000:0000:8a2e:0370:7334
99-
// - Compressed form: 2001:db8:85a3::8a2e:370:7334
100-
// - Loopback: ::1
101-
// - Unspecified: ::
102-
// - IPv4-mapped: ::ffff:192.0.2.1
103-
const ipv6Regex = /^(?:(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|(?:[0-9a-fA-F]{1,4}:){1,7}:|(?:[0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|(?:[0-9a-fA-F]{1,4}:){1,5}(?::[0-9a-fA-F]{1,4}){1,2}|(?:[0-9a-fA-F]{1,4}:){1,4}(?::[0-9a-fA-F]{1,4}){1,3}|(?:[0-9a-fA-F]{1,4}:){1,3}(?::[0-9a-fA-F]{1,4}){1,4}|(?:[0-9a-fA-F]{1,4}:){1,2}(?::[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:(?::[0-9a-fA-F]{1,4}){1,6}|:(?::[0-9a-fA-F]{1,4}){1,7}|::)$/;
104-
return ipv6Regex.test(ip);
98+
return isIPv6(ip);
10599
}
106100

107101
/**

src/host-iptables.ts

Lines changed: 57 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -270,6 +270,41 @@ export async function setupHostIptables(squidIp: string, squidPort: number, dnsS
270270
// Set up IPv6 chain if we have IPv6 DNS servers
271271
await setupIpv6Chain(bridgeName);
272272

273+
// IPv6 chain needs to mirror IPv4 chain's comprehensive filtering
274+
// This prevents IPv6 from becoming an unfiltered bypass path
275+
276+
// Note: Squid proxy rule is omitted for IPv6 since Squid runs on IPv4 only
277+
278+
// 1. Allow established and related connections (return traffic)
279+
await execa('ip6tables', [
280+
'-t', 'filter', '-A', CHAIN_NAME_V6,
281+
'-m', 'conntrack', '--ctstate', 'ESTABLISHED,RELATED',
282+
'-j', 'ACCEPT',
283+
]);
284+
285+
// 2. Allow localhost/loopback traffic
286+
await execa('ip6tables', [
287+
'-t', 'filter', '-A', CHAIN_NAME_V6,
288+
'-o', 'lo',
289+
'-j', 'ACCEPT',
290+
]);
291+
292+
await execa('ip6tables', [
293+
'-t', 'filter', '-A', CHAIN_NAME_V6,
294+
'-d', '::1/128',
295+
'-j', 'ACCEPT',
296+
]);
297+
298+
// 3. Allow essential ICMPv6 (required for IPv6 functionality)
299+
// This includes: destination unreachable, packet too big, time exceeded,
300+
// echo request/reply, and Neighbor Discovery Protocol (NDP)
301+
await execa('ip6tables', [
302+
'-t', 'filter', '-A', CHAIN_NAME_V6,
303+
'-p', 'ipv6-icmp',
304+
'-j', 'ACCEPT',
305+
]);
306+
307+
// 4. Allow DNS ONLY to specified trusted IPv6 DNS servers
273308
for (const dnsServer of ipv6DnsServers) {
274309
await execa('ip6tables', [
275310
'-t', 'filter', '-A', CHAIN_NAME_V6,
@@ -284,7 +319,20 @@ export async function setupHostIptables(squidIp: string, squidPort: number, dnsS
284319
]);
285320
}
286321

287-
// Block all other IPv6 UDP traffic in the IPv6 chain
322+
// 5. Block IPv6 multicast and link-local traffic
323+
await execa('ip6tables', [
324+
'-t', 'filter', '-A', CHAIN_NAME_V6,
325+
'-d', 'ff00::/8', // IPv6 multicast range
326+
'-j', 'REJECT', '--reject-with', 'icmp6-port-unreachable',
327+
]);
328+
329+
await execa('ip6tables', [
330+
'-t', 'filter', '-A', CHAIN_NAME_V6,
331+
'-d', 'fe80::/10', // IPv6 link-local range
332+
'-j', 'REJECT', '--reject-with', 'icmp6-port-unreachable',
333+
]);
334+
335+
// 6. Block all other IPv6 UDP traffic (DNS to whitelisted servers already allowed above)
288336
await execa('ip6tables', [
289337
'-t', 'filter', '-A', CHAIN_NAME_V6,
290338
'-p', 'udp',
@@ -297,10 +345,16 @@ export async function setupHostIptables(squidIp: string, squidPort: number, dnsS
297345
'-j', 'REJECT', '--reject-with', 'icmp6-port-unreachable',
298346
]);
299347

300-
// Default allow for other IPv6 traffic (return to main chain)
348+
// 7. Default deny all other IPv6 traffic (including TCP)
349+
// This prevents IPv6 from being an unfiltered bypass path
301350
await execa('ip6tables', [
302351
'-t', 'filter', '-A', CHAIN_NAME_V6,
303-
'-j', 'RETURN',
352+
'-j', 'LOG', '--log-prefix', '[FW_BLOCKED_OTHER6] ', '--log-level', '4',
353+
]);
354+
355+
await execa('ip6tables', [
356+
'-t', 'filter', '-A', CHAIN_NAME_V6,
357+
'-j', 'REJECT', '--reject-with', 'icmp6-port-unreachable',
304358
]);
305359
}
306360

0 commit comments

Comments
 (0)