Skip to content

Security: Separate privileged iptables setup from unprivileged command execution using Docker layers #375

@Mossaka

Description

@Mossaka

Summary

The current agent container architecture adds and drops CAP_NET_ADMIN capability within the same entrypoint script. While functionally secure, this pattern could be improved by separating privileged setup from unprivileged command execution at the Docker layer level.

Current Architecture

The current flow in containers/agent/entrypoint.sh:

  1. Container starts with NET_ADMIN capability
  2. entrypoint.sh runs as root and sets up iptables rules (line 115)
  3. entrypoint.sh drops CAP_NET_ADMIN using capsh --drop=cap_net_admin (line 144)
  4. gosu awfuser switches to unprivileged user
  5. User command executes
# Current: Single script handles both privileged setup AND privilege drop
exec capsh --drop=cap_net_admin -- -c "exec gosu awfuser $(printf '%q ' "$@")"

Concerns:

  • Same container layer handles both privilege escalation (iptables) and de-escalation (capability drop)
  • Relies on runtime tooling (capsh, gosu) to enforce security rather than Docker's built-in mechanisms
  • A bug or misconfiguration in the entrypoint could potentially skip the privilege drop

Proposed Solution: Init Container Pattern

Separate the privileged iptables setup into a dedicated init container, allowing the agent container to run without ever having NET_ADMIN capability.

Architecture

┌──────────────────────────────────────────────────────────────────────┐
│  awf-net (shared network namespace: 172.30.0.0/24)                  │
│                                                                      │
│  ┌─────────────────────┐      ┌─────────────────────────────────┐   │
│  │ awf-iptables-setup  │      │ awf-agent                       │   │
│  │ (init container)    │      │                                 │   │
│  │                     │      │                                 │   │
│  │ - NET_ADMIN cap     │      │ - NO capabilities               │   │
│  │ - Runs as root      │      │ - USER awfuser (Dockerfile)     │   │
│  │ - Sets up iptables  │      │ - Runs user command directly    │   │
│  │ - Exits on success  │      │                                 │   │
│  └─────────┬───────────┘      └─────────────────────────────────┘   │
│            │                              ↑                          │
│            │ depends_on:                  │                          │
│            │ service_completed_successfully                          │
│            └──────────────────────────────┘                          │
└──────────────────────────────────────────────────────────────────────┘

Docker Compose Changes

services:
  awf-iptables-setup:
    image: ghcr.io/githubnext/gh-aw-firewall/agent-setup:latest
    container_name: awf-iptables-setup
    network_mode: "service:awf-agent"  # Share network namespace
    cap_add:
      - NET_ADMIN
    environment:
      - SQUID_IP=172.30.0.10
      - SQUID_PORT=3128
    command: ["/usr/local/bin/setup-iptables.sh"]
    # Container exits after setup completes

  awf-agent:
    image: ghcr.io/githubnext/gh-aw-firewall/agent:latest
    container_name: awf-agent
    # NO cap_add - container never has NET_ADMIN
    user: "awfuser"  # Or via USER directive in Dockerfile
    depends_on:
      awf-iptables-setup:
        condition: service_completed_successfully
    command: ["user-command-here"]

Dockerfile Changes

# containers/agent/Dockerfile
FROM ubuntu:22.04

# ... existing package installation ...

# Create non-root user
RUN groupadd -g 1000 awfuser && \
    useradd -u 1000 -g 1000 -m -s /bin/bash awfuser

# ... existing setup ...

# Set user at Docker layer - container NEVER runs as root
USER awfuser

# Simple entrypoint - just run the command
ENTRYPOINT ["/bin/bash", "-c"]

Benefits

  1. Defense in depth: Agent container never has NET_ADMIN capability, even at startup
  2. Explicit security boundary: Docker layer (USER awfuser) enforces user, not runtime script
  3. Auditable: Security posture visible in Dockerfile and docker-compose.yml, not hidden in entrypoint logic
  4. Reduced attack surface: No capsh, gosu in the command execution path
  5. Simpler agent container: Entrypoint becomes trivial - just execute the user's command

Challenges to Address

  1. Network namespace sharing: Docker Compose's network_mode: "service:X" requires careful ordering
  2. UID/GID runtime adjustment: Currently handled in entrypoint.sh; may need alternative approach
  3. Two container images: Need to publish and version both agent-setup and agent images
  4. Backward compatibility: Existing users may have scripts expecting single-container behavior

Alternative: Simpler Two-Stage Entrypoint

If the init container pattern is too complex, a simpler improvement would be:

# Dockerfile
USER awfuser

# Entrypoint wrapper that:
# 1. Uses sudo/setpriv for iptables (not shell script dropping caps)
# 2. Then exec's to user command
ENTRYPOINT ["/usr/local/bin/entrypoint-wrapper"]

This keeps single-container simplicity while making the USER directive explicit.

Acceptance Criteria

  • Agent container does not have NET_ADMIN capability during user command execution
  • Security posture is visible at Docker layer (USER directive or no cap_add)
  • iptables rules are still correctly applied before user command runs
  • UID/GID matching with host user still works
  • All existing tests pass
  • Documentation updated

References

  • Security review finding: "It would be better to switch users using a Docker layer instead of relying entirely on entrypoint.sh"
  • Current entrypoint.sh: containers/agent/entrypoint.sh
  • Current Dockerfile: containers/agent/Dockerfile

Metadata

Metadata

Labels

No labels
No labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions