diff --git a/action.yml b/action.yml index 3013cbec..48e6ae22 100644 --- a/action.yml +++ b/action.yml @@ -46,6 +46,7 @@ runs: shell: bash env: INPUT_VERSION: ${{ inputs.version }} + GITHUB_TOKEN: ${{ github.token }} run: | set -euo pipefail @@ -53,6 +54,12 @@ runs: BINARY_NAME="awf-linux-x64" INSTALL_DIR="${RUNNER_TEMP}/awf-bin" + # Build auth header for GitHub API to avoid rate limits + AUTH_HEADER=() + if [ -n "${GITHUB_TOKEN:-}" ]; then + AUTH_HEADER=(-H "Authorization: token ${GITHUB_TOKEN}") + fi + # Create install directory mkdir -p "$INSTALL_DIR" @@ -61,9 +68,9 @@ runs: echo "Fetching latest release version..." # Use jq if available, fallback to grep/sed if command -v jq &> /dev/null; then - VERSION=$(curl -fsSL "https://api.github.com/repos/${REPO}/releases/latest" | jq -r '.tag_name') + VERSION=$(curl -fsSL "${AUTH_HEADER[@]}" "https://api.github.com/repos/${REPO}/releases/latest" | jq -r '.tag_name') else - VERSION=$(curl -fsSL "https://api.github.com/repos/${REPO}/releases/latest" | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/') + VERSION=$(curl -fsSL "${AUTH_HEADER[@]}" "https://api.github.com/repos/${REPO}/releases/latest" | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/') fi if [ -z "$VERSION" ] || [ "$VERSION" = "null" ]; then echo "::error::Failed to fetch latest version from GitHub API" diff --git a/containers/agent/entrypoint.sh b/containers/agent/entrypoint.sh index 4ca659b0..0a6358cd 100644 --- a/containers/agent/entrypoint.sh +++ b/containers/agent/entrypoint.sh @@ -65,9 +65,11 @@ if [ "$CURRENT_UID" != "$HOST_UID" ] || [ "$CURRENT_GID" != "$HOST_GID" ]; then echo "[entrypoint] UID/GID adjustment complete" fi -# 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) -# Trusted external DNS servers are used for internet domain resolution +# Configure DNS to use only Docker's embedded DNS (127.0.0.11) +# Docker embedded DNS handles all name resolution: +# - Container names (e.g., squid-proxy) → resolved directly +# - External domains → forwarded to upstream DNS servers configured via docker-compose dns: field +# No external DNS servers are listed in resolv.conf to prevent DNS-based data exfiltration echo "[entrypoint] Configuring DNS..." if [ -f /etc/resolv.conf ]; then # Backup original resolv.conf @@ -85,30 +87,17 @@ if [ -f /etc/resolv.conf ]; then } > /etc/resolv.conf echo "[entrypoint] DNS configured with Docker embedded DNS (127.0.0.11) and DoH proxy ($AWF_DOH_PROXY_IP)" else - # Traditional DNS mode: use configured DNS servers - # 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 + # Simplified security model: Docker embedded DNS only + # Docker embedded DNS at 127.0.0.11 handles all resolution + # It forwards to upstream servers configured via docker-compose dns: field + # No external DNS servers listed to prevent DNS-based data exfiltration { echo "# Generated by awf entrypoint" - echo "# Docker embedded DNS for service name resolution (squid-proxy, etc.)" + echo "# Docker embedded DNS handles all resolution (forwards to upstream via docker-compose dns: config)" 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" + echo "[entrypoint] DNS configured with Docker embedded DNS (127.0.0.11) only" fi fi diff --git a/containers/agent/setup-iptables.sh b/containers/agent/setup-iptables.sh index d26ee4b8..ac6f5fa8 100644 --- a/containers/agent/setup-iptables.sh +++ b/containers/agent/setup-iptables.sh @@ -52,12 +52,30 @@ if [ -z "$SQUID_IP" ]; then fi echo "[iptables] Squid IP resolved to: $SQUID_IP" +# Save Docker's embedded DNS DNAT rules before flushing. +# Docker adds DNAT rules to redirect 127.0.0.11:53 to its internal DNS server +# on a random high port. Flushing the NAT chain destroys these rules, breaking +# DNS resolution via Docker embedded DNS. +DOCKER_DNS_RULES=$(iptables-save -t nat 2>/dev/null | grep -- "-A OUTPUT.*127.0.0.11" || true) + # Clear existing NAT rules (both IPv4 and IPv6) iptables -t nat -F OUTPUT 2>/dev/null || true if [ "$IP6TABLES_AVAILABLE" = true ]; then ip6tables -t nat -F OUTPUT 2>/dev/null || true fi +# Restore Docker's embedded DNS DNAT rules (must come before localhost RETURN rules +# so that DNS queries to 127.0.0.11:53 are properly redirected to Docker's DNS server) +if [ -n "$DOCKER_DNS_RULES" ]; then + echo "[iptables] Restoring Docker embedded DNS DNAT rules..." + while IFS= read -r rule; do + if [ -n "$rule" ]; then + # iptables-save outputs rules like "-A OUTPUT -d 127.0.0.11/32 -p udp -m udp --dport 53 -j DNAT --to-destination 127.0.0.11:XXXXX" + iptables -t nat $rule 2>/dev/null || true + fi + done <<< "$DOCKER_DNS_RULES" +fi + # Allow localhost traffic (for stdio MCP servers and test frameworks) echo "[iptables] Allow localhost traffic..." iptables -t nat -A OUTPUT -o lo -j RETURN @@ -79,9 +97,6 @@ if [ -n "$AGENT_IP" ] && is_valid_ipv4 "$AGENT_IP"; then iptables -A OUTPUT -p tcp -d "$AGENT_IP" -j ACCEPT fi -# Get DNS servers from environment (default to Google DNS) -DNS_SERVERS="${AWF_DNS_SERVERS:-8.8.8.8,8.8.4.4}" - # Check if DNS-over-HTTPS mode is enabled if [ "$AWF_DOH_ENABLED" = "true" ] && [ -n "$AWF_DOH_PROXY_IP" ]; then echo "[iptables] DNS-over-HTTPS mode: routing DNS through DoH proxy at $AWF_DOH_PROXY_IP" @@ -97,66 +112,35 @@ if [ "$AWF_DOH_ENABLED" = "true" ] && [ -n "$AWF_DOH_PROXY_IP" ]; then # Allow return traffic to DoH proxy iptables -t nat -A OUTPUT -d "$AWF_DOH_PROXY_IP" -j RETURN - - # Set variables for OUTPUT filter chain (used later) - IPV4_DNS_SERVERS=() - IPV6_DNS_SERVERS=() else - echo "[iptables] Configuring DNS rules for trusted servers: $DNS_SERVERS" - - # Separate IPv4 and IPv6 DNS servers - IPV4_DNS_SERVERS=() - IPV6_DNS_SERVERS=() + # Simplified DNS model: Docker embedded DNS (127.0.0.11) handles all name resolution. + # The embedded DNS forwards to upstream servers configured via docker-compose dns: field. + # Docker's DNS forwarding may traverse the container's network namespace, so we must + # explicitly allow UDP/TCP port 53 to the configured upstream servers. + # Direct DNS queries to non-configured servers are blocked by the OUTPUT filter chain. + DNS_SERVERS="${AWF_DNS_SERVERS:-8.8.8.8,8.8.4.4}" + echo "[iptables] DNS: Docker embedded DNS forwards to upstream: $DNS_SERVERS" + + # Allow DNS queries to configured upstream servers (needed for Docker DNS forwarding) 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 if is_ipv6 "$dns_server"; then - IPV6_DNS_SERVERS+=("$dns_server") + if [ "$IP6TABLES_AVAILABLE" = true ]; then + 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 + fi else - IPV4_DNS_SERVERS+=("$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 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 - 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)..." + # Also allow DNS to Docker's embedded DNS server itself 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 IPv4 DNS servers - echo "[iptables] Allow traffic to trusted DNS servers..." - 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 - 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 fi # Allow traffic to Squid proxy itself (prevent redirect loop) @@ -290,23 +274,27 @@ fi # These rules apply AFTER NAT translation echo "[iptables] Configuring OUTPUT filter chain rules..." -# Allow localhost traffic +# Allow localhost traffic (includes Docker embedded DNS at 127.0.0.11) iptables -A OUTPUT -o lo -j ACCEPT -# Allow DNS queries to trusted servers (or DoH proxy) +# Allow DNS to DoH proxy or configured upstream servers if [ "$AWF_DOH_ENABLED" = "true" ] && [ -n "$AWF_DOH_PROXY_IP" ]; then iptables -A OUTPUT -p udp -d "$AWF_DOH_PROXY_IP" --dport 53 -j ACCEPT iptables -A OUTPUT -p tcp -d "$AWF_DOH_PROXY_IP" --dport 53 -j ACCEPT else - for dns_server in "${IPV4_DNS_SERVERS[@]}"; do - iptables -A OUTPUT -p udp -d "$dns_server" --dport 53 -j ACCEPT - iptables -A OUTPUT -p tcp -d "$dns_server" --dport 53 -j ACCEPT + # Allow DNS to configured upstream servers (needed for Docker DNS forwarding) + for dns_server in "${DNS_ARRAY[@]}"; do + dns_server=$(echo "$dns_server" | tr -d ' ') + if [ -n "$dns_server" ] && ! is_ipv6 "$dns_server"; then + iptables -A OUTPUT -p udp -d "$dns_server" --dport 53 -j ACCEPT + iptables -A OUTPUT -p tcp -d "$dns_server" --dport 53 -j ACCEPT + fi done -fi -# Allow DNS to Docker's embedded DNS server -iptables -A OUTPUT -p udp -d 127.0.0.11 --dport 53 -j ACCEPT -iptables -A OUTPUT -p tcp -d 127.0.0.11 --dport 53 -j ACCEPT + # Allow DNS to Docker's embedded DNS server + iptables -A OUTPUT -p udp -d 127.0.0.11 --dport 53 -j ACCEPT + iptables -A OUTPUT -p tcp -d 127.0.0.11 --dport 53 -j ACCEPT +fi # Allow traffic to Squid proxy (after NAT redirection) iptables -A OUTPUT -p tcp -d "$SQUID_IP" -j ACCEPT @@ -316,10 +304,12 @@ if [ -n "$AWF_API_PROXY_IP" ]; then iptables -A OUTPUT -p tcp -d "$AWF_API_PROXY_IP" -j ACCEPT fi -# Drop all other TCP traffic (default deny policy) -# This ensures that only explicitly allowed ports can be accessed -echo "[iptables] Drop all non-redirected TCP traffic (default deny)..." +# Drop all other TCP and UDP traffic (default deny policy) +# TCP: ensures only explicitly allowed ports can be accessed +# UDP: prevents DNS exfiltration by blocking direct queries to non-configured DNS servers +echo "[iptables] Drop all non-allowed TCP and UDP traffic (default deny)..." iptables -A OUTPUT -p tcp -j DROP +iptables -A OUTPUT -p udp -j DROP echo "[iptables] NAT rules applied successfully" echo "[iptables] Current IPv4 NAT OUTPUT rules:" diff --git a/src/cli-workflow.ts b/src/cli-workflow.ts index 90b27c9c..2cefd6a9 100644 --- a/src/cli-workflow.ts +++ b/src/cli-workflow.ts @@ -43,9 +43,9 @@ 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(); - const dnsServers = config.dnsServers || ['8.8.8.8', '8.8.4.4']; // When API proxy is enabled, allow agent→sidecar traffic at the host level. // The sidecar itself routes through Squid, so domain whitelisting is still enforced. + const dnsServers = config.dnsServers || ['8.8.8.8', '8.8.4.4']; const apiProxyIp = config.enableApiProxy ? networkConfig.proxyIp : undefined; // When DoH is enabled, the DoH proxy needs direct HTTPS access to the resolver const dohProxyIp = config.dnsOverHttps ? '172.30.0.40' : undefined; diff --git a/src/cli.test.ts b/src/cli.test.ts index 6424e4cc..c6ac08f7 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -347,7 +347,6 @@ describe('cli', () => { program .option('--log-level ', 'Log level', 'info') .option('--keep-containers', 'Keep containers', false) - .option('--skip-cleanup', 'Skip all cleanup', false) .option('--build-local', 'Build locally', false) .option('--env-all', 'Pass all env vars', false); @@ -357,22 +356,9 @@ describe('cli', () => { expect(opts.logLevel).toBe('info'); expect(opts.keepContainers).toBe(false); - expect(opts.skipCleanup).toBe(false); expect(opts.buildLocal).toBe(false); expect(opts.envAll).toBe(false); }); - - it('should parse --skip-cleanup flag when provided', () => { - const program = new Command(); - - program - .option('--skip-cleanup', 'Skip all cleanup', false); - - program.parse(['node', 'awf', '--skip-cleanup'], { from: 'node' }); - const opts = program.opts(); - - expect(opts.skipCleanup).toBe(true); - }); }); describe('argument parsing with variadic args', () => { @@ -1334,32 +1320,6 @@ describe('cli', () => { }); }); - describe('hasRateLimitOptions', () => { - it('should return false when no rate limit options are set', () => { - expect(hasRateLimitOptions({})).toBe(false); - }); - - it('should return true when rateLimitRpm is set', () => { - expect(hasRateLimitOptions({ rateLimitRpm: '100' })).toBe(true); - }); - - it('should return true when rateLimitRph is set', () => { - expect(hasRateLimitOptions({ rateLimitRph: '1000' })).toBe(true); - }); - - it('should return true when rateLimitBytesPm is set', () => { - expect(hasRateLimitOptions({ rateLimitBytesPm: '50000000' })).toBe(true); - }); - - it('should return true when rateLimit is explicitly false', () => { - expect(hasRateLimitOptions({ rateLimit: false })).toBe(true); - }); - - it('should return false when rateLimit is true (default enabling)', () => { - expect(hasRateLimitOptions({ rateLimit: true })).toBe(false); - }); - }); - describe('validateSkipPullWithBuildLocal', () => { it('should return valid when both flags are false', () => { const result = validateSkipPullWithBuildLocal(false, false); @@ -2060,4 +2020,30 @@ describe('cli', () => { }); }); }); + + describe('hasRateLimitOptions', () => { + it('returns false when no rate limit options are set', () => { + expect(hasRateLimitOptions({})).toBe(false); + }); + + it('returns true when rateLimitRpm is set', () => { + expect(hasRateLimitOptions({ rateLimitRpm: '100' })).toBe(true); + }); + + it('returns true when rateLimitRph is set', () => { + expect(hasRateLimitOptions({ rateLimitRph: '1000' })).toBe(true); + }); + + it('returns true when rateLimitBytesPm is set', () => { + expect(hasRateLimitOptions({ rateLimitBytesPm: '1048576' })).toBe(true); + }); + + it('returns true when rateLimit is explicitly false', () => { + expect(hasRateLimitOptions({ rateLimit: false })).toBe(true); + }); + + it('returns false when rateLimit is true', () => { + expect(hasRateLimitOptions({ rateLimit: true })).toBe(false); + }); + }); }); diff --git a/src/docker-manager.test.ts b/src/docker-manager.test.ts index 0a163604..1f27f324 100644 --- a/src/docker-manager.test.ts +++ b/src/docker-manager.test.ts @@ -1496,7 +1496,7 @@ describe('docker-manager', () => { }); describe('dnsServers option', () => { - it('should use custom DNS servers when specified', () => { + it('should use custom DNS servers for Docker embedded DNS forwarding', () => { const config: WrapperConfig = { ...mockConfig, dnsServers: ['1.1.1.1', '1.0.0.1'], @@ -1506,6 +1506,7 @@ describe('docker-manager', () => { const env = agent.environment as Record; expect(agent.dns).toEqual(['1.1.1.1', '1.0.0.1']); + // AWF_DNS_SERVERS env var should be set for setup-iptables.sh DNS ACCEPT rules expect(env.AWF_DNS_SERVERS).toBe('1.1.1.1,1.0.0.1'); }); @@ -1515,6 +1516,7 @@ describe('docker-manager', () => { const env = agent.environment as Record; expect(agent.dns).toEqual(['8.8.8.8', '8.8.4.4']); + // AWF_DNS_SERVERS env var should be set for setup-iptables.sh DNS ACCEPT rules expect(env.AWF_DNS_SERVERS).toBe('8.8.8.8,8.8.4.4'); }); }); diff --git a/src/docker-manager.ts b/src/docker-manager.ts index f8746c5f..6e4cca94 100644 --- a/src/docker-manager.ts +++ b/src/docker-manager.ts @@ -508,8 +508,10 @@ export function generateDockerCompose( } } - // Pass DNS servers to container for setup-iptables.sh and entrypoint.sh + // DNS servers for Docker embedded DNS forwarding (used in docker-compose dns: field) const dnsServers = config.dnsServers || ['8.8.8.8', '8.8.4.4']; + // Pass DNS servers to container so setup-iptables.sh can allow Docker DNS forwarding + // to these upstream servers while blocking direct DNS to all other servers. environment.AWF_DNS_SERVERS = dnsServers.join(','); // When DoH is enabled, tell the agent container to route DNS through the DoH proxy @@ -1394,6 +1396,7 @@ export async function writeConfigs(config: WrapperConfig): Promise { enableHostAccess: config.enableHostAccess, allowHostPorts: config.allowHostPorts, enableDlp: config.enableDlp, + dnsServers: config.dnsServers, }); const squidConfigPath = path.join(config.workDir, 'squid.conf'); fs.writeFileSync(squidConfigPath, squidConfig, { mode: 0o644 }); diff --git a/src/host-iptables.test.ts b/src/host-iptables.test.ts index 1280a366..21d57378 100644 --- a/src/host-iptables.test.ts +++ b/src/host-iptables.test.ts @@ -143,61 +143,10 @@ describe('host-iptables', () => { '-j', 'ACCEPT', ]); - // Verify DNS query logging rules (LOG before ACCEPT for audit trail) + // Verify DNS forwarding rules for default upstream servers expect(mockedExeca).toHaveBeenCalledWith('iptables', [ '-t', 'filter', '-A', 'FW_WRAPPER', '-p', 'udp', '-d', '8.8.8.8', '--dport', '53', - '-j', 'LOG', '--log-prefix', '[FW_DNS_QUERY] ', '--log-level', '4', - ]); - - expect(mockedExeca).toHaveBeenCalledWith('iptables', [ - '-t', 'filter', '-A', 'FW_WRAPPER', - '-p', 'tcp', '-d', '8.8.8.8', '--dport', '53', - '-j', 'LOG', '--log-prefix', '[FW_DNS_QUERY] ', '--log-level', '4', - ]); - - // Verify DNS rules for trusted servers only - expect(mockedExeca).toHaveBeenCalledWith('iptables', [ - '-t', 'filter', '-A', 'FW_WRAPPER', - '-p', 'udp', '-d', '8.8.8.8', '--dport', '53', - '-j', 'ACCEPT', - ]); - - expect(mockedExeca).toHaveBeenCalledWith('iptables', [ - '-t', 'filter', '-A', 'FW_WRAPPER', - '-p', 'tcp', '-d', '8.8.8.8', '--dport', '53', - '-j', 'ACCEPT', - ]); - - // Verify DNS query logging rules for second DNS server - expect(mockedExeca).toHaveBeenCalledWith('iptables', [ - '-t', 'filter', '-A', 'FW_WRAPPER', - '-p', 'udp', '-d', '8.8.4.4', '--dport', '53', - '-j', 'LOG', '--log-prefix', '[FW_DNS_QUERY] ', '--log-level', '4', - ]); - - expect(mockedExeca).toHaveBeenCalledWith('iptables', [ - '-t', 'filter', '-A', 'FW_WRAPPER', - '-p', 'tcp', '-d', '8.8.4.4', '--dport', '53', - '-j', 'LOG', '--log-prefix', '[FW_DNS_QUERY] ', '--log-level', '4', - ]); - - 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', ]); @@ -414,7 +363,89 @@ describe('host-iptables', () => { ]); }); - it('should use ip6tables for IPv6 DNS servers', async () => { + it('should add API proxy sidecar rules when apiProxyIp is provided', 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 (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', '8.8.4.4'], '172.30.0.30'); + + // Verify API proxy sidecar rule was added with port range + expect(mockedExeca).toHaveBeenCalledWith('iptables', expect.arrayContaining([ + '-t', 'filter', '-A', 'FW_WRAPPER', + '-p', 'tcp', '-d', '172.30.0.30', + ])); + }); + + it('should throw error when bridge name is not found', async () => { + // Mock getNetworkBridgeName returning empty/null + mockedExeca.mockResolvedValueOnce({ + stdout: '', + stderr: '', + exitCode: 0, + } as any); + + await expect(setupHostIptables('172.30.0.10', 3128, ['8.8.8.8', '8.8.4.4'])).rejects.toThrow( + "Failed to get bridge name for network 'awf-net'" + ); + }); + + it('should create DOCKER-USER chain when it does not exist', async () => { + const noChainError: any = new Error('No chain/target/match by that name'); + noChainError.stderr = 'No chain/target/match by that name'; + + mockedExeca + // Mock getNetworkBridgeName + .mockResolvedValueOnce({ stdout: 'fw-bridge', stderr: '', exitCode: 0 } as any) + // Mock iptables -L DOCKER-USER (chain doesn't exist) + .mockRejectedValueOnce(noChainError) + // Mock iptables -N DOCKER-USER (create chain) + .mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 } as any) + // Mock chain existence check (FW_WRAPPER doesn't exist) + .mockResolvedValueOnce({ exitCode: 1 } as any); + + // Mock all subsequent calls + mockedExeca.mockResolvedValue({ stdout: '', stderr: '', exitCode: 0 } as any); + + await setupHostIptables('172.30.0.10', 3128, ['8.8.8.8', '8.8.4.4']); + + // Verify DOCKER-USER chain was created + expect(mockedExeca).toHaveBeenCalledWith('iptables', ['-t', 'filter', '-N', 'DOCKER-USER']); + }); + + it('should skip inserting DOCKER-USER jump rule if it already exists', 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 (doesn't exist) + .mockResolvedValueOnce({ exitCode: 1 } as any); + + // Default mock: all calls succeed, and DOCKER-USER listing includes bridge rule + mockedExeca.mockResolvedValue({ + stdout: '1 FW_WRAPPER all -- -i fw-bridge 0.0.0.0/0 0.0.0.0/0', + stderr: '', + exitCode: 0, + } as any); + + await setupHostIptables('172.30.0.10', 3128, ['8.8.8.8', '8.8.4.4']); + + // Should NOT insert a new rule since it already exists + expect(mockedExeca).not.toHaveBeenCalledWith('iptables', [ + '-t', 'filter', '-I', 'DOCKER-USER', '1', + '-i', 'fw-bridge', + '-j', 'FW_WRAPPER', + ]); + }); + + it('should not create IPv6 chain but should add DNS forwarding rules', async () => { mockedExeca // Mock getNetworkBridgeName .mockResolvedValueOnce({ @@ -439,56 +470,21 @@ describe('host-iptables', () => { exitCode: 0, } as any); - await setupHostIptables('172.30.0.10', 3128, ['8.8.8.8', '2001:4860:4860::8888']); + await setupHostIptables('172.30.0.10', 3128, ['8.8.8.8', '8.8.4.4']); - // Verify IPv4 DNS rule uses iptables + // Verify no IPv6 chain + expect(mockedExeca).not.toHaveBeenCalledWith('ip6tables', ['-t', 'filter', '-N', 'FW_WRAPPER_V6']); + // DNS forwarding rules should exist for default upstream servers (8.8.8.8, 8.8.4.4) expect(mockedExeca).toHaveBeenCalledWith('iptables', [ '-t', 'filter', '-A', 'FW_WRAPPER', '-p', 'udp', '-d', '8.8.8.8', '--dport', '53', '-j', 'ACCEPT', ]); - - // Verify IPv6 DNS query logging rules (LOG before ACCEPT) - expect(mockedExeca).toHaveBeenCalledWith('ip6tables', [ - '-t', 'filter', '-A', 'FW_WRAPPER_V6', - '-p', 'udp', '-d', '2001:4860:4860::8888', '--dport', '53', - '-j', 'LOG', '--log-prefix', '[FW_DNS_QUERY] ', '--log-level', '4', - ]); - - expect(mockedExeca).toHaveBeenCalledWith('ip6tables', [ - '-t', 'filter', '-A', 'FW_WRAPPER_V6', - '-p', 'tcp', '-d', '2001:4860:4860::8888', '--dport', '53', - '-j', 'LOG', '--log-prefix', '[FW_DNS_QUERY] ', '--log-level', '4', - ]); - - // 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', + expect(mockedExeca).toHaveBeenCalledWith('iptables', [ + '-t', 'filter', '-A', 'FW_WRAPPER', + '-p', 'udp', '-d', '8.8.4.4', '--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 disable IPv6 via sysctl when ip6tables unavailable', async () => { @@ -533,36 +529,6 @@ describe('host-iptables', () => { expect(mockedExeca).not.toHaveBeenCalledWith('sysctl', ['-w', 'net.ipv6.conf.default.disable_ipv6=1']); }); - 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', () => { @@ -599,7 +565,7 @@ describe('host-iptables', () => { return Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }); }) as any); - await setupHostIptables('172.30.0.10', 3128, ['8.8.8.8']); + await setupHostIptables('172.30.0.10', 3128, ['8.8.8.8', '8.8.4.4']); // Now run cleanup jest.clearAllMocks(); @@ -617,6 +583,60 @@ describe('host-iptables', () => { expect(mockedExeca).toHaveBeenCalledWith('sysctl', ['-w', 'net.ipv6.conf.default.disable_ipv6=0']); }); + it('should clean up IPv6 rules from DOCKER-USER when ip6tables is available', async () => { + // Mock all calls to succeed (ip6tables available) + mockedExeca.mockImplementation(((cmd: string, args: string[]) => { + // getNetworkBridgeName + if (cmd === 'docker' && args[0] === 'network') { + return Promise.resolve({ stdout: 'fw-bridge', stderr: '', exitCode: 0 }); + } + // ip6tables -L -n (availability check) + if (cmd === 'ip6tables' && args.includes('-L') && args.includes('-n') && !args.includes('--line-numbers')) { + return Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }); + } + // ip6tables DOCKER-USER listing with FW_WRAPPER_V6 reference + if (cmd === 'ip6tables' && args.includes('DOCKER-USER') && args.includes('--line-numbers')) { + return Promise.resolve({ stdout: '1 FW_WRAPPER_V6 all -- * * ::/0 ::/0\n', stderr: '', exitCode: 0 }); + } + // iptables DOCKER-USER listing with FW_WRAPPER reference + if (cmd === 'iptables' && args.includes('DOCKER-USER') && args.includes('--line-numbers')) { + return Promise.resolve({ stdout: '1 FW_WRAPPER all -- -i fw-bridge -o fw-bridge 0.0.0.0/0 0.0.0.0/0\n', stderr: '', exitCode: 0 }); + } + return Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }); + }) as any); + + await cleanupHostIptables(); + + // Verify IPv6 chain was flushed and deleted + expect(mockedExeca).toHaveBeenCalledWith('ip6tables', ['-t', 'filter', '-F', 'FW_WRAPPER_V6'], { reject: false }); + expect(mockedExeca).toHaveBeenCalledWith('ip6tables', ['-t', 'filter', '-X', 'FW_WRAPPER_V6'], { reject: false }); + // Verify IPv6 DOCKER-USER rule was removed + expect(mockedExeca).toHaveBeenCalledWith('ip6tables', ['-t', 'filter', '-D', 'DOCKER-USER', '1'], { reject: false }); + // Verify IPv4 chain was also cleaned + expect(mockedExeca).toHaveBeenCalledWith('iptables', ['-t', 'filter', '-F', 'FW_WRAPPER'], { reject: false }); + expect(mockedExeca).toHaveBeenCalledWith('iptables', ['-t', 'filter', '-X', 'FW_WRAPPER'], { reject: false }); + }); + + it('should skip IPv6 cleanup when ip6tables is not available', async () => { + mockedExeca.mockImplementation(((cmd: string, args: string[]) => { + if (cmd === 'docker') { + return Promise.resolve({ stdout: 'fw-bridge', stderr: '', exitCode: 0 }); + } + if (cmd === 'ip6tables') { + return Promise.reject(new Error('ip6tables not found')); + } + if (cmd === 'iptables' && args.includes('DOCKER-USER') && args.includes('--line-numbers')) { + return Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }); + } + return Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }); + }) as any); + + await cleanupHostIptables(); + + // Should NOT attempt ip6tables cleanup (except the availability check) + expect(mockedExeca).not.toHaveBeenCalledWith('ip6tables', ['-t', 'filter', '-F', 'FW_WRAPPER_V6'], { reject: false }); + }); + it('should not throw on errors (best-effort cleanup)', async () => { mockedExeca.mockRejectedValue(new Error('iptables error')); diff --git a/src/host-iptables.ts b/src/host-iptables.ts index 034bff67..c12f8a27 100644 --- a/src/host-iptables.ts +++ b/src/host-iptables.ts @@ -1,6 +1,5 @@ import execa from 'execa'; import { logger } from './logger'; -import { isIPv6 } from 'net'; import { API_PROXY_PORTS } from './types'; const NETWORK_NAME = 'awf-net'; @@ -140,69 +139,20 @@ 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 + * This ensures ALL containers on the firewall network are subject to egress filtering. + * + * Simplified security model: only localhost, Squid proxy, and DNS forwarding are allowed. + * Containers use Docker's embedded DNS (127.0.0.11) as their only nameserver. + * Docker's DNS proxy forwards queries to upstream servers configured via docker-compose dns: field. + * These forwarded queries traverse the Docker bridge and must be allowed in DOCKER-USER. + * Squid resolves DNS internally for all HTTP/HTTPS traffic. + * * @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) + * @param apiProxyIp - Optional IP address of the API proxy sidecar + * @param dnsServers - Upstream DNS servers that Docker embedded DNS forwards to */ export async function setupHostIptables(squidIp: string, squidPort: number, dnsServers: string[], apiProxyIp?: string, dohProxyIp?: string): Promise { logger.info('Setting up host-level iptables rules...'); @@ -325,173 +275,67 @@ export async function setupHostIptables(squidIp: string, squidPort: number, dnsS '-j', 'ACCEPT', ]); - // 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) { - // Log DNS queries first (LOG doesn't terminate processing) - await execa('iptables', [ - '-t', 'filter', '-A', CHAIN_NAME, - '-p', 'udp', '-d', dnsServer, '--dport', '53', - '-j', 'LOG', '--log-prefix', '[FW_DNS_QUERY] ', '--log-level', '4', - ]); - - 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', 'LOG', '--log-prefix', '[FW_DNS_QUERY] ', '--log-level', '4', - ]); - - await execa('iptables', [ - '-t', 'filter', '-A', CHAIN_NAME, - '-p', 'tcp', '-d', dnsServer, '--dport', '53', - '-j', 'ACCEPT', - ]); - } - - // Check ip6tables availability and disable IPv6 if unavailable + // 4. Check ip6tables availability and disable IPv6 if unavailable const ip6tablesAvailable = await isIp6tablesAvailable(); if (!ip6tablesAvailable) { logger.warn('ip6tables is not available, disabling IPv6 via sysctl to prevent unfiltered bypass'); await disableIpv6ViaSysctl(); } - // Add IPv6 DNS server rules using ip6tables - if (ipv6DnsServers.length > 0) { - if (!ip6tablesAvailable) { - logger.warn('IPv6 DNS servers configured but ip6tables not available; IPv6 has been disabled'); - } else { - // 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) { - // Log DNS queries first (LOG doesn't terminate processing) - await execa('ip6tables', [ - '-t', 'filter', '-A', CHAIN_NAME_V6, - '-p', 'udp', '-d', dnsServer, '--dport', '53', - '-j', 'LOG', '--log-prefix', '[FW_DNS_QUERY] ', '--log-level', '4', - ]); + // 4b. Allow DNS forwarding to upstream servers + // Docker's embedded DNS (127.0.0.11) proxies queries to upstream servers configured + // via docker-compose dns: field. These forwarded queries traverse the Docker bridge + // and need to be allowed here. Only the configured upstream servers are permitted. + const upstreamDns = dnsServers && dnsServers.length > 0 ? dnsServers : ['8.8.8.8', '8.8.4.4']; + logger.debug(`Allowing DNS forwarding to upstream servers: ${upstreamDns.join(', ')}`); + + // Create IPv6 chain if needed (only when IPv6 DNS servers are configured) + const hasIpv6Dns = upstreamDns.some(s => s.includes(':')); + if (hasIpv6Dns && ip6tablesAvailable) { + logger.debug(`Creating dedicated IPv6 chain '${CHAIN_NAME_V6}' for IPv6 DNS rules...`); + try { + const { exitCode: v6ChainExists } = await execa('ip6tables', ['-t', 'filter', '-L', CHAIN_NAME_V6, '-n'], { reject: false }); + if (v6ChainExists === 0) { + logger.debug(`Chain '${CHAIN_NAME_V6}' already exists, cleaning up...`); + 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); + } + await execa('ip6tables', ['-t', 'filter', '-N', CHAIN_NAME_V6]); + } + for (const dnsServer of upstreamDns) { + // IPv6 DNS servers must use ip6tables, IPv4 uses iptables + const isV6 = dnsServer.includes(':'); + if (isV6) { + if (ip6tablesAvailable) { 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', 'LOG', '--log-prefix', '[FW_DNS_QUERY] ', '--log-level', '4', - ]); - await execa('ip6tables', [ '-t', 'filter', '-A', CHAIN_NAME_V6, '-p', 'tcp', '-d', dnsServer, '--dport', '53', '-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', - ]); - - 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', - '-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', - ]); - - // 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', + } else { + await execa('iptables', [ + '-t', 'filter', '-A', CHAIN_NAME, + '-p', 'udp', '-d', dnsServer, '--dport', '53', + '-j', 'ACCEPT', ]); - - await execa('ip6tables', [ - '-t', 'filter', '-A', CHAIN_NAME_V6, - '-j', 'REJECT', '--reject-with', 'icmp6-port-unreachable', + await execa('iptables', [ + '-t', 'filter', '-A', CHAIN_NAME, + '-p', 'tcp', '-d', dnsServer, '--dport', '53', + '-j', 'ACCEPT', ]); } } - // Also allow DNS to Docker's embedded DNS server (127.0.0.11) for container name resolution - await execa('iptables', [ - '-t', 'filter', '-A', CHAIN_NAME, - '-p', 'udp', '-d', '127.0.0.11', '--dport', '53', - '-j', 'ACCEPT', - ]); - - await execa('iptables', [ - '-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', CHAIN_NAME, diff --git a/src/squid-config.ts b/src/squid-config.ts index cd036284..9613ee33 100644 --- a/src/squid-config.ts +++ b/src/squid-config.ts @@ -206,7 +206,7 @@ ${urlAclSection}${urlAccessRules}`; * // Blocked: internal.example.com -> acl blocked_domains dstdomain .internal.example.com */ export function generateSquidConfig(config: SquidConfig): string { - const { domains, blockedDomains, port, sslBump, caFiles, sslDbPath, urlPatterns, enableHostAccess, allowHostPorts, enableDlp } = config; + const { domains, blockedDomains, port, sslBump, caFiles, sslDbPath, urlPatterns, enableHostAccess, allowHostPorts, enableDlp, dnsServers } = config; // Parse domains into plain domains and wildcard patterns // Note: parseDomainList extracts and preserves protocol info from prefixes (http://, https://) @@ -567,8 +567,8 @@ http_access deny all # Disable caching cache deny all -# DNS settings -dns_nameservers 8.8.8.8 8.8.4.4 +# DNS settings - Squid resolves all domains for HTTP/HTTPS traffic +dns_nameservers ${(dnsServers && dnsServers.length > 0) ? dnsServers.join(' ') : '8.8.8.8 8.8.4.4'} # Forwarded headers forwarded_for delete diff --git a/src/ssl-bump.test.ts b/src/ssl-bump.test.ts index bf96b1b4..368378c0 100644 --- a/src/ssl-bump.test.ts +++ b/src/ssl-bump.test.ts @@ -2,7 +2,7 @@ import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; import execa from 'execa'; -import { parseUrlPatterns, generateSessionCa, initSslDb, isOpenSslAvailable, secureWipeFile, cleanupSslKeyMaterial, chownRecursive } from './ssl-bump'; +import { parseUrlPatterns, generateSessionCa, initSslDb, isOpenSslAvailable, secureWipeFile, cleanupSslKeyMaterial, chownRecursive, unmountSslTmpfs } from './ssl-bump'; // Pattern constant for the safer URL character class (matches the implementation) const URL_CHAR_PATTERN = '[^\\s]*'; @@ -455,4 +455,17 @@ describe('SSL Bump', () => { expect(() => cleanupSslKeyMaterial(tempDir)).not.toThrow(); }); }); + + describe('unmountSslTmpfs', () => { + it('should call umount on the ssl directory', async () => { + mockExeca.mockResolvedValueOnce({ stdout: '', stderr: '' }); + await unmountSslTmpfs('/tmp/test-ssl'); + expect(mockExeca).toHaveBeenCalledWith('umount', ['/tmp/test-ssl']); + }); + + it('should handle umount failure gracefully', async () => { + mockExeca.mockRejectedValueOnce(new Error('not mounted')); + await expect(unmountSslTmpfs('/tmp/test-ssl')).resolves.not.toThrow(); + }); + }); }); diff --git a/src/types.ts b/src/types.ts index 05bef1c4..b72d5a87 100644 --- a/src/types.ts +++ b/src/types.ts @@ -749,6 +749,17 @@ export interface SquidConfig { * @example "3000-3010,8000-8090" */ allowHostPorts?: string; + + /** + * DNS servers for Squid to use for domain resolution + * + * In the simplified security model, Squid handles all DNS resolution + * for HTTP/HTTPS traffic. These servers are passed to Squid's + * dns_nameservers directive. + * + * @default ['8.8.8.8', '8.8.4.4'] + */ + dnsServers?: string[]; } /** diff --git a/tests/integration/dns-servers.test.ts b/tests/integration/dns-servers.test.ts index 12054c01..ee19469e 100644 --- a/tests/integration/dns-servers.test.ts +++ b/tests/integration/dns-servers.test.ts @@ -1,11 +1,11 @@ /** * DNS Server Configuration Tests * - * These tests verify the --dns-servers CLI option: - * - Default DNS servers (8.8.8.8, 8.8.4.4) - * - Custom DNS server configuration - * - DNS resolution works with custom servers - * - Invalid DNS server handling + * These tests verify the simplified DNS security model: + * - Docker embedded DNS (127.0.0.11) handles all name resolution + * - Direct DNS queries to external servers are blocked + * - DNS resolution via Docker embedded DNS still works for allowed domains + * - The --dns-servers flag configures Docker embedded DNS forwarding */ /// @@ -14,7 +14,7 @@ import { describe, test, expect, beforeAll, afterAll, beforeEach } from '@jest/g import { createRunner, AwfRunner } from '../fixtures/awf-runner'; import { cleanup } from '../fixtures/cleanup'; -describe('DNS Server Configuration', () => { +describe('DNS Resolution via Docker Embedded DNS', () => { let runner: AwfRunner; beforeAll(async () => { @@ -26,7 +26,9 @@ describe('DNS Server Configuration', () => { await cleanup(false); }); - test('should resolve DNS with default servers', async () => { + test('should resolve DNS for allowed domains via Docker embedded DNS', async () => { + // DNS resolution uses Docker embedded DNS (127.0.0.11) which forwards + // to upstream servers configured via docker-compose dns: field const result = await runner.runWithSudo( 'nslookup github.com', { @@ -40,9 +42,9 @@ describe('DNS Server Configuration', () => { expect(result.stdout).toContain('Address'); }, 120000); - test('should resolve DNS with custom Google DNS server', async () => { + test('should resolve multiple domains sequentially', async () => { const result = await runner.runWithSudo( - 'nslookup github.com 8.8.8.8', + 'bash -c "nslookup github.com && nslookup api.github.com"', { allowDomains: ['github.com'], logLevel: 'debug', @@ -51,25 +53,25 @@ describe('DNS Server Configuration', () => { ); expect(result).toSucceed(); - expect(result.stdout).toContain('Address'); + expect(result.stdout).toContain('github.com'); }, 120000); - test('should resolve DNS with Cloudflare DNS server', async () => { + test('should resolve DNS with dig command via Docker embedded DNS', async () => { const result = await runner.runWithSudo( - 'nslookup github.com 1.1.1.1', + 'dig github.com +short', { allowDomains: ['github.com'], - dnsServers: ['1.1.1.1'], // Must whitelist Cloudflare DNS or iptables blocks it logLevel: 'debug', timeout: 60000, } ); expect(result).toSucceed(); - expect(result.stdout).toContain('Address'); + // dig should return IP address(es) + expect(result.stdout.trim()).toMatch(/\d+\.\d+\.\d+\.\d+/); }, 120000); - test('should show DNS servers in debug output', async () => { + test('should show DNS configuration in debug output', async () => { const result = await runner.runWithSudo( 'echo "test"', { @@ -84,38 +86,24 @@ describe('DNS Server Configuration', () => { expect(result.stderr).toMatch(/DNS|dns/); }, 120000); - test('should resolve multiple domains sequentially', async () => { - const result = await runner.runWithSudo( - 'bash -c "nslookup github.com && nslookup api.github.com"', - { - allowDomains: ['github.com'], - logLevel: 'debug', - timeout: 60000, - } - ); - - expect(result).toSucceed(); - // Both lookups should succeed - expect(result.stdout).toContain('github.com'); - }, 120000); - - test('should resolve DNS for allowed domains', async () => { + test('should work with custom DNS servers for Docker forwarding', async () => { + // Custom --dns-servers configures Docker embedded DNS upstream forwarding const result = await runner.runWithSudo( - 'dig github.com +short', + 'nslookup github.com', { allowDomains: ['github.com'], + dnsServers: ['1.1.1.1'], logLevel: 'debug', timeout: 60000, } ); expect(result).toSucceed(); - // dig should return IP address(es) - expect(result.stdout.trim()).toMatch(/\d+\.\d+\.\d+\.\d+/); + expect(result.stdout).toContain('Address'); }, 120000); }); -describe('DNS Restriction Enforcement', () => { +describe('DNS Exfiltration Prevention', () => { let runner: AwfRunner; beforeAll(async () => { @@ -132,58 +120,26 @@ describe('DNS Restriction Enforcement', () => { await cleanup(false); }); - test('should block DNS queries to non-whitelisted servers', async () => { - // Only whitelist Google DNS (8.8.8.8) — Cloudflare (1.1.1.1) should be blocked + test('should block direct DNS queries to non-configured DNS servers (Quad9)', async () => { + // Direct DNS to non-configured servers should be blocked (prevents DNS exfiltration) + // Default upstream is 8.8.8.8/8.8.4.4, so 9.9.9.9 (Quad9) is not allowed const result = await runner.runWithSudo( - 'nslookup example.com 1.1.1.1', + 'nslookup example.com 9.9.9.9', { allowDomains: ['example.com'], - dnsServers: ['8.8.8.8'], logLevel: 'debug', timeout: 60000, } ); - // DNS query to non-whitelisted server should fail + // Direct DNS query to non-configured server should fail expect(result).toFail(); }, 120000); - test('should allow DNS queries to whitelisted servers', async () => { - // Whitelist Google DNS (8.8.8.8) — queries to it should succeed + test('should block direct DNS queries to OpenDNS', async () => { + // OpenDNS (208.67.222.222) is not in the default upstream list const result = await runner.runWithSudo( - 'nslookup example.com 8.8.8.8', - { - allowDomains: ['example.com'], - dnsServers: ['8.8.8.8'], - logLevel: 'debug', - timeout: 60000, - } - ); - - expect(result).toSucceed(); - expect(result.stdout).toContain('Address'); - }, 120000); - - test('should pass --dns-servers flag through to iptables configuration', async () => { - const result = await runner.runWithSudo( - 'echo "dns-test"', - { - allowDomains: ['example.com'], - dnsServers: ['8.8.8.8'], - logLevel: 'debug', - timeout: 60000, - } - ); - - expect(result).toSucceed(); - // Debug output should show the custom DNS server configuration - expect(result.stderr).toContain('8.8.8.8'); - }, 120000); - - test('should work with default DNS when --dns-servers is not specified', async () => { - // Without explicit dnsServers, default Google DNS (8.8.8.8, 8.8.4.4) should work - const result = await runner.runWithSudo( - 'nslookup example.com', + 'nslookup example.com 208.67.222.222', { allowDomains: ['example.com'], logLevel: 'debug', @@ -191,15 +147,14 @@ describe('DNS Restriction Enforcement', () => { } ); - expect(result).toSucceed(); - expect(result.stdout).toContain('Address'); + // DNS query to non-configured external server should fail + expect(result).toFail(); }, 120000); - test('should block DNS to non-default server when using defaults', async () => { - // With default DNS (8.8.8.8, 8.8.4.4), a query to a random DNS server - // like 208.67.222.222 (OpenDNS) should be blocked + test('should block direct DNS queries to Cloudflare when not configured', async () => { + // Cloudflare DNS (1.1.1.1) is not in the default upstream list (8.8.8.8/8.8.4.4) const result = await runner.runWithSudo( - 'nslookup example.com 208.67.222.222', + 'nslookup example.com 1.1.1.1', { allowDomains: ['example.com'], logLevel: 'debug', @@ -207,23 +162,23 @@ describe('DNS Restriction Enforcement', () => { } ); - // DNS query to non-default server should fail + // Direct DNS query to non-configured server should fail expect(result).toFail(); }, 120000); - test('should allow Cloudflare DNS when explicitly whitelisted', async () => { - // Whitelist Cloudflare DNS (1.1.1.1) — queries to it should succeed + test('should pass --dns-servers flag through to configuration', async () => { const result = await runner.runWithSudo( - 'nslookup example.com 1.1.1.1', + 'echo "dns-test"', { allowDomains: ['example.com'], - dnsServers: ['1.1.1.1'], + dnsServers: ['8.8.8.8'], logLevel: 'debug', timeout: 60000, } ); expect(result).toSucceed(); - expect(result.stdout).toContain('Address'); + // Debug output should show the custom DNS server configuration + expect(result.stderr).toContain('8.8.8.8'); }, 120000); });