Skip to content

Commit 5ef9084

Browse files
committed
fix: Relax inbound firewall and add tamper protection
1 parent 04b6fee commit 5ef9084

12 files changed

Lines changed: 180 additions & 18 deletions

.claude/hooks/devcontainer-policy-blocker.sh

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,21 @@ for pattern in "${BLOCKED_DOCKER_ESCAPE[@]}"; do
102102
fi
103103
done
104104

105+
# --- Firewall tampering (all tiers) ---
106+
BLOCKED_FIREWALL=(
107+
'iptables '
108+
'ip6tables '
109+
'ipset '
110+
'nft '
111+
'init-firewall'
112+
)
113+
114+
for pattern in "${BLOCKED_FIREWALL[@]}"; do
115+
if echo "$COMMAND" | grep -qiF "$pattern"; then
116+
block "Blocked by devcontainer-policy-blocker: firewall commands are not allowed. The network firewall is a security boundary."
117+
fi
118+
done
119+
105120
# --- GitHub shared state mutations (tiers 0/1/2 only, allowed in tier 3) ---
106121
if [ "$TIER" != "3" ]; then
107122
BLOCKED_GH_MUTATIONS=(
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
#!/bin/bash
2+
# PreToolUse hook: Blocks edits to firewall and sudoers files inside devcontainers.
3+
# The network firewall is a security boundary -- Claude must not weaken it by
4+
# modifying init-firewall.sh or sudoers configuration.
5+
# Only active when DEVCONTAINER=true (no-op on bare metal).
6+
# Exit 2 = block the action, Exit 0 = allow.
7+
# Requires jq for JSON parsing; degrades gracefully if missing.
8+
9+
if ! command -v jq &>/dev/null; then
10+
echo "WARNING: jq not found, firewall-edit-blocker hook disabled" >&2
11+
exit 0
12+
fi
13+
14+
# Only enforce inside devcontainers
15+
if [ "$DEVCONTAINER" != "true" ]; then
16+
exit 0
17+
fi
18+
19+
INPUT=$(cat)
20+
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // empty')
21+
22+
if [ "$TOOL_NAME" != "Edit" ] && [ "$TOOL_NAME" != "Write" ]; then
23+
exit 0
24+
fi
25+
26+
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
27+
28+
if [ -z "$FILE_PATH" ]; then
29+
exit 0
30+
fi
31+
32+
# Block edits to firewall script
33+
if echo "$FILE_PATH" | grep -qF "init-firewall.sh"; then
34+
jq -n '{"decision":"block","reason":"Blocked by firewall-edit-blocker: editing init-firewall.sh is not allowed. The network firewall is a security boundary."}'
35+
exit 2
36+
fi
37+
38+
# Block edits to sudoers files
39+
if echo "$FILE_PATH" | grep -qF "sudoers"; then
40+
jq -n '{"decision":"block","reason":"Blocked by firewall-edit-blocker: editing sudoers files is not allowed. Sudo permissions are a security boundary."}'
41+
exit 2
42+
fi
43+
44+
exit 0

.claude/settings.json

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,9 @@
3131
"deny": [
3232
"Bash(gh secret *)", "Bash(gh auth *)", "Bash(gh ssh-key *)", "Bash(gh gpg-key *)",
3333
"Bash(git clean *)", "Bash(git config *)",
34-
"Bash(uv self *)"
34+
"Bash(uv self *)",
35+
"Bash(*iptables *)", "Bash(*ip6tables *)", "Bash(*ipset *)",
36+
"Bash(*nft *)", "Bash(*init-firewall*)"
3537
],
3638
"ask": [
3739
"Bash(python *)", "Bash(uv run python *)",
@@ -55,7 +57,10 @@
5557
},
5658
{
5759
"matcher": "Edit|Write",
58-
"hooks": [{"type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/unicode-injection-scanner.sh"}]
60+
"hooks": [
61+
{"type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/unicode-injection-scanner.sh"},
62+
{"type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/firewall-edit-blocker.sh"}
63+
]
5964
}
6065
],
6166
"PostToolUse": [

.devcontainer/devcontainer.json

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

.devcontainer/init-firewall.sh

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

915
echo "iptables version: $(iptables --version)"
1016
if iptables_path="$(command -v iptables 2>/dev/null)"; then
@@ -48,10 +54,14 @@ fi
4854

4955
# Allow DNS and localhost before any restrictions
5056
iptables -A OUTPUT -p udp --dport 53 -j ACCEPT
51-
iptables -A INPUT -p udp --sport 53 -j ACCEPT
57+
if [ "$ALLOW_INBOUND" != "true" ]; then
58+
iptables -A INPUT -p udp --sport 53 -j ACCEPT
59+
fi
5260
iptables -A OUTPUT -p tcp --dport 53 -j ACCEPT
5361
iptables -A OUTPUT -p tcp --dport 22 -j ACCEPT
54-
iptables -A INPUT -p tcp --sport 22 -m state --state ESTABLISHED -j ACCEPT
62+
if [ "$ALLOW_INBOUND" != "true" ]; then
63+
iptables -A INPUT -p tcp --sport 22 -m state --state ESTABLISHED -j ACCEPT
64+
fi
5565
iptables -A INPUT -i lo -j ACCEPT
5666
iptables -A OUTPUT -o lo -j ACCEPT
5767

@@ -119,7 +129,9 @@ fi
119129
HOST_NETWORK=$(echo "$HOST_IP" | sed "s/\.[0-9]*$/.0\/24/")
120130
echo "Host network detected as: $HOST_NETWORK"
121131

122-
iptables -A INPUT -s "$HOST_NETWORK" -j ACCEPT
132+
if [ "$ALLOW_INBOUND" != "true" ]; then
133+
iptables -A INPUT -s "$HOST_NETWORK" -j ACCEPT
134+
fi
123135
iptables -A OUTPUT -d "$HOST_NETWORK" -j ACCEPT
124136

125137
# Block all IPv6 traffic (firewall is IPv4-only)
@@ -130,7 +142,9 @@ ip6tables -A INPUT -i lo -j ACCEPT 2>/dev/null || true
130142
ip6tables -A OUTPUT -o lo -j ACCEPT 2>/dev/null || true
131143

132144
# Allow established connections
133-
iptables -A INPUT -m state --state ESTABLISHED,RELATED -j ACCEPT
145+
if [ "$ALLOW_INBOUND" != "true" ]; then
146+
iptables -A INPUT -m state --state ESTABLISHED,RELATED -j ACCEPT
147+
fi
134148
iptables -A OUTPUT -m state --state ESTABLISHED,RELATED -j ACCEPT
135149

136150
# Allow traffic to whitelisted domains
@@ -140,11 +154,13 @@ iptables -A OUTPUT -m set --match-set allowed-domains dst -j ACCEPT
140154
iptables -A OUTPUT -j REJECT --reject-with icmp-admin-prohibited
141155

142156
# Set default policies AFTER all ACCEPT rules (prevents lockout on partial failure)
143-
iptables -P INPUT DROP
157+
if [ "$ALLOW_INBOUND" != "true" ]; then
158+
iptables -P INPUT DROP
159+
fi
144160
iptables -P FORWARD DROP
145161
iptables -P OUTPUT DROP
146162

147-
echo "Firewall configuration complete"
163+
echo "Firewall configuration complete (inbound: $([ "$ALLOW_INBOUND" = "true" ] && echo "permissive" || echo "strict"))"
148164

149165
# --- Verification ---
150166
echo "Verifying firewall rules..."

.devcontainer/permissions/tier1-assisted.json

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,9 @@
2424
"Bash(*gh pr merge *)",
2525
"Bash(*gh workflow run *)", "Bash(*gh workflow enable *)", "Bash(*gh workflow disable *)",
2626
"Bash(*gh issue create *)", "Bash(*gh issue close *)", "Bash(*gh issue edit *)",
27-
"Bash(*terraform *)"
27+
"Bash(*terraform *)",
28+
"Bash(*iptables *)", "Bash(*ip6tables *)", "Bash(*ipset *)",
29+
"Bash(*nft *)", "Bash(*init-firewall*)", "Bash(*sudo *)"
2830
]
2931
},
3032
"enabledPlugins": {
@@ -41,7 +43,10 @@
4143
},
4244
{
4345
"matcher": "Edit|Write",
44-
"hooks": [{"type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/unicode-injection-scanner.sh"}]
46+
"hooks": [
47+
{"type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/unicode-injection-scanner.sh"},
48+
{"type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/firewall-edit-blocker.sh"}
49+
]
4550
}
4651
],
4752
"PostToolUse": [

.devcontainer/permissions/tier2-autonomous.json

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,9 @@
2323
"Bash(*cargo install *)", "Bash(*go install *)", "Bash(*gem install *)",
2424
"Bash(*uv tool install *)", "Bash(*uv tool *)",
2525
"Bash(*apt install *)", "Bash(*apt-get install *)", "Bash(*dpkg -i *)",
26-
"Bash(*snap install *)", "Bash(*brew install *)"
26+
"Bash(*snap install *)", "Bash(*brew install *)",
27+
"Bash(*iptables *)", "Bash(*ip6tables *)", "Bash(*ipset *)",
28+
"Bash(*nft *)", "Bash(*init-firewall*)", "Bash(*sudo *)"
2729
]
2830
},
2931
"enabledPlugins": {
@@ -40,7 +42,10 @@
4042
},
4143
{
4244
"matcher": "Edit|Write",
43-
"hooks": [{"type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/unicode-injection-scanner.sh"}]
45+
"hooks": [
46+
{"type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/unicode-injection-scanner.sh"},
47+
{"type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/firewall-edit-blocker.sh"}
48+
]
4449
}
4550
],
4651
"PostToolUse": [

.devcontainer/permissions/tier3-full-trust.json

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,9 @@
1212
"Bash(*docker run --privileged *)",
1313
"Bash(*docker run --cap-add=ALL *)",
1414
"Bash(*docker run --pid=host *)",
15-
"Bash(*docker run --network=host *)"
15+
"Bash(*docker run --network=host *)",
16+
"Bash(*iptables *)", "Bash(*ip6tables *)", "Bash(*ipset *)",
17+
"Bash(*nft *)", "Bash(*init-firewall*)", "Bash(*sudo *)"
1618
]
1719
},
1820
"enabledPlugins": {
@@ -29,7 +31,10 @@
2931
},
3032
{
3133
"matcher": "Edit|Write",
32-
"hooks": [{"type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/unicode-injection-scanner.sh"}]
34+
"hooks": [
35+
{"type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/unicode-injection-scanner.sh"},
36+
{"type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/firewall-edit-blocker.sh"}
37+
]
3338
}
3439
],
3540
"PostToolUse": [

CLAUDE.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,12 @@ Use `/sync` before starting work, `/design` to formalize a plan, `/done` when fi
77
## Security
88

99
- **Real-time scanning**: The `security-guidance` plugin runs automatically during code editing, warning about command injection, eval/exec, pickle deserialization, XSS, and os.system() usage
10-
- **Runtime hooks**: 3 base security hooks run automatically via `.claude/hooks/` (+ 1 devcontainer-only policy hook):
10+
- **Runtime hooks**: 4 base security hooks run automatically via `.claude/hooks/` (+ 1 devcontainer-only policy hook):
1111
- `dangerous-actions-blocker.sh` (PreToolUse/Bash): blocks `rm -rf`, `sudo`, `DROP DATABASE`, `git push --force`, secrets in args
1212
- `output-secrets-scanner.sh` (PostToolUse/Bash): warns if command output contains API keys, tokens, private keys, or DB URLs
1313
- `unicode-injection-scanner.sh` (PreToolUse/Edit|Write): blocks zero-width chars, RTL overrides, ANSI escapes, null bytes
14-
- `devcontainer-policy-blocker.sh` (PreToolUse/Bash, devcontainer only): blocks tool installation, publishing, supply-chain piping, and tier-dependent GH/infra commands
14+
- `firewall-edit-blocker.sh` (PreToolUse/Edit|Write, devcontainer only): blocks edits to `init-firewall.sh` and sudoers files
15+
- `devcontainer-policy-blocker.sh` (PreToolUse/Bash, devcontainer only): blocks tool installation, publishing, supply-chain piping, firewall commands, and tier-dependent GH/infra commands
1516
- **Secrets handling**: Never commit API keys, tokens, passwords, or private keys -- use environment variables or `.env` files (which are gitignored)
1617
- **Unsafe operations**: Avoid `eval`, `exec`, `pickle.loads`, `subprocess(shell=True)`, and `yaml.load` without SafeLoader in production code. If required, document the justification in a code comment
1718
- **Code review**: The code-reviewer agent checks for logic-level security issues (authorization bypass, TOCTOU, data exposure) that static pattern matching cannot catch

docs/DECISIONS.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,3 +172,15 @@ When a decision is superseded or obsolete, delete it (git history preserves the
172172
| GitHub API via curl (`curl -H "Authorization: ..." https://api.github.com/.../merge`) | Blocking curl to github.com is fragile and breaks legitimate web fetching. The hook already blocks commands containing `GH_TOKEN=` as a literal argument. | Use fine-grained PATs with minimal scopes. CLAUDE.md instructs Claude to use `gh` CLI, not raw API calls. Token scoping is the real control. |
173173
| Docker not present but deny rules exist | Docker is not installed in the current template container. Deny rules exist as defense-in-depth for users who add Docker-in-Docker later. | If Docker-in-Docker is added, the deny list should be revisited (add `-v` and `--mount` volume escape patterns). |
174174
| Whitelisted domains as exfil channels | `github.com` is whitelisted for git/gh operations. A compromised agent could theoretically exfiltrate via gist creation or issue comments. | Token scoping (no gist/issue create permission) + GH mutation deny rules in Tier 2. Tier 3 accepts this risk explicitly. |
175+
176+
## 2026-03-15: Devcontainer Firewall Inbound Relaxation and Tamper Protection
177+
178+
**Request**: Fix two firewall problems: (1) strict inbound filtering blocks legitimate dev server use cases unnecessarily, (2) Claude can tamper with the firewall via iptables commands or by editing init-firewall.sh.
179+
180+
**Decisions**:
181+
- Default to permissive inbound (`FIREWALL_ALLOW_INBOUND=true`) -- the primary threat model is egress (data exfiltration), not inbound; Docker's own network stack handles inbound isolation
182+
- Opt-in strict inbound via `FIREWALL_ALLOW_INBOUND=false` preserves the original INPUT DROP behavior for users who need it
183+
- Three-layer tamper protection: deny rules in all tier files + settings.json (Layer A), firewall command patterns in devcontainer-policy-blocker.sh (Layer B), new firewall-edit-blocker.sh hook blocks edits to init-firewall.sh and sudoers (Layer C)
184+
- Firewall deny rules apply at ALL tiers (not tier-gated) because the firewall is a security boundary, not a workflow convenience
185+
- `sudo` broadly denied in tier files because the vscode user's only sudoers entry is the firewall script, and Claude should never re-run it
186+
- firewall-edit-blocker.sh only activates when `DEVCONTAINER=true` -- no-op on bare metal where users own their own firewall

0 commit comments

Comments
 (0)