Skip to content
Open
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
229 changes: 217 additions & 12 deletions containers/agent/docker-wrapper.sh
Original file line number Diff line number Diff line change
@@ -1,11 +1,132 @@
#!/bin/bash
# Docker wrapper that injects --network awf-net and proxy env vars to all docker run commands
# This ensures spawned containers are subject to the same firewall rules
#
# Security: This wrapper also injects NAT rules into child containers to prevent proxy bypass.
# Applications that ignore HTTP_PROXY environment variables will still have their traffic
# redirected to Squid via iptables NAT rules.

NETWORK_NAME="awf-net"
SQUID_PROXY="http://172.30.0.10:3128"
SQUID_IP="172.30.0.10"
SQUID_PORT="3128"
LOG_FILE="/tmp/docker-wrapper.log"

# NAT setup script that will be injected into child containers
# This script redirects HTTP/HTTPS traffic to Squid proxy using iptables
# It's designed to be minimal and work with busybox/alpine shells
# Note: The script ends with a semicolon to ensure proper command separation
NAT_SETUP_SCRIPT='if command -v iptables >/dev/null 2>&1; then iptables -t nat -F OUTPUT 2>/dev/null || true; iptables -t nat -A OUTPUT -o lo -j RETURN; iptables -t nat -A OUTPUT -d 127.0.0.0/8 -j RETURN; 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; iptables -t nat -A OUTPUT -p udp -d 8.8.8.8 --dport 53 -j RETURN; iptables -t nat -A OUTPUT -p tcp -d 8.8.8.8 --dport 53 -j RETURN; iptables -t nat -A OUTPUT -p udp -d 8.8.4.4 --dport 53 -j RETURN; iptables -t nat -A OUTPUT -p tcp -d 8.8.4.4 --dport 53 -j RETURN; iptables -t nat -A OUTPUT -d 8.8.8.8 -j RETURN; iptables -t nat -A OUTPUT -d 8.8.4.4 -j RETURN; iptables -t nat -A OUTPUT -d '"$SQUID_IP"' -j RETURN; iptables -t nat -A OUTPUT -p tcp --dport 80 -j DNAT --to-destination '"$SQUID_IP:$SQUID_PORT"'; iptables -t nat -A OUTPUT -p tcp --dport 443 -j DNAT --to-destination '"$SQUID_IP:$SQUID_PORT"'; fi; '

# Function to escape a command argument for use in sh -c
# Uses printf %q for robust escaping of all shell metacharacters
escape_for_shell() {
local arg="$1"
# Use printf %q for proper shell escaping
printf '%q' "$arg"
}

# Function to build escaped command string from array of arguments
build_escaped_cmd() {
local escaped=""
for arg in "$@"; do
if [ -n "$escaped" ]; then
escaped="$escaped "
fi
escaped="$escaped$(escape_for_shell "$arg")"
done
echo "$escaped"
}

# Function to check if an argument is a Docker option that takes a value
is_docker_option_with_value() {
local arg="$1"
case "$arg" in
-e|--env|-l|--label|-v|--volume|-p|--publish|--name|--hostname|\
--user|-u|--workdir|-w|--mount|--network-alias|--dns|--dns-search|\
--dns-option|--cpus|--memory|-m|--memory-swap|--memory-reservation|\
--kernel-memory|--cpu-shares|--cpu-period|--cpu-quota|--cpuset-cpus|\
--cpuset-mems|--blkio-weight|--device|--device-cgroup-rule|\
--device-read-bps|--device-read-iops|--device-write-bps|\
--device-write-iops|--cap-add|--cap-drop|--security-opt|--ulimit|\
--sysctl|--restart|--stop-signal|--stop-timeout|--health-cmd|\
--health-interval|--health-retries|--health-start-period|\
--health-timeout|--log-driver|--log-opt|--storage-opt|\
--tmpfs|--ipc|--pid|--userns|--uts|--runtime|--isolation|\
--platform|--pull|--shm-size|--group-add|--cidfile|--cgroup-parent|\
--init|--read-only|--gpus|--link|--link-local-ip|--ip|--ip6|\
--mac-address|--expose|--domainname|--add-host|--annotation)
return 0
;;
*)
return 1
;;
esac
}

# Function to parse docker run arguments and extract options, image, and command
# Sets global variables: PARSED_DOCKER_OPTS, PARSED_IMAGE, PARSED_USER_CMD
parse_docker_run_args() {
local include_network="$1"
shift

PARSED_DOCKER_OPTS=()
PARSED_USER_CMD=()
PARSED_IMAGE=""
local parsing_opts=true
local skip_next=false

for arg in "$@"; do
if [ "$skip_next" = true ]; then
PARSED_DOCKER_OPTS+=("$arg")
skip_next=false
continue
fi

if [ "$parsing_opts" = true ]; then
# Check for --entrypoint flag (we track this but don't use it currently)
if [ "$arg" = "--entrypoint" ]; then
PARSED_DOCKER_OPTS+=("$arg")
skip_next=true
continue
fi
if [[ "$arg" == "--entrypoint="* ]]; then
PARSED_DOCKER_OPTS+=("$arg")
continue
fi

# Check if it's a Docker option that takes a value
if is_docker_option_with_value "$arg"; then
PARSED_DOCKER_OPTS+=("$arg")
skip_next=true
continue
fi

# Handle --network and --net specially if we need to include them
if [ "$include_network" = "true" ]; then
if [ "$arg" = "--network" ] || [ "$arg" = "--net" ]; then
PARSED_DOCKER_OPTS+=("$arg")
skip_next=true
continue
fi
fi

# Docker options that are flags (no value) or have = format
if [[ "$arg" == -* ]]; then
PARSED_DOCKER_OPTS+=("$arg")
continue
fi

# First non-option argument is the image name
PARSED_IMAGE="$arg"
parsing_opts=false
else
# Everything after the image name is the command
PARSED_USER_CMD+=("$arg")
fi
done
}

# Log all docker commands
echo "[$(date -Iseconds)] WRAPPER CALLED: docker $@" >> "$LOG_FILE"

Expand Down Expand Up @@ -78,23 +199,107 @@ if [ "$1" = "run" ]; then
exit 1
fi

# If --network not specified, inject it along with proxy environment variables
# If --network not specified, inject it along with proxy environment variables and NAT rules
if [ "$has_network" = false ]; then
# Build new args: docker run --network awf-net -e HTTP_PROXY -e HTTPS_PROXY <rest of args>
shift # remove 'run'
echo "[$(date -Iseconds)] INJECTING --network $NETWORK_NAME and proxy env vars" >> "$LOG_FILE"
exec /usr/bin/docker-real run \
--network "$NETWORK_NAME" \
-e HTTP_PROXY="$SQUID_PROXY" \
-e HTTPS_PROXY="$SQUID_PROXY" \
-e http_proxy="$SQUID_PROXY" \
-e https_proxy="$SQUID_PROXY" \
"$@"
echo "[$(date -Iseconds)] INJECTING --network $NETWORK_NAME, proxy env vars, and NAT rules" >> "$LOG_FILE"

# Parse docker run arguments
parse_docker_run_args "false" "$@"

# If no image was found, just pass through
if [ -z "$PARSED_IMAGE" ]; then
echo "[$(date -Iseconds)] ERROR: Could not find image name, passing through" >> "$LOG_FILE"
exec /usr/bin/docker-real run "$@"
fi

echo "[$(date -Iseconds)] Image: $PARSED_IMAGE, Command: ${PARSED_USER_CMD[*]:-<default>}" >> "$LOG_FILE"

# Build the wrapped command that sets up NAT rules then runs the user's command
if [ ${#PARSED_USER_CMD[@]} -gt 0 ]; then
# User specified a command - wrap it with NAT setup
escaped_cmd=$(build_escaped_cmd "${PARSED_USER_CMD[@]}")
wrapped_cmd="${NAT_SETUP_SCRIPT}exec ${escaped_cmd}"

# Execute with NAT wrapper
exec /usr/bin/docker-real run \
--network "$NETWORK_NAME" \
--cap-add NET_ADMIN \
-e HTTP_PROXY="$SQUID_PROXY" \
-e HTTPS_PROXY="$SQUID_PROXY" \
-e http_proxy="$SQUID_PROXY" \
-e https_proxy="$SQUID_PROXY" \
-e SQUID_PROXY_IP="$SQUID_IP" \
-e SQUID_PROXY_PORT="$SQUID_PORT" \
"${PARSED_DOCKER_OPTS[@]}" \
--entrypoint sh \
"$PARSED_IMAGE" \
-c "$wrapped_cmd"
else
# No user command - can't easily wrap the default entrypoint
echo "[$(date -Iseconds)] WARNING: No command specified, NAT rules may not apply to default entrypoint" >> "$LOG_FILE"
exec /usr/bin/docker-real run \
--network "$NETWORK_NAME" \
--cap-add NET_ADMIN \
-e HTTP_PROXY="$SQUID_PROXY" \
-e HTTPS_PROXY="$SQUID_PROXY" \
-e http_proxy="$SQUID_PROXY" \
-e https_proxy="$SQUID_PROXY" \
-e SQUID_PROXY_IP="$SQUID_IP" \
-e SQUID_PROXY_PORT="$SQUID_PORT" \
"${PARSED_DOCKER_OPTS[@]}" \
"$PARSED_IMAGE"
fi
else
echo "[$(date -Iseconds)] --network $network_value already specified, passing through" >> "$LOG_FILE"
# Network is already specified (and not host) - still inject NAT rules and proxy env vars
echo "[$(date -Iseconds)] --network $network_value already specified, injecting NAT rules and proxy env vars" >> "$LOG_FILE"

shift # remove 'run'

# Parse docker run arguments (include network options in docker_opts)
parse_docker_run_args "true" "$@"

# If no image was found, just pass through
if [ -z "$PARSED_IMAGE" ]; then
echo "[$(date -Iseconds)] ERROR: Could not find image name, passing through" >> "$LOG_FILE"
exec /usr/bin/docker-real run "$@"
fi

echo "[$(date -Iseconds)] Image: $PARSED_IMAGE, Command: ${PARSED_USER_CMD[*]:-<default>}" >> "$LOG_FILE"

# Build the wrapped command
if [ ${#PARSED_USER_CMD[@]} -gt 0 ]; then
escaped_cmd=$(build_escaped_cmd "${PARSED_USER_CMD[@]}")
wrapped_cmd="${NAT_SETUP_SCRIPT}exec ${escaped_cmd}"

exec /usr/bin/docker-real run \
--cap-add NET_ADMIN \
-e HTTP_PROXY="$SQUID_PROXY" \
-e HTTPS_PROXY="$SQUID_PROXY" \
-e http_proxy="$SQUID_PROXY" \
-e https_proxy="$SQUID_PROXY" \
-e SQUID_PROXY_IP="$SQUID_IP" \
-e SQUID_PROXY_PORT="$SQUID_PORT" \
"${PARSED_DOCKER_OPTS[@]}" \
--entrypoint sh \
"$PARSED_IMAGE" \
-c "$wrapped_cmd"
else
# No command specified - pass through with proxy env vars
exec /usr/bin/docker-real run \
--cap-add NET_ADMIN \
-e HTTP_PROXY="$SQUID_PROXY" \
-e HTTPS_PROXY="$SQUID_PROXY" \
-e http_proxy="$SQUID_PROXY" \
-e https_proxy="$SQUID_PROXY" \
-e SQUID_PROXY_IP="$SQUID_IP" \
-e SQUID_PROXY_PORT="$SQUID_PORT" \
"${PARSED_DOCKER_OPTS[@]}" \
"$PARSED_IMAGE"
fi
fi
fi

# For all other commands or if --network already specified, pass through
# For all other commands (not docker run), pass through
echo "[$(date -Iseconds)] PASSING THROUGH: /usr/bin/docker-real $@" >> "$LOG_FILE"
exec /usr/bin/docker-real "$@"
80 changes: 80 additions & 0 deletions tests/integration/docker-egress.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -346,4 +346,84 @@ describe('Docker Container Egress Tests', () => {
expect(result).toFail();
}, 120000);
});

describe('8M. NAT rules in child containers (proxy bypass prevention)', () => {
test('Container: NAT rules applied - blocks traffic even when proxy env vars are ignored', async () => {
// This test verifies that child containers have NAT rules applied via docker-wrapper.sh
// Even if an application ignores HTTP_PROXY env vars, traffic is still redirected to Squid
const result = await runner.runWithSudo(
`docker run --rm alpine:latest sh -c 'apk add --no-cache curl iptables >/dev/null 2>&1 && unset HTTP_PROXY HTTPS_PROXY http_proxy https_proxy && curl -f https://example.com --max-time 10'`,
{
allowDomains: ['github.com'],
logLevel: 'warn',
timeout: 60000,
}
);

// Should fail because NAT rules redirect traffic to Squid which blocks example.com
expect(result).toFail();
}, 180000);

test('Container: NAT rules applied - allows whitelisted domains even when proxy env vars are ignored', async () => {
// Verify that allowed domains still work even when proxy env vars are unset
const result = await runner.runWithSudo(
`docker run --rm alpine:latest sh -c 'apk add --no-cache curl iptables >/dev/null 2>&1 && unset HTTP_PROXY HTTPS_PROXY http_proxy https_proxy && curl -fsS https://api.github.com/zen --max-time 30'`,
{
allowDomains: ['api.github.com'],
logLevel: 'warn',
timeout: 60000,
}
);

// Should succeed because NAT rules redirect to Squid which allows api.github.com
expect(result).toSucceed();
}, 180000);

test('Container: wget --no-proxy blocked by NAT rules', async () => {
// wget --no-proxy explicitly ignores proxy settings, but NAT rules should still work
const result = await runner.runWithSudo(
`docker run --rm alpine:latest sh -c 'apk add --no-cache wget iptables >/dev/null 2>&1 && wget --no-proxy -q -O- https://example.com --timeout=10'`,
{
allowDomains: ['github.com'],
logLevel: 'warn',
timeout: 60000,
}
);

// Should fail because NAT rules redirect traffic to Squid regardless of --no-proxy
expect(result).toFail();
}, 180000);

test('Container: Verify NAT rules are present in child container', async () => {
// Directly check if NAT rules are applied in child container
const result = await runner.runWithSudo(
`docker run --rm alpine:latest sh -c 'apk add --no-cache iptables >/dev/null 2>&1 && iptables -t nat -L OUTPUT -n 2>/dev/null | grep DNAT || echo "No NAT rules"'`,
{
allowDomains: ['github.com'],
logLevel: 'warn',
timeout: 60000,
}
);

// Should show DNAT rules pointing to Squid
expect(result).toSucceed();
expect(result.stdout).toContain('DNAT');
expect(result.stdout).toContain('172.30.0.10:3128');
}, 180000);

test('Container: Images without iptables still work via HTTP_PROXY', async () => {
// Containers without iptables should still be able to access allowed domains via HTTP_PROXY
const result = await runner.runWithSudo(
'docker run --rm curlimages/curl:latest -fsS https://api.github.com/zen',
{
allowDomains: ['api.github.com'],
logLevel: 'warn',
timeout: 30000,
}
);

// Should succeed via HTTP_PROXY (NAT rules won't apply without iptables)
expect(result).toSucceed();
}, 120000);
});
});
Loading