Skip to content

Commit 04f242b

Browse files
CopilotMossaka
andcommitted
feat: inject NAT rules into child containers to prevent proxy bypass
Co-authored-by: Mossaka <5447827+Mossaka@users.noreply.github.com>
1 parent 95a8837 commit 04f242b

2 files changed

Lines changed: 326 additions & 6 deletions

File tree

containers/agent/docker-wrapper.sh

Lines changed: 261 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,39 @@
11
#!/bin/bash
22
# Docker wrapper that injects --network awf-net and proxy env vars to all docker run commands
33
# This ensures spawned containers are subject to the same firewall rules
4+
#
5+
# Security: This wrapper also injects NAT rules into child containers to prevent proxy bypass.
6+
# Applications that ignore HTTP_PROXY environment variables will still have their traffic
7+
# redirected to Squid via iptables NAT rules.
48

59
NETWORK_NAME="awf-net"
610
SQUID_PROXY="http://172.30.0.10:3128"
11+
SQUID_IP="172.30.0.10"
12+
SQUID_PORT="3128"
713
LOG_FILE="/tmp/docker-wrapper.log"
814

15+
# NAT setup script that will be injected into child containers
16+
# This script redirects HTTP/HTTPS traffic to Squid proxy using iptables
17+
# It's designed to be minimal and work with busybox/alpine shells
18+
NAT_SETUP_SCRIPT='
19+
if command -v iptables >/dev/null 2>&1; then
20+
iptables -t nat -F OUTPUT 2>/dev/null || true
21+
iptables -t nat -A OUTPUT -o lo -j RETURN
22+
iptables -t nat -A OUTPUT -d 127.0.0.0/8 -j RETURN
23+
iptables -t nat -A OUTPUT -p udp -d 127.0.0.11 --dport 53 -j RETURN
24+
iptables -t nat -A OUTPUT -p tcp -d 127.0.0.11 --dport 53 -j RETURN
25+
iptables -t nat -A OUTPUT -p udp -d 8.8.8.8 --dport 53 -j RETURN
26+
iptables -t nat -A OUTPUT -p tcp -d 8.8.8.8 --dport 53 -j RETURN
27+
iptables -t nat -A OUTPUT -p udp -d 8.8.4.4 --dport 53 -j RETURN
28+
iptables -t nat -A OUTPUT -p tcp -d 8.8.4.4 --dport 53 -j RETURN
29+
iptables -t nat -A OUTPUT -d 8.8.8.8 -j RETURN
30+
iptables -t nat -A OUTPUT -d 8.8.4.4 -j RETURN
31+
iptables -t nat -A OUTPUT -d '"$SQUID_IP"' -j RETURN
32+
iptables -t nat -A OUTPUT -p tcp --dport 80 -j DNAT --to-destination '"$SQUID_IP:$SQUID_PORT"'
33+
iptables -t nat -A OUTPUT -p tcp --dport 443 -j DNAT --to-destination '"$SQUID_IP:$SQUID_PORT"'
34+
fi
35+
'
36+
937
# Log all docker commands
1038
echo "[$(date -Iseconds)] WRAPPER CALLED: docker $@" >> "$LOG_FILE"
1139

@@ -78,23 +106,250 @@ if [ "$1" = "run" ]; then
78106
exit 1
79107
fi
80108

81-
# If --network not specified, inject it along with proxy environment variables
109+
# If --network not specified, inject it along with proxy environment variables and NAT rules
82110
if [ "$has_network" = false ]; then
83-
# Build new args: docker run --network awf-net -e HTTP_PROXY -e HTTPS_PROXY <rest of args>
84111
shift # remove 'run'
85-
echo "[$(date -Iseconds)] INJECTING --network $NETWORK_NAME and proxy env vars" >> "$LOG_FILE"
112+
echo "[$(date -Iseconds)] INJECTING --network $NETWORK_NAME, proxy env vars, and NAT rules" >> "$LOG_FILE"
113+
114+
# We need to parse the remaining arguments to find where the image and command are
115+
# Docker run format: docker run [OPTIONS] IMAGE [COMMAND] [ARG...]
116+
# We'll inject our security flags and then wrap the command with NAT setup
117+
118+
# Parse remaining arguments to extract:
119+
# 1. Docker options (flags that start with - or their values)
120+
# 2. Image name
121+
# 3. Command and its arguments
122+
declare -a docker_opts=()
123+
declare -a user_cmd=()
124+
image_name=""
125+
parsing_opts=true
126+
skip_next=false
127+
has_rm=false
128+
has_entrypoint=false
129+
130+
for arg in "$@"; do
131+
if [ "$skip_next" = true ]; then
132+
docker_opts+=("$arg")
133+
skip_next=false
134+
continue
135+
fi
136+
137+
if [ "$parsing_opts" = true ]; then
138+
# Check for --rm flag
139+
if [ "$arg" = "--rm" ]; then
140+
has_rm=true
141+
docker_opts+=("$arg")
142+
continue
143+
fi
144+
145+
# Check for --entrypoint flag (we need to handle this specially)
146+
if [ "$arg" = "--entrypoint" ]; then
147+
has_entrypoint=true
148+
docker_opts+=("$arg")
149+
skip_next=true
150+
continue
151+
fi
152+
if [[ "$arg" == "--entrypoint="* ]]; then
153+
has_entrypoint=true
154+
docker_opts+=("$arg")
155+
continue
156+
fi
157+
158+
# Docker options that take a value on the next argument
159+
case "$arg" in
160+
-e|--env|-l|--label|-v|--volume|-p|--publish|--name|--hostname|\
161+
--user|-u|--workdir|-w|--mount|--network-alias|--dns|--dns-search|\
162+
--dns-option|--cpus|--memory|-m|--memory-swap|--memory-reservation|\
163+
--kernel-memory|--cpu-shares|--cpu-period|--cpu-quota|--cpuset-cpus|\
164+
--cpuset-mems|--blkio-weight|--device|--device-cgroup-rule|\
165+
--device-read-bps|--device-read-iops|--device-write-bps|\
166+
--device-write-iops|--cap-add|--cap-drop|--security-opt|--ulimit|\
167+
--sysctl|--restart|--stop-signal|--stop-timeout|--health-cmd|\
168+
--health-interval|--health-retries|--health-start-period|\
169+
--health-timeout|--log-driver|--log-opt|--storage-opt|\
170+
--tmpfs|--ipc|--pid|--userns|--uts|--runtime|--isolation|\
171+
--platform|--pull|--shm-size|--group-add|--cidfile|--cgroup-parent|\
172+
--init|--read-only|--gpus|--link|--link-local-ip|--ip|--ip6|\
173+
--mac-address|--expose|--domainname|--add-host|--annotation)
174+
docker_opts+=("$arg")
175+
skip_next=true
176+
continue
177+
;;
178+
esac
179+
180+
# Docker options that are flags (no value)
181+
if [[ "$arg" == -* ]]; then
182+
docker_opts+=("$arg")
183+
continue
184+
fi
185+
186+
# First non-option argument is the image name
187+
image_name="$arg"
188+
parsing_opts=false
189+
else
190+
# Everything after the image name is the command
191+
user_cmd+=("$arg")
192+
fi
193+
done
194+
195+
# If no image was found, just pass through
196+
if [ -z "$image_name" ]; then
197+
echo "[$(date -Iseconds)] ERROR: Could not find image name, passing through" >> "$LOG_FILE"
198+
exec /usr/bin/docker-real run "$@"
199+
fi
200+
201+
echo "[$(date -Iseconds)] Image: $image_name, Command: ${user_cmd[*]:-<default>}, HasEntrypoint: $has_entrypoint" >> "$LOG_FILE"
202+
203+
# Build the wrapped command that sets up NAT rules then runs the user's command
204+
# If user specified a command, wrap it; otherwise let the image's default run
205+
if [ ${#user_cmd[@]} -gt 0 ]; then
206+
# User specified a command - wrap it with NAT setup
207+
# Escape the user command for embedding in sh -c
208+
escaped_cmd=""
209+
for cmd_part in "${user_cmd[@]}"; do
210+
# Escape single quotes in the command
211+
escaped_part="${cmd_part//\'/\'\\\'\'}"
212+
escaped_cmd="$escaped_cmd '$escaped_part'"
213+
done
214+
215+
wrapped_cmd="$NAT_SETUP_SCRIPT exec $escaped_cmd"
216+
else
217+
# No user command - NAT setup only, then exit (image entrypoint will not run with our wrapper)
218+
# For this case, we can't easily wrap the default entrypoint, so just set up NAT and warn
219+
echo "[$(date -Iseconds)] WARNING: No command specified, NAT rules may not apply to default entrypoint" >> "$LOG_FILE"
220+
# Pass through without wrapping - NAT won't apply but proxy env vars will
221+
exec /usr/bin/docker-real run \
222+
--network "$NETWORK_NAME" \
223+
--cap-add NET_ADMIN \
224+
-e HTTP_PROXY="$SQUID_PROXY" \
225+
-e HTTPS_PROXY="$SQUID_PROXY" \
226+
-e http_proxy="$SQUID_PROXY" \
227+
-e https_proxy="$SQUID_PROXY" \
228+
-e SQUID_PROXY_IP="$SQUID_IP" \
229+
-e SQUID_PROXY_PORT="$SQUID_PORT" \
230+
"${docker_opts[@]}" \
231+
"$image_name"
232+
fi
233+
234+
# Execute with NAT wrapper
86235
exec /usr/bin/docker-real run \
87236
--network "$NETWORK_NAME" \
237+
--cap-add NET_ADMIN \
88238
-e HTTP_PROXY="$SQUID_PROXY" \
89239
-e HTTPS_PROXY="$SQUID_PROXY" \
90240
-e http_proxy="$SQUID_PROXY" \
91241
-e https_proxy="$SQUID_PROXY" \
92-
"$@"
242+
-e SQUID_PROXY_IP="$SQUID_IP" \
243+
-e SQUID_PROXY_PORT="$SQUID_PORT" \
244+
"${docker_opts[@]}" \
245+
--entrypoint sh \
246+
"$image_name" \
247+
-c "$wrapped_cmd"
93248
else
94-
echo "[$(date -Iseconds)] --network $network_value already specified, passing through" >> "$LOG_FILE"
249+
# Network is already specified (and not host) - still inject NAT rules and proxy env vars
250+
echo "[$(date -Iseconds)] --network $network_value already specified, injecting NAT rules and proxy env vars" >> "$LOG_FILE"
251+
252+
shift # remove 'run'
253+
254+
# Parse remaining arguments similar to above
255+
declare -a docker_opts2=()
256+
declare -a user_cmd2=()
257+
image_name2=""
258+
parsing_opts2=true
259+
skip_next2=false
260+
261+
for arg in "$@"; do
262+
if [ "$skip_next2" = true ]; then
263+
docker_opts2+=("$arg")
264+
skip_next2=false
265+
continue
266+
fi
267+
268+
if [ "$parsing_opts2" = true ]; then
269+
# Docker options that take a value on the next argument
270+
case "$arg" in
271+
-e|--env|-l|--label|-v|--volume|-p|--publish|--name|--hostname|\
272+
--user|-u|--workdir|-w|--mount|--network-alias|--dns|--dns-search|\
273+
--dns-option|--cpus|--memory|-m|--memory-swap|--memory-reservation|\
274+
--kernel-memory|--cpu-shares|--cpu-period|--cpu-quota|--cpuset-cpus|\
275+
--cpuset-mems|--blkio-weight|--device|--device-cgroup-rule|\
276+
--device-read-bps|--device-read-iops|--device-write-bps|\
277+
--device-write-iops|--cap-add|--cap-drop|--security-opt|--ulimit|\
278+
--sysctl|--restart|--stop-signal|--stop-timeout|--health-cmd|\
279+
--health-interval|--health-retries|--health-start-period|\
280+
--health-timeout|--log-driver|--log-opt|--storage-opt|\
281+
--tmpfs|--ipc|--pid|--userns|--uts|--runtime|--isolation|\
282+
--platform|--pull|--shm-size|--group-add|--cidfile|--cgroup-parent|\
283+
--init|--read-only|--gpus|--link|--link-local-ip|--ip|--ip6|\
284+
--mac-address|--expose|--domainname|--add-host|--annotation|\
285+
--network|--net)
286+
docker_opts2+=("$arg")
287+
skip_next2=true
288+
continue
289+
;;
290+
esac
291+
292+
# Docker options that are flags (no value) or have = format
293+
if [[ "$arg" == -* ]]; then
294+
docker_opts2+=("$arg")
295+
continue
296+
fi
297+
298+
# First non-option argument is the image name
299+
image_name2="$arg"
300+
parsing_opts2=false
301+
else
302+
# Everything after the image name is the command
303+
user_cmd2+=("$arg")
304+
fi
305+
done
306+
307+
# If no image was found, just pass through
308+
if [ -z "$image_name2" ]; then
309+
echo "[$(date -Iseconds)] ERROR: Could not find image name, passing through" >> "$LOG_FILE"
310+
exec /usr/bin/docker-real run "$@"
311+
fi
312+
313+
echo "[$(date -Iseconds)] Image: $image_name2, Command: ${user_cmd2[*]:-<default>}" >> "$LOG_FILE"
314+
315+
# Build the wrapped command
316+
if [ ${#user_cmd2[@]} -gt 0 ]; then
317+
escaped_cmd2=""
318+
for cmd_part in "${user_cmd2[@]}"; do
319+
escaped_part2="${cmd_part//\'/\'\\\'\'}"
320+
escaped_cmd2="$escaped_cmd2 '$escaped_part2'"
321+
done
322+
323+
wrapped_cmd2="$NAT_SETUP_SCRIPT exec $escaped_cmd2"
324+
325+
exec /usr/bin/docker-real run \
326+
--cap-add NET_ADMIN \
327+
-e HTTP_PROXY="$SQUID_PROXY" \
328+
-e HTTPS_PROXY="$SQUID_PROXY" \
329+
-e http_proxy="$SQUID_PROXY" \
330+
-e https_proxy="$SQUID_PROXY" \
331+
-e SQUID_PROXY_IP="$SQUID_IP" \
332+
-e SQUID_PROXY_PORT="$SQUID_PORT" \
333+
"${docker_opts2[@]}" \
334+
--entrypoint sh \
335+
"$image_name2" \
336+
-c "$wrapped_cmd2"
337+
else
338+
# No command specified - pass through with proxy env vars
339+
exec /usr/bin/docker-real run \
340+
--cap-add NET_ADMIN \
341+
-e HTTP_PROXY="$SQUID_PROXY" \
342+
-e HTTPS_PROXY="$SQUID_PROXY" \
343+
-e http_proxy="$SQUID_PROXY" \
344+
-e https_proxy="$SQUID_PROXY" \
345+
-e SQUID_PROXY_IP="$SQUID_IP" \
346+
-e SQUID_PROXY_PORT="$SQUID_PORT" \
347+
"${docker_opts2[@]}" \
348+
"$image_name2"
349+
fi
95350
fi
96351
fi
97352

98-
# For all other commands or if --network already specified, pass through
353+
# For all other commands (not docker run), pass through
99354
echo "[$(date -Iseconds)] PASSING THROUGH: /usr/bin/docker-real $@" >> "$LOG_FILE"
100355
exec /usr/bin/docker-real "$@"

tests/integration/docker-egress.test.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -346,4 +346,69 @@ describe('Docker Container Egress Tests', () => {
346346
expect(result).toFail();
347347
}, 120000);
348348
});
349+
350+
describe('8M. NAT rules in child containers (proxy bypass prevention)', () => {
351+
test('Container: NAT rules applied - blocks traffic even when proxy env vars are ignored', async () => {
352+
// This test verifies that child containers have NAT rules applied via docker-wrapper.sh
353+
// Even if an application ignores HTTP_PROXY env vars, traffic is still redirected to Squid
354+
const result = await runner.runWithSudo(
355+
`docker run --rm alpine:latest sh -c 'apk add --no-cache curl >/dev/null 2>&1 && unset HTTP_PROXY HTTPS_PROXY http_proxy https_proxy && curl -f https://example.com --max-time 10'`,
356+
{
357+
allowDomains: ['github.com'],
358+
logLevel: 'warn',
359+
timeout: 60000,
360+
}
361+
);
362+
363+
// Should fail because NAT rules redirect traffic to Squid which blocks example.com
364+
expect(result).toFail();
365+
}, 180000);
366+
367+
test('Container: NAT rules applied - allows whitelisted domains even when proxy env vars are ignored', async () => {
368+
// Verify that allowed domains still work even when proxy env vars are unset
369+
const result = await runner.runWithSudo(
370+
`docker run --rm alpine:latest sh -c 'apk add --no-cache curl >/dev/null 2>&1 && unset HTTP_PROXY HTTPS_PROXY http_proxy https_proxy && curl -fsS https://api.github.com/zen --max-time 30'`,
371+
{
372+
allowDomains: ['api.github.com'],
373+
logLevel: 'warn',
374+
timeout: 60000,
375+
}
376+
);
377+
378+
// Should succeed because NAT rules redirect to Squid which allows api.github.com
379+
expect(result).toSucceed();
380+
}, 180000);
381+
382+
test('Container: wget --no-proxy blocked by NAT rules', async () => {
383+
// wget --no-proxy explicitly ignores proxy settings, but NAT rules should still work
384+
const result = await runner.runWithSudo(
385+
`docker run --rm alpine:latest sh -c 'apk add --no-cache wget >/dev/null 2>&1 && wget --no-proxy -q -O- https://example.com --timeout=10'`,
386+
{
387+
allowDomains: ['github.com'],
388+
logLevel: 'warn',
389+
timeout: 60000,
390+
}
391+
);
392+
393+
// Should fail because NAT rules redirect traffic to Squid regardless of --no-proxy
394+
expect(result).toFail();
395+
}, 180000);
396+
397+
test('Container: Verify NAT rules are present in child container', async () => {
398+
// Directly check if NAT rules are applied in child container
399+
const result = await runner.runWithSudo(
400+
`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"'`,
401+
{
402+
allowDomains: ['github.com'],
403+
logLevel: 'warn',
404+
timeout: 60000,
405+
}
406+
);
407+
408+
// Should show DNAT rules pointing to Squid
409+
expect(result).toSucceed();
410+
expect(result.stdout).toContain('DNAT');
411+
expect(result.stdout).toContain('172.30.0.10:3128');
412+
}, 180000);
413+
});
349414
});

0 commit comments

Comments
 (0)