Last active
June 13, 2025 09:59
-
-
Save mmizutani/01d47aa43f0593bfd1c754d0898f7d5c to your computer and use it in GitHub Desktop.
Dev Container post create command script to setup firewall for Claude Code (Antropic's original plus some enhancements)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/bin/bash | |
# | |
# init-firewall.sh - Egress Firewall for Development Container with Claude Code | |
# | |
# This script configures iptables to function as a strict egress firewall. | |
# Its primary purpose is to enhance security by restricting network access from | |
# within a development container to only a predefined list of essential services | |
# required for development, such as package managers (npm, Go), source control | |
# (GitHub), and specific APIs (Anthropic). | |
# Adapted from https://github.com/anthropics/devcontainer-features/blob/e09d1bb8d0f413fc8e6a5936478939cf2463deec/src/claude-code/init-firewall.sh | |
# | |
# Key Features: | |
# - Whitelist-based: All outbound traffic is blocked by default, and only | |
# connections to explicitly allowed domains are permitted. | |
# - Dynamic IP Sets: Uses ipset to manage the list of allowed IP addresses. | |
# It dynamically resolves domain names and fetches IP ranges for services | |
# like GitHub to keep the rules current. | |
# - Atomic Updates: The list of allowed IPs is built in a temporary ipset | |
# and then atomically swapped with the live one. This ensures the firewall | |
# is always using a complete and valid ruleset, preventing race conditions | |
# or incomplete rule application. | |
# - Reentrant & Robust: The script is designed to be safely re-runnable. It | |
# includes error handling and cleanup traps to manage failures gracefully. | |
# | |
# Prerequisites: | |
# - Must be run with root privileges (e.g., 'sudo'). | |
# - The following packages/commands must be installed and in the system's PATH: | |
# - iproute2 (provides the 'ip' command) | |
# - iptables | |
# - ipset | |
# - curl | |
# - jq | |
# - dnsutils (provides the 'dig' command) | |
# - aggregate (a tool for merging CIDR blocks, may require manual installation) | |
# | |
# Usage: | |
# This script is intended to be run at container startup, for example via the | |
# "postCreateCommand" in a devcontainer.json configuration. It must be run as | |
# root because it modifies the system's firewall rules. | |
# | |
# To enable the firewall, COPY this script to `/usr/local/bin/init-firewall.sh` and add | |
# the following to your devcontainer.json: | |
# { | |
# ..., | |
# "runArgs": [ | |
# "--cap-add=NET_ADMIN", | |
# "--cap-add=NET_RAW" | |
# ], | |
# "postCreateCommand": "sudo /usr/local/bin/init-firewall.sh" | |
# } | |
set -eu -o pipefail -o errtrace | |
shopt -s inherit_errexit nullglob | |
# --- Cleanup and Error Handling --- | |
# This function is registered with a 'trap' to be called on any script exit. | |
# Its purpose is to ensure that the temporary ipset used for building the new | |
# ruleset is always removed, even if the script is interrupted or fails. | |
function final_cleanup() { | |
# Attempt to destroy the temporary ipset, ignoring errors if it doesn't exist. | |
ipset destroy allowed-domains-new 2>/dev/null || true | |
} | |
# This function is registered with a 'trap' to be called on any error (non-zero exit code). | |
# It provides a clear message indicating that the firewall setup has failed, which | |
# is critical because it leaves the container in a potentially insecure state | |
# (likely with open firewall policies from the initial setup phase). | |
function error_handler() { | |
local exit_code=$? | |
echo | |
echo "--- ERROR: Firewall script failed with exit code $exit_code ---" | |
echo "--- Firewall may be in an inconsistent (likely open) state ---" | |
# Call final_cleanup to remove any temporary artifacts. | |
final_cleanup | |
} | |
# Register the functions with their respective trap signals. | |
# ERR: Triggered by any command failing. | |
# EXIT: Triggered when the script exits, regardless of success or failure. | |
trap error_handler ERR | |
trap final_cleanup EXIT | |
# --- Initial State Reset --- | |
# Temporarily set default policies to ACCEPT. This is a critical step for | |
# reentrancy. If the script was run before and set the policies to DROP, this | |
# script would fail when it tries to use 'curl' or 'dig' to fetch IP addresses. | |
# We reset to ACCEPT here, and then set them back to a secure DROP at the end. | |
iptables -P INPUT ACCEPT | |
iptables -P FORWARD ACCEPT | |
iptables -P OUTPUT ACCEPT | |
# Flush (F) all existing rules from the filter table and delete (X) all non-default chains. | |
# This ensures we are starting from a clean slate. | |
# We only touch the 'filter' table, which is the default for iptables commands. | |
# We specifically AVOID flushing the 'nat' or 'mangle' tables, as this can | |
# interfere with Docker's internal networking and DNS resolution. | |
iptables -F | |
iptables -X | |
# --- Base Rule Configuration --- | |
# Allow essential network traffic before applying any restrictions. | |
# Allow outbound DNS requests (to resolve domain names). | |
iptables -A OUTPUT -p udp --dport 53 -j ACCEPT | |
# Allow inbound responses to our DNS requests. | |
iptables -A INPUT -p udp --sport 53 -j ACCEPT | |
# Allow outbound SSH connections (e.g., for git over SSH). | |
iptables -A OUTPUT -p tcp --dport 22 -j ACCEPT | |
# Allow inbound responses to our SSH connections. The 'state' module ensures | |
# we only accept packets that are part of an established connection. | |
iptables -A INPUT -p tcp --sport 22 -m state --state ESTABLISHED -j ACCEPT | |
# Allow all traffic on the loopback interface (for services running on localhost). | |
iptables -A INPUT -i lo -j ACCEPT | |
iptables -A OUTPUT -o lo -j ACCEPT | |
# --- Dynamic IP Whitelist Creation --- | |
# Create a new, temporary ipset. We will populate this set with all the | |
# allowed IPs. Using a temporary set is key to the atomic update strategy. | |
TMP_IPSET="allowed-domains-new" | |
ipset create "$TMP_IPSET" hash:net | |
# Fetch GitHub's IP ranges from their metadata API. | |
# The 'jq' command parses the JSON response and extracts the IP blocks | |
# for web traffic, the API, and git operations. | |
# 'aggregate' is a tool that merges overlapping CIDR blocks. | |
echo "Fetching GitHub IP ranges..." | |
gh_ranges=$(curl -s https://api.github.com/meta) | |
if [ -z "$gh_ranges" ]; then | |
echo "ERROR: Failed to fetch GitHub IP ranges" | |
exit 1 | |
fi | |
if ! echo "$gh_ranges" | jq -e '.web and .api and .git' >/dev/null; then | |
echo "ERROR: GitHub API response missing required fields" | |
exit 1 | |
fi | |
echo "Processing GitHub IPs..." | |
while read -r cidr; do | |
if [[ ! "$cidr" =~ ^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}/[0-9]{1,2}$ ]]; then | |
echo "ERROR: Invalid CIDR range from GitHub meta: $cidr" | |
exit 1 | |
fi | |
echo "Adding GitHub range $cidr" | |
ipset add "$TMP_IPSET" "$cidr" | |
done < <(echo "$gh_ranges" | jq -r '(.web + .api + .git)[]' | aggregate -q) | |
# Resolve a list of the required domains for Cloud Code CLI. For each domain, we use 'dig' to | |
# get its A records (IP addresses) and add them to our temporary ipset. | |
# This list should be maintained to include all external services the | |
# development environment needs to contact. | |
for domain in \ | |
"registry.npmjs.org" \ | |
"api.anthropic.com" \ | |
"sentry.io" \ | |
"statsig.anthropic.com" \ | |
"statsig.com" \ | |
"proxy.golang.org" \ | |
"sum.golang.org" \ | |
"index.golang.org" \ | |
; do | |
echo "Resolving $domain..." | |
ips=$(dig +short A "$domain") | |
if [ -z "$ips" ]; then | |
echo "ERROR: Failed to resolve $domain" | |
exit 1 | |
fi | |
while read -r ip; do | |
if [[ ! "$ip" =~ ^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$ ]]; then | |
echo "ERROR: Invalid IP from DNS for $domain: $ip" | |
exit 1 | |
fi | |
echo "Adding $ip for $domain" | |
ipset add "$TMP_IPSET" "$ip" | |
done < <(echo "$ips") | |
done | |
# --- Atomic IP Set Swap --- | |
# This is the core of the atomic update. The 'ipset swap' command is | |
# instantaneous. It replaces the live 'allowed-domains' set with our newly | |
# populated temporary set. This avoids any period of time where the firewall | |
# might have an incomplete or empty list of IPs. | |
echo "Atomically updating the firewall ipset..." | |
# Ensure the target set exists before trying to swap. | |
ipset create allowed-domains hash:net -exist | |
ipset swap "$TMP_IPSET" allowed-domains | |
echo "IP set updated." | |
# --- Final Firewall Policy Application --- | |
# Get the container's host IP address from the default route. This is used | |
# to allow communication with services running on the host machine. | |
HOST_IP=$(ip route | grep default | cut -d" " -f3) | |
if [ -z "$HOST_IP" ]; then | |
echo "ERROR: Failed to detect host IP" | |
exit 1 | |
fi | |
# Derive the host's local network (assuming a /24 subnet) and allow | |
# all traffic to and from that network. | |
HOST_NETWORK=$(echo "$HOST_IP" | sed "s/\.[0-9]*$/.0\/24/") | |
echo "Host network detected as: $HOST_NETWORK" | |
iptables -A INPUT -s "$HOST_NETWORK" -j ACCEPT | |
iptables -A OUTPUT -d "$HOST_NETWORK" -j ACCEPT | |
# Set the default policies for all chains to DROP. This is the "whitelist" | |
# enforcement. Any packet that does not match an explicit ACCEPT rule above | |
# will be dropped. | |
iptables -P INPUT DROP | |
iptables -P FORWARD DROP | |
iptables -P OUTPUT DROP | |
# Allow established and related connections. This is a crucial stateful firewall | |
# rule. It allows return traffic for connections that we have initiated (e.g., | |
# the response from a web server we contacted). Without this, no connections | |
# would work. | |
iptables -A INPUT -m state --state ESTABLISHED,RELATED -j ACCEPT | |
iptables -A OUTPUT -m state --state ESTABLISHED,RELATED -j ACCEPT | |
# This is the main egress rule. It allows any outbound packet (-A OUTPUT) | |
# whose destination IP address (--match-set allowed-domains dst) is in our ipset. | |
iptables -A OUTPUT -m set --match-set allowed-domains dst -j ACCEPT | |
# --- Verification --- | |
echo "Firewall configuration complete" | |
echo "Verifying firewall rules..." | |
# Test 1: Try to reach a domain that should be BLOCKED. | |
# We expect this curl command to fail (timeout). If it succeeds, the firewall | |
# is not working correctly, and we exit with an error. | |
if curl --connect-timeout 5 https://example.com >/dev/null 2>&1; then | |
echo "ERROR: Firewall verification failed - was able to reach https://example.com" | |
exit 1 | |
else | |
echo "Firewall verification passed - unable to reach https://example.com as expected" | |
fi | |
# Test 2: Try to reach a domain that should be ALLOWED. | |
# We expect this to succeed. If it fails, something is wrong with our ruleset | |
# or the setup process. | |
if ! curl --connect-timeout 5 https://api.github.com/zen >/dev/null 2>&1; then | |
echo "ERROR: Firewall verification failed - unable to reach https://api.github.com" | |
exit 1 | |
else | |
echo "Firewall verification passed - able to reach https://api.github.com as expected" | |
fi |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment