Skip to content

Commit f7c9b9e

Browse files
committed
fix: Relax inbound firewall and add tamper protection
1 parent 8ddfb21 commit f7c9b9e

5 files changed

Lines changed: 46 additions & 8 deletions

File tree

.claude/settings.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,9 @@
3535
"Bash(git clean *)", "Bash(git config *)",
3636
"Bash(*git remote add *)", "Bash(*git remote set-url *)", "Bash(*git remote remove *)",
3737
"Bash(*git remote rename *)", "Bash(*git remote set-head *)",
38-
"Bash(uv self *)"
38+
"Bash(uv self *)",
39+
"Bash(*iptables *)", "Bash(*ip6tables *)", "Bash(*ipset *)",
40+
"Bash(*nft *)", "Bash(*init-firewall*)"
3941
],
4042
"ask": [
4143
"Bash(python *)", "Bash(uv run python *)",

.devcontainer/devcontainer.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,8 @@
4646
],
4747
"containerEnv": {
4848
"CLAUDE_CONFIG_DIR": "/home/vscode/.claude",
49-
"POWERLEVEL9K_DISABLE_GITSTATUS": "true"
49+
"POWERLEVEL9K_DISABLE_GITSTATUS": "true",
50+
"FIREWALL_ALLOW_INBOUND": "${localEnv:FIREWALL_ALLOW_INBOUND:true}"
5051
},
5152
"workspaceMount": "source=${localWorkspaceFolder},target=/workspace,type=bind,consistency=delegated",
5253
"workspaceFolder": "/workspace",

.devcontainer/init-firewall.sh

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,12 @@ IFS=$'\n\t'
66
# Restricts egress to: PyPI, GitHub, Anthropic/Claude, VS Code, uv/Astral,
77
# plus any domains from WebFetch(domain:...) permission patterns.
88
# Uses ipset with aggregated CIDR ranges for reliable filtering.
9+
# FIREWALL_ALLOW_INBOUND (default: true) controls inbound filtering.
10+
# When true, INPUT chain is left permissive (Docker handles inbound isolation).
11+
# When false, strict INPUT DROP policy is applied.
12+
13+
ALLOW_INBOUND="${FIREWALL_ALLOW_INBOUND:-true}"
14+
echo "Inbound firewall mode: $([ "$ALLOW_INBOUND" = "true" ] && echo "permissive (Docker handles isolation)" || echo "strict (INPUT DROP)")"
915

1016
echo "iptables version: $(iptables --version)"
1117
if iptables_path="$(command -v iptables 2>/dev/null)"; then
@@ -49,10 +55,14 @@ fi
4955

5056
# Allow DNS and localhost before any restrictions
5157
iptables -A OUTPUT -p udp --dport 53 -j ACCEPT
52-
iptables -A INPUT -p udp --sport 53 -j ACCEPT
58+
if [ "$ALLOW_INBOUND" != "true" ]; then
59+
iptables -A INPUT -p udp --sport 53 -j ACCEPT
60+
fi
5361
iptables -A OUTPUT -p tcp --dport 53 -j ACCEPT
5462
iptables -A OUTPUT -p tcp --dport 22 -j ACCEPT
55-
iptables -A INPUT -p tcp --sport 22 -m state --state ESTABLISHED -j ACCEPT
63+
if [ "$ALLOW_INBOUND" != "true" ]; then
64+
iptables -A INPUT -p tcp --sport 22 -m state --state ESTABLISHED -j ACCEPT
65+
fi
5666
iptables -A INPUT -i lo -j ACCEPT
5767
iptables -A OUTPUT -o lo -j ACCEPT
5868

@@ -164,7 +174,9 @@ fi
164174
HOST_NETWORK=$(echo "$HOST_IP" | sed "s/\.[0-9]*$/.0\/24/")
165175
echo "Host network detected as: $HOST_NETWORK"
166176

167-
iptables -A INPUT -s "$HOST_NETWORK" -j ACCEPT
177+
if [ "$ALLOW_INBOUND" != "true" ]; then
178+
iptables -A INPUT -s "$HOST_NETWORK" -j ACCEPT
179+
fi
168180
iptables -A OUTPUT -d "$HOST_NETWORK" -j ACCEPT
169181

170182
# Block all IPv6 traffic (firewall is IPv4-only)
@@ -175,7 +187,9 @@ ip6tables -A INPUT -i lo -j ACCEPT 2>/dev/null || true
175187
ip6tables -A OUTPUT -o lo -j ACCEPT 2>/dev/null || true
176188

177189
# Allow established connections
178-
iptables -A INPUT -m state --state ESTABLISHED,RELATED -j ACCEPT
190+
if [ "$ALLOW_INBOUND" != "true" ]; then
191+
iptables -A INPUT -m state --state ESTABLISHED,RELATED -j ACCEPT
192+
fi
179193
iptables -A OUTPUT -m state --state ESTABLISHED,RELATED -j ACCEPT
180194

181195
# Allow traffic to whitelisted domains
@@ -185,11 +199,13 @@ iptables -A OUTPUT -m set --match-set allowed-domains dst -j ACCEPT
185199
iptables -A OUTPUT -j REJECT --reject-with icmp-admin-prohibited
186200

187201
# Set default policies AFTER all ACCEPT rules (prevents lockout on partial failure)
188-
iptables -P INPUT DROP
202+
if [ "$ALLOW_INBOUND" != "true" ]; then
203+
iptables -P INPUT DROP
204+
fi
189205
iptables -P FORWARD DROP
190206
iptables -P OUTPUT DROP
191207

192-
echo "Firewall configuration complete"
208+
echo "Firewall configuration complete (inbound: $([ "$ALLOW_INBOUND" = "true" ] && echo "permissive" || echo "strict"))"
193209

194210
# --- Verification ---
195211
echo "Verifying firewall rules..."

docs/DECISIONS.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,3 +128,12 @@ most hooks, commands, and niche agents. Refocus on exfiltration prevention.
128128
- output-secrets-scanner removed -- conversation leaks to Anthropic are accepted
129129
- Permission tiers removed -- single settings.json baseline for all environments
130130
- unicode-injection-scanner removed -- exotic threat, low practical risk
131+
132+
## 2026-03-15: Devcontainer Firewall Inbound Relaxation
133+
134+
**Request**: Strict inbound filtering blocks legitimate dev server use cases unnecessarily.
135+
136+
**Decisions**:
137+
- Default to permissive inbound (`FIREWALL_ALLOW_INBOUND=true`) -- the primary threat model is egress (data exfiltration), not inbound; Docker's network stack provides inbound isolation depending on port publishing and network mode
138+
- Opt-in strict inbound via `FIREWALL_ALLOW_INBOUND=false` preserves the original INPUT DROP behavior for users who need it
139+
- Firewall deny rules (iptables, ip6tables, ipset, nft, init-firewall) added to settings.json -- prevents Claude from tampering with the firewall, which is the primary security boundary

tests/test_hooks.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,3 +167,13 @@ def test_checks_edit_and_write(self) -> None:
167167
content = (HOOKS_DIR / "auto-format.sh").read_text(encoding="utf-8")
168168
assert '"Edit"' in content, "auto-format should check Edit tool"
169169
assert '"Write"' in content, "auto-format should check Write tool"
170+
171+
172+
class TestFirewallDenyRules:
173+
"""Verify firewall tampering is denied in settings.json."""
174+
175+
def test_settings_denies_firewall_commands(self) -> None:
176+
settings_path = Path(__file__).parent.parent / ".claude" / "settings.json"
177+
content = settings_path.read_text(encoding="utf-8")
178+
for pattern in ["iptables", "ip6tables", "ipset", "nft", "init-firewall"]:
179+
assert pattern in content, f"settings.json missing firewall deny pattern: {pattern}"

0 commit comments

Comments
 (0)