diff --git a/containers/agent/setup-iptables.sh b/containers/agent/setup-iptables.sh index e8efa0a45..e7db210f2 100644 --- a/containers/agent/setup-iptables.sh +++ b/containers/agent/setup-iptables.sh @@ -162,6 +162,21 @@ if [ -n "$AWF_API_PROXY_IP" ]; then iptables -t nat -A OUTPUT -d "$AWF_API_PROXY_IP" -j RETURN fi +# Validate port specification (single port 1-65535 or range N-M) +# Rejects leading zeros (e.g., 080) to align with TypeScript isValidPortSpec() +is_valid_port_spec() { + local spec="$1" + if echo "$spec" | grep -qE '^[1-9][0-9]{0,4}-[1-9][0-9]{0,4}$'; then + local start=$(echo "$spec" | cut -d- -f1) + local end=$(echo "$spec" | cut -d- -f2) + [ "$start" -ge 1 ] && [ "$start" -le 65535 ] && [ "$end" -ge 1 ] && [ "$end" -le 65535 ] && [ "$start" -le "$end" ] + elif echo "$spec" | grep -qE '^[1-9][0-9]{0,4}$'; then + [ "$spec" -ge 1 ] && [ "$spec" -le 65535 ] + else + return 1 + fi +} + # Bypass Squid for host.docker.internal when host access is enabled. # MCP gateway traffic to host.docker.internal gets DNAT'd to Squid, # where Squid fails with "Invalid URL" because rmcp sends relative URLs. @@ -181,6 +196,10 @@ if [ -n "$AWF_ENABLE_HOST_ACCESS" ]; then IFS=',' read -ra HOST_PORTS <<< "$AWF_ALLOW_HOST_PORTS" for port_spec in "${HOST_PORTS[@]}"; do port_spec=$(echo "$port_spec" | xargs) + if ! is_valid_port_spec "$port_spec"; then + echo "[iptables] WARNING: Skipping invalid port spec: $port_spec" + continue + fi echo "[iptables] Allow host gateway port $port_spec" iptables -A OUTPUT -p tcp -d "$HOST_GATEWAY_IP" --dport "$port_spec" -j ACCEPT done @@ -205,6 +224,10 @@ if [ -n "$AWF_ENABLE_HOST_ACCESS" ]; then IFS=',' read -ra NET_GW_PORTS <<< "$AWF_ALLOW_HOST_PORTS" for port_spec in "${NET_GW_PORTS[@]}"; do port_spec=$(echo "$port_spec" | xargs) + if ! is_valid_port_spec "$port_spec"; then + echo "[iptables] WARNING: Skipping invalid port spec: $port_spec" + continue + fi iptables -A OUTPUT -p tcp -d "$NETWORK_GATEWAY_IP" --dport "$port_spec" -j ACCEPT done fi @@ -263,6 +286,11 @@ if [ -n "$AWF_ALLOW_HOST_PORTS" ]; then # Remove leading/trailing spaces port_spec=$(echo "$port_spec" | xargs) + if ! is_valid_port_spec "$port_spec"; then + echo "[iptables] WARNING: Skipping invalid port spec: $port_spec" + continue + fi + if [[ $port_spec == *"-"* ]]; then # Port range (e.g., "3000-3010") echo "[iptables] Redirect port range $port_spec to Squid..." diff --git a/src/cli-workflow.test.ts b/src/cli-workflow.test.ts index f8158a896..43206c7a8 100644 --- a/src/cli-workflow.test.ts +++ b/src/cli-workflow.test.ts @@ -1,5 +1,6 @@ import { runMainWorkflow, WorkflowDependencies } from './cli-workflow'; import { WrapperConfig } from './types'; +import { HostAccessConfig } from './host-iptables'; const baseConfig: WrapperConfig = { allowedDomains: ['github.com'], @@ -109,6 +110,48 @@ describe('runMainWorkflow', () => { ); }); + it('passes hostAccess config when enableHostAccess is true', async () => { + const configWithHostAccess: WrapperConfig = { + ...baseConfig, + enableHostAccess: true, + allowHostPorts: '3000,8080', + }; + const dependencies: WorkflowDependencies = { + ensureFirewallNetwork: jest.fn().mockResolvedValue({ squidIp: '172.30.0.10', proxyIp: '172.30.0.30' }), + setupHostIptables: jest.fn().mockResolvedValue(undefined), + writeConfigs: jest.fn().mockResolvedValue(undefined), + startContainers: jest.fn().mockResolvedValue(undefined), + runAgentCommand: jest.fn().mockResolvedValue({ exitCode: 0 }), + }; + const performCleanup = jest.fn().mockResolvedValue(undefined); + const logger = createLogger(); + + await runMainWorkflow(configWithHostAccess, dependencies, { logger, performCleanup }); + + const expectedHostAccess: HostAccessConfig = { enabled: true, allowHostPorts: '3000,8080' }; + expect(dependencies.setupHostIptables).toHaveBeenCalledWith( + '172.30.0.10', 3128, ['8.8.8.8', '8.8.4.4'], undefined, undefined, expectedHostAccess + ); + }); + + it('passes undefined hostAccess when enableHostAccess is not set', async () => { + const dependencies: WorkflowDependencies = { + ensureFirewallNetwork: jest.fn().mockResolvedValue({ squidIp: '172.30.0.10', proxyIp: '172.30.0.30' }), + setupHostIptables: jest.fn().mockResolvedValue(undefined), + writeConfigs: jest.fn().mockResolvedValue(undefined), + startContainers: jest.fn().mockResolvedValue(undefined), + runAgentCommand: jest.fn().mockResolvedValue({ exitCode: 0 }), + }; + const performCleanup = jest.fn().mockResolvedValue(undefined); + const logger = createLogger(); + + await runMainWorkflow(baseConfig, dependencies, { logger, performCleanup }); + + expect(dependencies.setupHostIptables).toHaveBeenCalledWith( + '172.30.0.10', 3128, ['8.8.8.8', '8.8.4.4'], undefined, undefined, undefined + ); + }); + it('logs warning with exit code when command fails', async () => { const callOrder: string[] = []; const dependencies: WorkflowDependencies = { diff --git a/src/cli-workflow.ts b/src/cli-workflow.ts index 2cefd6a95..81b310798 100644 --- a/src/cli-workflow.ts +++ b/src/cli-workflow.ts @@ -1,8 +1,9 @@ import { WrapperConfig } from './types'; +import { HostAccessConfig } from './host-iptables'; export interface WorkflowDependencies { ensureFirewallNetwork: () => Promise<{ squidIp: string; agentIp: string; proxyIp: string; subnet: string }>; - setupHostIptables: (squidIp: string, port: number, dnsServers: string[], apiProxyIp?: string, dohProxyIp?: string) => Promise; + setupHostIptables: (squidIp: string, port: number, dnsServers: string[], apiProxyIp?: string, dohProxyIp?: string, hostAccess?: HostAccessConfig) => Promise; writeConfigs: (config: WrapperConfig) => Promise; startContainers: (workDir: string, allowedDomains: string[], proxyLogsDir?: string, skipPull?: boolean) => Promise; runAgentCommand: ( @@ -49,7 +50,10 @@ export async function runMainWorkflow( 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; - await dependencies.setupHostIptables(networkConfig.squidIp, 3128, dnsServers, apiProxyIp, dohProxyIp); + const hostAccess: HostAccessConfig | undefined = config.enableHostAccess + ? { enabled: true, allowHostPorts: config.allowHostPorts } + : undefined; + await dependencies.setupHostIptables(networkConfig.squidIp, 3128, dnsServers, apiProxyIp, dohProxyIp, hostAccess); onHostIptablesSetup?.(); // Step 1: Write configuration files diff --git a/src/cli.ts b/src/cli.ts index 9b038da77..e7c49e503 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1626,6 +1626,7 @@ program proxyLogsDir: options.proxyLogsDir, auditDir: options.auditDir || process.env.AWF_AUDIT_DIR, enableHostAccess: options.enableHostAccess, + localhostDetected: localhostResult.localhostDetected, allowHostPorts: options.allowHostPorts, sslBump: options.sslBump, enableDind: options.enableDind, diff --git a/src/docker-manager.ts b/src/docker-manager.ts index 15d8abec3..f1500d0ac 100644 --- a/src/docker-manager.ts +++ b/src/docker-manager.ts @@ -838,9 +838,20 @@ export function generateDockerCompose( '-f', '{{(index .IPAM.Config 0).Gateway}}' ]); const hostGatewayIp = stdout.trim(); - if (hostGatewayIp) { + const ipv4Regex = /^(\d{1,3}\.){3}\d{1,3}$/; + if (hostGatewayIp && ipv4Regex.test(hostGatewayIp)) { hostsContent += `${hostGatewayIp}\thost.docker.internal\n`; logger.debug(`Added host.docker.internal (${hostGatewayIp}) to chroot-hosts`); + + if (config.localhostDetected) { + // Replace 127.0.0.1 localhost entries with the host gateway IP + // /etc/hosts uses first-match semantics, so we must replace rather than append + hostsContent = hostsContent.replace( + /^127\.0\.0\.1\s+localhost(\s+.*)?$/gm, + `${hostGatewayIp}\tlocalhost$1` + ); + logger.info('localhost inside container resolves to host machine (localhost keyword active)'); + } } } catch (err) { logger.debug(`Could not resolve Docker bridge gateway: ${err}`); @@ -1213,6 +1224,7 @@ export function generateDockerCompose( AWF_DNS_SERVERS: environment.AWF_DNS_SERVERS || '', AWF_BLOCKED_PORTS: environment.AWF_BLOCKED_PORTS || '', AWF_ENABLE_HOST_ACCESS: environment.AWF_ENABLE_HOST_ACCESS || '', + AWF_ALLOW_HOST_PORTS: environment.AWF_ALLOW_HOST_PORTS || '', AWF_API_PROXY_IP: environment.AWF_API_PROXY_IP || '', AWF_DOH_PROXY_IP: environment.AWF_DOH_PROXY_IP || '', AWF_SSL_BUMP_ENABLED: environment.AWF_SSL_BUMP_ENABLED || '', diff --git a/src/host-iptables.test.ts b/src/host-iptables.test.ts index 21d573785..d2df1e819 100644 --- a/src/host-iptables.test.ts +++ b/src/host-iptables.test.ts @@ -1,4 +1,4 @@ -import { ensureFirewallNetwork, setupHostIptables, cleanupHostIptables, cleanupFirewallNetwork, _resetIpv6State } from './host-iptables'; +import { ensureFirewallNetwork, setupHostIptables, cleanupHostIptables, cleanupFirewallNetwork, _resetIpv6State, HostAccessConfig, isValidPortSpec } from './host-iptables'; import execa from 'execa'; // Mock execa @@ -531,6 +531,311 @@ describe('host-iptables', () => { }); + describe('setupHostIptables with host access', () => { + it('should add gateway ACCEPT rules when hostAccess is enabled', 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 for all subsequent calls; getDockerBridgeGateway returns 172.17.0.1 + mockedExeca.mockImplementation(((cmd: string, args: string[]) => { + if (cmd === 'docker' && args.includes('bridge')) { + return Promise.resolve({ stdout: '172.17.0.1', stderr: '', exitCode: 0 }); + } + if (cmd === 'ip6tables') { + return Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }); + } + return Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }); + }) as any); + + const hostAccess: HostAccessConfig = { enabled: true }; + await setupHostIptables('172.30.0.10', 3128, ['8.8.8.8', '8.8.4.4'], undefined, undefined, hostAccess); + + // Verify ACCEPT rules for Docker bridge gateway on default ports + expect(mockedExeca).toHaveBeenCalledWith('iptables', [ + '-t', 'filter', '-A', 'FW_WRAPPER', + '-p', 'tcp', '-d', '172.17.0.1', '--dport', '80', + '-j', 'ACCEPT', + ]); + expect(mockedExeca).toHaveBeenCalledWith('iptables', [ + '-t', 'filter', '-A', 'FW_WRAPPER', + '-p', 'tcp', '-d', '172.17.0.1', '--dport', '443', + '-j', 'ACCEPT', + ]); + + // Verify ACCEPT rules for AWF network gateway (172.30.0.1) on default ports + expect(mockedExeca).toHaveBeenCalledWith('iptables', [ + '-t', 'filter', '-A', 'FW_WRAPPER', + '-p', 'tcp', '-d', '172.30.0.1', '--dport', '80', + '-j', 'ACCEPT', + ]); + expect(mockedExeca).toHaveBeenCalledWith('iptables', [ + '-t', 'filter', '-A', 'FW_WRAPPER', + '-p', 'tcp', '-d', '172.30.0.1', '--dport', '443', + '-j', 'ACCEPT', + ]); + }); + + it('should not add gateway rules when hostAccess is undefined', async () => { + mockedExeca + .mockResolvedValueOnce({ stdout: 'fw-bridge', stderr: '', exitCode: 0 } as any) + .mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 } as any) + .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 no gateway rules for 172.30.0.1 or 172.17.0.1 + expect(mockedExeca).not.toHaveBeenCalledWith('iptables', expect.arrayContaining([ + '-d', '172.30.0.1', '--dport', '80', + ])); + expect(mockedExeca).not.toHaveBeenCalledWith('iptables', expect.arrayContaining([ + '-d', '172.17.0.1', + ])); + }); + + it('should add custom port rules when allowHostPorts is specified', async () => { + mockedExeca + .mockResolvedValueOnce({ stdout: 'fw-bridge', stderr: '', exitCode: 0 } as any) + .mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 } as any) + .mockResolvedValueOnce({ exitCode: 1 } as any); + + mockedExeca.mockImplementation(((cmd: string, args: string[]) => { + if (cmd === 'docker' && args.includes('bridge')) { + return Promise.resolve({ stdout: '172.17.0.1', stderr: '', exitCode: 0 }); + } + return Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }); + }) as any); + + const hostAccess: HostAccessConfig = { enabled: true, allowHostPorts: '3000,8080' }; + await setupHostIptables('172.30.0.10', 3128, ['8.8.8.8', '8.8.4.4'], undefined, undefined, hostAccess); + + // Verify custom port rules for Docker bridge gateway + expect(mockedExeca).toHaveBeenCalledWith('iptables', [ + '-t', 'filter', '-A', 'FW_WRAPPER', + '-p', 'tcp', '-d', '172.17.0.1', '--dport', '3000', + '-j', 'ACCEPT', + ]); + expect(mockedExeca).toHaveBeenCalledWith('iptables', [ + '-t', 'filter', '-A', 'FW_WRAPPER', + '-p', 'tcp', '-d', '172.17.0.1', '--dport', '8080', + '-j', 'ACCEPT', + ]); + + // Verify custom port rules for AWF network gateway + expect(mockedExeca).toHaveBeenCalledWith('iptables', [ + '-t', 'filter', '-A', 'FW_WRAPPER', + '-p', 'tcp', '-d', '172.30.0.1', '--dport', '3000', + '-j', 'ACCEPT', + ]); + expect(mockedExeca).toHaveBeenCalledWith('iptables', [ + '-t', 'filter', '-A', 'FW_WRAPPER', + '-p', 'tcp', '-d', '172.30.0.1', '--dport', '8080', + '-j', 'ACCEPT', + ]); + }); + + it('should only use AWF gateway when Docker bridge gateway is null', async () => { + mockedExeca + .mockResolvedValueOnce({ stdout: 'fw-bridge', stderr: '', exitCode: 0 } as any) + .mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 } as any) + .mockResolvedValueOnce({ exitCode: 1 } as any); + + mockedExeca.mockImplementation(((cmd: string, args: string[]) => { + // Make getDockerBridgeGateway return null (docker network inspect bridge fails) + if (cmd === 'docker' && args.includes('bridge')) { + return Promise.reject(new Error('network bridge not found')); + } + return Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }); + }) as any); + + const hostAccess: HostAccessConfig = { enabled: true }; + await setupHostIptables('172.30.0.10', 3128, ['8.8.8.8', '8.8.4.4'], undefined, undefined, hostAccess); + + // Verify rules for AWF network gateway (172.30.0.1) + expect(mockedExeca).toHaveBeenCalledWith('iptables', [ + '-t', 'filter', '-A', 'FW_WRAPPER', + '-p', 'tcp', '-d', '172.30.0.1', '--dport', '80', + '-j', 'ACCEPT', + ]); + + // Verify NO rules for Docker bridge gateway (172.17.0.1) + expect(mockedExeca).not.toHaveBeenCalledWith('iptables', [ + '-t', 'filter', '-A', 'FW_WRAPPER', + '-p', 'tcp', '-d', '172.17.0.1', '--dport', '80', + '-j', 'ACCEPT', + ]); + }); + + it('should only add default ports when allowHostPorts is empty', async () => { + mockedExeca + .mockResolvedValueOnce({ stdout: 'fw-bridge', stderr: '', exitCode: 0 } as any) + .mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 } as any) + .mockResolvedValueOnce({ exitCode: 1 } as any); + + mockedExeca.mockImplementation(((cmd: string, args: string[]) => { + if (cmd === 'docker' && args.includes('bridge')) { + return Promise.resolve({ stdout: '172.17.0.1', stderr: '', exitCode: 0 }); + } + return Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }); + }) as any); + + const hostAccess: HostAccessConfig = { enabled: true, allowHostPorts: '' }; + await setupHostIptables('172.30.0.10', 3128, ['8.8.8.8', '8.8.4.4'], undefined, undefined, hostAccess); + + // Verify default port 80 rules exist + expect(mockedExeca).toHaveBeenCalledWith('iptables', [ + '-t', 'filter', '-A', 'FW_WRAPPER', + '-p', 'tcp', '-d', '172.30.0.1', '--dport', '80', + '-j', 'ACCEPT', + ]); + expect(mockedExeca).toHaveBeenCalledWith('iptables', [ + '-t', 'filter', '-A', 'FW_WRAPPER', + '-p', 'tcp', '-d', '172.30.0.1', '--dport', '443', + '-j', 'ACCEPT', + ]); + }); + + it('should support port ranges in allowHostPorts', async () => { + mockedExeca + .mockResolvedValueOnce({ stdout: 'fw-bridge', stderr: '', exitCode: 0 } as any) + .mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 } as any) + .mockResolvedValueOnce({ exitCode: 1 } as any); + + mockedExeca.mockImplementation(((cmd: string, args: string[]) => { + if (cmd === 'docker' && args.includes('bridge')) { + return Promise.resolve({ stdout: '172.17.0.1', stderr: '', exitCode: 0 }); + } + return Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }); + }) as any); + + const hostAccess: HostAccessConfig = { enabled: true, allowHostPorts: '3000-3010' }; + await setupHostIptables('172.30.0.10', 3128, ['8.8.8.8', '8.8.4.4'], undefined, undefined, hostAccess); + + // Verify port range rule + expect(mockedExeca).toHaveBeenCalledWith('iptables', [ + '-t', 'filter', '-A', 'FW_WRAPPER', + '-p', 'tcp', '-d', '172.30.0.1', '--dport', '3000-3010', + '-j', 'ACCEPT', + ]); + expect(mockedExeca).toHaveBeenCalledWith('iptables', [ + '-t', 'filter', '-A', 'FW_WRAPPER', + '-p', 'tcp', '-d', '172.17.0.1', '--dport', '3000-3010', + '-j', 'ACCEPT', + ]); + }); + + it('should skip invalid ports in allowHostPorts', async () => { + mockedExeca + .mockResolvedValueOnce({ stdout: 'fw-bridge', stderr: '', exitCode: 0 } as any) + .mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 } as any) + .mockResolvedValueOnce({ exitCode: 1 } as any); + + mockedExeca.mockImplementation(((cmd: string, args: string[]) => { + if (cmd === 'docker' && args.includes('bridge')) { + return Promise.resolve({ stdout: '172.17.0.1', stderr: '', exitCode: 0 }); + } + return Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }); + }) as any); + + const hostAccess: HostAccessConfig = { enabled: true, allowHostPorts: 'abc,99999,-1' }; + await setupHostIptables('172.30.0.10', 3128, ['8.8.8.8', '8.8.4.4'], undefined, undefined, hostAccess); + + // Verify invalid ports are NOT added - only default ports (80, 443) should exist + expect(mockedExeca).not.toHaveBeenCalledWith('iptables', expect.arrayContaining([ + '--dport', 'abc', + ])); + expect(mockedExeca).not.toHaveBeenCalledWith('iptables', expect.arrayContaining([ + '--dport', '99999', + ])); + expect(mockedExeca).not.toHaveBeenCalledWith('iptables', expect.arrayContaining([ + '--dport', '-1', + ])); + + // Default ports should still be present + expect(mockedExeca).toHaveBeenCalledWith('iptables', [ + '-t', 'filter', '-A', 'FW_WRAPPER', + '-p', 'tcp', '-d', '172.30.0.1', '--dport', '80', + '-j', 'ACCEPT', + ]); + expect(mockedExeca).toHaveBeenCalledWith('iptables', [ + '-t', 'filter', '-A', 'FW_WRAPPER', + '-p', 'tcp', '-d', '172.30.0.1', '--dport', '443', + '-j', 'ACCEPT', + ]); + }); + + it('should deduplicate ports when custom ports overlap with defaults', async () => { + mockedExeca + .mockResolvedValueOnce({ stdout: 'fw-bridge', stderr: '', exitCode: 0 } as any) + .mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 } as any) + .mockResolvedValueOnce({ exitCode: 1 } as any); + + mockedExeca.mockImplementation(((cmd: string, args: string[]) => { + if (cmd === 'docker' && args.includes('bridge')) { + return Promise.resolve({ stdout: '172.17.0.1', stderr: '', exitCode: 0 }); + } + return Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }); + }) as any); + + // Pass 80 and 443 as custom ports (duplicates of defaults) plus 3000 + const hostAccess: HostAccessConfig = { enabled: true, allowHostPorts: '80,443,3000' }; + await setupHostIptables('172.30.0.10', 3128, ['8.8.8.8', '8.8.4.4'], undefined, undefined, hostAccess); + + // Count how many times port 80 rule was called for 172.30.0.1 + const port80Calls = mockedExeca.mock.calls.filter( + (call) => call[0] === 'iptables' && + Array.isArray(call[1]) && + call[1].includes('--dport') && + call[1][call[1].indexOf('--dport') + 1] === '80' && + call[1].includes('-d') && + call[1][call[1].indexOf('-d') + 1] === '172.30.0.1' + ); + // Should only be called once (deduplicated) + expect(port80Calls).toHaveLength(1); + + // Verify port 3000 also got a rule + expect(mockedExeca).toHaveBeenCalledWith('iptables', [ + '-t', 'filter', '-A', 'FW_WRAPPER', + '-p', 'tcp', '-d', '172.30.0.1', '--dport', '3000', + '-j', 'ACCEPT', + ]); + }); + }); + + describe('isValidPortSpec', () => { + it('should accept valid single ports', () => { + expect(isValidPortSpec('1')).toBe(true); + expect(isValidPortSpec('80')).toBe(true); + expect(isValidPortSpec('443')).toBe(true); + expect(isValidPortSpec('65535')).toBe(true); + }); + + it('should accept valid port ranges', () => { + expect(isValidPortSpec('3000-3010')).toBe(true); + expect(isValidPortSpec('1-65535')).toBe(true); + expect(isValidPortSpec('80-80')).toBe(true); + }); + + it('should reject invalid port specs', () => { + expect(isValidPortSpec('abc')).toBe(false); + expect(isValidPortSpec('0')).toBe(false); + expect(isValidPortSpec('65536')).toBe(false); + expect(isValidPortSpec('-1')).toBe(false); + expect(isValidPortSpec('99999')).toBe(false); + expect(isValidPortSpec('3010-3000')).toBe(false); // reversed range + expect(isValidPortSpec('')).toBe(false); + expect(isValidPortSpec('080-090')).toBe(false); // leading zeros in range + expect(isValidPortSpec('01-100')).toBe(false); // leading zero in start + expect(isValidPortSpec('1-0100')).toBe(false); // leading zero in end + }); + }); + describe('cleanupHostIptables', () => { it('should flush and delete both FW_WRAPPER and FW_WRAPPER_V6 chains', async () => { mockedExeca.mockResolvedValue({ diff --git a/src/host-iptables.ts b/src/host-iptables.ts index c12f8a27b..117d2a8c6 100644 --- a/src/host-iptables.ts +++ b/src/host-iptables.ts @@ -6,6 +6,33 @@ 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'; +const AWF_NETWORK_GATEWAY = '172.30.0.1'; + +/** + * Configuration for host access rules in the FW_WRAPPER chain. + * When enabled, allows container traffic to reach the Docker host gateway + * (needed for Playwright localhost testing, MCP servers, etc.). + */ +export interface HostAccessConfig { + enabled: boolean; + allowHostPorts?: string; +} + +/** + * Validates a port specification string. + * Accepts a single port (1-65535) or a port range ("N-M" where both are valid ports and N <= M). + */ +export function isValidPortSpec(spec: string): boolean { + const rangeMatch = spec.match(/^(\d+)-(\d+)$/); + if (rangeMatch) { + const start = parseInt(rangeMatch[1], 10); + const end = parseInt(rangeMatch[2], 10); + if (String(start) !== rangeMatch[1] || String(end) !== rangeMatch[2]) return false; + return start >= 1 && start <= 65535 && end >= 1 && end <= 65535 && start <= end; + } + const port = parseInt(spec, 10); + return !isNaN(port) && String(port) === spec && port >= 1 && port <= 65535; +} // Cache for ip6tables availability check (only checked once per run) let ip6tablesAvailableCache: boolean | null = null; @@ -41,6 +68,31 @@ async function getNetworkBridgeName(): Promise { } } +/** + * Gets the Docker default bridge gateway IP (e.g., 172.17.0.1). + * This is the IP that host.docker.internal resolves to inside containers. + */ +export async function getDockerBridgeGateway(): Promise { + try { + const { stdout } = await execa('docker', [ + 'network', 'inspect', 'bridge', + '-f', '{{(index .IPAM.Config 0).Gateway}}', + ]); + const gateway = stdout.trim(); + if (!gateway) return null; + // Validate IPv4 format before using in iptables rules + const ipv4Regex = /^(\d{1,3}\.){3}\d{1,3}$/; + if (!ipv4Regex.test(gateway)) { + logger.warn(`Docker bridge gateway returned invalid IPv4: ${gateway}, skipping`); + return null; + } + return gateway; + } catch (error) { + logger.debug('Failed to get Docker bridge gateway:', error); + return null; + } +} + /** * Checks if ip6tables is available and functional. * The result is cached to avoid redundant system calls. @@ -153,8 +205,9 @@ export async function ensureFirewallNetwork(): Promise<{ * @param squidPort - Port number of the Squid proxy * @param apiProxyIp - Optional IP address of the API proxy sidecar * @param dnsServers - Upstream DNS servers that Docker embedded DNS forwards to + * @param hostAccess - Optional host access configuration for localhost/Playwright support */ -export async function setupHostIptables(squidIp: string, squidPort: number, dnsServers: string[], apiProxyIp?: string, dohProxyIp?: string): Promise { +export async function setupHostIptables(squidIp: string, squidPort: number, dnsServers: string[], apiProxyIp?: string, dohProxyIp?: string, hostAccess?: HostAccessConfig): Promise { logger.info('Setting up host-level iptables rules...'); // Get the bridge interface name @@ -373,6 +426,49 @@ export async function setupHostIptables(squidIp: string, squidPort: number, dnsS ]); } + // 5c. Allow traffic to host gateway when host access is enabled + // This is needed for Playwright localhost testing, MCP servers, etc. + if (hostAccess?.enabled) { + const gatewayIp = await getDockerBridgeGateway(); + const gatewayIps = [AWF_NETWORK_GATEWAY]; + if (gatewayIp) { + gatewayIps.push(gatewayIp); + } + + // Default: allow HTTP (80) and HTTPS (443) + const defaultPorts = ['80', '443']; + + // Parse additional custom ports + const customPorts: string[] = []; + if (hostAccess.allowHostPorts) { + for (const entry of hostAccess.allowHostPorts.split(',')) { + const trimmed = entry.trim(); + if (trimmed) { + if (!isValidPortSpec(trimmed)) { + logger.warn(`Skipping invalid port spec: ${trimmed}`); + continue; + } + customPorts.push(trimmed); + } + } + } + + const allPorts = [...new Set([...defaultPorts, ...customPorts])]; + + for (const gwIp of gatewayIps) { + for (const port of allPorts) { + // Port ranges (e.g., "3000-3010") use --dport with range syntax + logger.debug(`Allowing host gateway traffic: ${gwIp}:${port}`); + await execa('iptables', [ + '-t', 'filter', '-A', CHAIN_NAME, + '-p', 'tcp', '-d', gwIp, '--dport', port, + '-j', 'ACCEPT', + ]); + } + } + logger.info(`Host access enabled: allowing traffic to gateway IPs ${gatewayIps.join(', ')} on ports ${allPorts.join(', ')}`); + } + // 6. Block multicast and link-local traffic await execa('iptables', [ '-t', 'filter', '-A', CHAIN_NAME, diff --git a/src/types.ts b/src/types.ts index fdf361a5a..3c67e146b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -385,6 +385,17 @@ export interface WrapperConfig { */ enableHostAccess?: boolean; + /** + * Whether the localhost keyword was detected in --allow-domains. + * + * When true, localhost inside the container resolves to the host machine's + * Docker bridge gateway IP instead of 127.0.0.1 (container loopback). + * This allows Playwright and other tools to access services running on the host. + * + * @default undefined (localhost resolves to container loopback as normal) + */ + localhostDetected?: boolean; + /** * Additional ports to allow when using --enable-host-access *