Building ScamShield AI: Week 2 — Docker Security, Firewall Bypasses, and Fail-Closed Architecture A developer building ScamShield AI, an evidence-grounded threat intelligence platform for analyzing Indian scam SMS and WhatsApp messages, hardened the platform's Mitmproxy-based proxy container by reordering iptables rules so private-range and cloud-metadata drops are enforced before stateful connection acceptance, closing an SSRF bypass against endpoints like 169.254.169.254. The developer also rewrote the container entrypoint to fail closed, aborting startup with exit code 1 if iptables or NET_ADMIN is unavailable or any rule fails verification, and fixed a boot crash caused by mitmproxy 10.4.2 deprecating the --termlog-verbosity flag in favor of --set termlog_verbosity=info. If you caught Week 1, you know I'm building ScamShield AI — an evidence-grounded threat intelligence platform designed to safely analyze Indian scam SMS messages and WhatsApp forwards fake KYC alerts, electricity cut-off threats, UPI traps . To safely "click" a scammer's link without letting them compromise our backend infrastructure, we are building an isolated Docker sandbox using Playwright Headless Chromium and Mitmproxy The Network Guard . This week was entirely about hardening Stage 1: The Proxy Container . I learned the hard way that building a Docker container is trivial, but securing it against a determined attacker probing from the inside is a completely different engineering challenge. Here are my biggest technical takeaways, bugs, and architectural wins from Week 2. iptables Rule Ordering is a Strict Security Boundary In iptables the Linux kernel packet filter , rules are evaluated sequentially from top to bottom. The first match wins. I originally wrote my firewall rules in this intuitive order: 1. Allow loopback lo 2. Allow ESTABLISHED, RELATED traffic 3. DROP private IPs 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 169.254.169.254 An attacker exploiting a network connection could potentially match an existing state or craft packets that trigger state-tracking rules prematurely. Because the ESTABLISHED, RELATED rule was placed at 2 , any connection matching that condition is accepted immediately. It never reaches the DROP rules sitting at 3 . On a threat-intelligence scanner inspecting hostile URLs, this is a dangerous bypass that risks Server-Side Request Forgery SSRF against cloud metadata endpoints 169.254.169.254 . We refactored the firewall initialization into 6 strict phases . All prohibited destination drops are enforced in Phase 1 , before stateful connection acceptance is even evaluated. Phase 1: DROP 169.254.169.254/32 AWS/Cloud Metadata DROP 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 RFC1918 Private Ranges DROP 100.64.0.0/10 CGNAT ... Phase 4: ACCEPT ESTABLISHED, RELATED Phase 5: DROP ALL Default Policy Fallback If a packet targets an internal address, the kernel drops it immediately on line 1. No connection tracking rule can resurrect it. My original container entrypoint.sh had logic that looked roughly like this: if command -v iptables /dev/null 2 &1; then apply firewall rules else echo "Warning: iptables not found, continuing anyway..." fi exec run proxy "$@" If the container was accidentally started without the NET ADMIN Linux capability, or if a base image update broke iptables , the script simply printed a warning to stdout and happily booted the proxy without a firewall. This is classic "Fail-Open" design, and in security software, it is fatal. A security boundary that silently degrades to unprotected execution is worse than no boundary at all, because it gives you false confidence. We rewrote the entire initialization script to be strictly Fail-Closed : set -euo pipefail Fail immediately if NET ADMIN is missing if iptables -L -n /dev/null 2 &1; then echo " FATAL iptables unavailable or insufficient permissions missing NET ADMIN? . Aborting." &2 exit 1 fi Apply rules and verify every single one with iptables -C apply firewall rules || { echo " FATAL Failed to enforce firewall policy. Aborting startup." &2 exit 1 } Now, if a single firewall rule fails to apply, the container terminates immediately with exit code 1 . The sandbox refuses to run without its armor. During a full Docker rebuild, our proxy container suddenly crashed on boot: mitmdump: error: unrecognized arguments: --termlog-verbosity We had updated to mitmproxy 10.4.2 , which completely deprecated the --termlog-verbosity CLI flag in favor of options syntax: --set termlog verbosity=info . Because our test suite was testing startup shell logic using mocked binaries . Our test mock for mitmdump was a dummy script that recorded incoming flags and exited 0 without validating whether those flags were actually accepted by the real mitmproxy CLI Our tests were falsely green . We updated the configuration to the modern --set syntax, added negative assertions to the mocks to disallow deprecated arguments, and verified startup against the real binary. To adhere to the principle of least privilege, our proxy container must drop root privileges after configuring iptables and run the worker process as an unprivileged user named proxy with explicit UID 1002 . In our Dockerfile, we had: RUN groupadd -g 1002 proxy || true && \ useradd -u 1002 -g proxy proxy || true When checking running processes inside the container with docker top , something looked wrong: bash $ docker top scamshield-proxy UID PID PPID CMD 13 14820 14798 mitmdump -s /app/addon.py ... The process wasn't running as UID 1002 . It was running as UID 13 The official python:3.11-slim image is built on Debian. Debian has shipped with a built-in system user named proxy UID 13 since the 1990s originally for Squid/inn . When our Dockerfile executed groupadd ... || true , it failed silently because group proxy already existed, and useradd skipped creation. The container fell back to the system user UID 13 . Never rely on implicit user creation in base images. We explicitly modify existing system users or pick a collision-free name: RUN if id "proxy" /dev/null 2 &1; then \ userdel -f proxy && groupdel proxy || true; \ fi && \ groupadd -g 1002 proxy && \ useradd -u 1002 -g proxy -s /usr/sbin/nologin -d /nonexistent proxy Always inspect docker top and /proc/