Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions containers/agent/setup-iptables.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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..."
Expand Down
43 changes: 43 additions & 0 deletions src/cli-workflow.test.ts
Original file line number Diff line number Diff line change
@@ -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'],
Expand Down Expand Up @@ -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 = {
Expand Down
8 changes: 6 additions & 2 deletions src/cli-workflow.ts
Original file line number Diff line number Diff line change
@@ -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<void>;
setupHostIptables: (squidIp: string, port: number, dnsServers: string[], apiProxyIp?: string, dohProxyIp?: string, hostAccess?: HostAccessConfig) => Promise<void>;
writeConfigs: (config: WrapperConfig) => Promise<void>;
startContainers: (workDir: string, allowedDomains: string[], proxyLogsDir?: string, skipPull?: boolean) => Promise<void>;
runAgentCommand: (
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
14 changes: 13 additions & 1 deletion src/docker-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)');
}
Comment on lines +846 to +854
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In chroot mode, this rewrites localhost to hostGatewayIp that was computed via docker network inspect bridge. That IP may not match the host.docker.internal:host-gateway value for the AWF network (often the awf-net gateway, e.g. 172.30.0.1), which is what setup-iptables.sh uses when adding NAT RETURN/ACCEPT rules. If these differ, chrooted processes will connect to an IP that is still DNAT’d to Squid (or blocked), so localhost/host access remains broken. Consider deriving the gateway from the actual AWF network (docker network inspect awf-net ... Gateway) or otherwise using the same IP that Docker will inject for host-gateway on the awf-net network.

This issue also appears on line 848 of the same file.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good observation, but this is already handled. The host-iptables.ts FW_WRAPPER chain adds ACCEPT rules for both gateway IPs:

  1. Docker bridge gateway (172.17.0.1) — from getDockerBridgeGateway() (line 410)
  2. AWF network gateway (172.30.0.1) — hardcoded as AWF_NETWORK_GATEWAY (line 414)

Similarly, setup-iptables.sh adds NAT RETURN + FILTER ACCEPT for both the host.docker.internal IP (resolved via getent hosts, typically 172.17.0.1) AND the network gateway (172.30.0.1, resolved via route -n).

The /etc/hosts uses the Docker bridge gateway IP (172.17.0.1) for the localhost entry, which matches the host.docker.internal IP that Docker injects via extra_hosts: host-gateway. Traffic to this IP is allowed by both the container-level and host-level rules. The dual-gateway approach ensures it works regardless of which IP the tool resolves to.

}
} catch (err) {
logger.debug(`Could not resolve Docker bridge gateway: ${err}`);
Expand Down Expand Up @@ -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 || '',
Expand Down
Loading
Loading