Skip to content

fix: Relax inbound firewall and add tamper protection#29

Open
stranma wants to merge 1 commit intomasterfrom
fix/firewall-inbound-tamper-protection
Open

fix: Relax inbound firewall and add tamper protection#29
stranma wants to merge 1 commit intomasterfrom
fix/firewall-inbound-tamper-protection

Conversation

@stranma
Copy link
Owner

@stranma stranma commented Mar 15, 2026

Summary

  • Permissive inbound by default: FIREWALL_ALLOW_INBOUND=true skips INPUT-chain restrictions since Docker handles inbound isolation. Opt-in strict mode via FIREWALL_ALLOW_INBOUND=false preserves original INPUT DROP behavior.
  • Three-layer tamper protection: (A) Deny rules for iptables/ip6tables/ipset/nft/init-firewall/sudo in all tier files + settings.json, (B) firewall command patterns in devcontainer-policy-blocker.sh (all tiers), (C) new firewall-edit-blocker.sh hook blocks edits to init-firewall.sh and sudoers files inside devcontainers.
  • Tests, docs, and CLAUDE.md updated to reflect new hook and deny rules (90 tests passing).

Test plan

  • python -m pytest tests/test_hooks.py -v -- 90 tests pass
  • Verify init-firewall.sh conditional wrapping does not break egress rules (manual in devcontainer)
  • Verify FIREWALL_ALLOW_INBOUND=false applies strict INPUT DROP (manual in devcontainer)
  • Confirm deny rules block iptables commands in all 3 tiers

Generated with Claude Code

Summary by CodeRabbit

Release Notes

  • New Features

    • Added configurable inbound firewall mode for development containers. Defaults to permissive behavior; strict filtering mode available via configuration option.
  • Chores

    • Enhanced security deny rules for firewall commands across multiple tools.
    • Added test coverage validating firewall security restrictions.
  • Documentation

    • Documented firewall configuration approach and updated threat modeling strategy.

@coderabbitai
Copy link

coderabbitai bot commented Mar 15, 2026

📝 Walkthrough

Walkthrough

This pull request introduces a configurable inbound firewall mode for the devcontainer. The firewall now defaults to allowing inbound traffic (permissive mode) while providing an opt-in strict mode. Firewall command patterns are added to the permission deny list to prevent tampering through configuration files and scripts.

Changes

Cohort / File(s) Summary
Firewall Permission Deny List
.claude/settings.json
Added Bash command patterns to deny list for firewall tooling: iptables, ip6tables, ipset, nft, and init-firewall to prevent unauthorized firewall modifications.
Devcontainer Firewall Configuration
.devcontainer/devcontainer.json, .devcontainer/init-firewall.sh
Added FIREWALL_ALLOW_INBOUND environment variable (defaults to true). Modified init-firewall.sh to conditionally apply INPUT rules based on this setting: when true, skips strict INPUT filtering; when false, applies original strict behavior.
Documentation and Testing
docs/DECISIONS.md, tests/test_hooks.py
Documented the firewall behavior change decision (2026-03-15). Added TestFirewallDenyRules test class to verify firewall deny patterns are present in settings.json.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

  • PR #32: Modifies devcontainer firewall implementation in init-firewall.sh and settings.json firewall deny configuration with similar code-level changes.
  • PR #17: Updates devcontainer firewall setup including init-firewall.sh and firewall-related allowlists and configuration.
  • PR #33: Touches devcontainer firewall configuration and settings.json deny rules for iptables/nft commands and firewall environment variables.
🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the two main objectives: relaxing inbound firewall (via FIREWALL_ALLOW_INBOUND) and adding tamper protection (deny rules and hooks). It is concise and clearly communicates the primary changes.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/firewall-inbound-tamper-protection
📝 Coding Plan
  • Generate coding plan for human review comments

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In @.claude/hooks/devcontainer-policy-blocker.sh:
- Around line 105-118: The current BLOCKED_FIREWALL array and matching logic
(uses BLOCKED_FIREWALL, COMMAND, and block) miss variants like iptables-legacy;
update the firewall check to also match common variants (e.g., iptables-legacy,
ip6tables-legacy, iptables-nft, etc.) or switch the grep test to detect the
iptables prefix with optional hyphen/word characters so commands like
"iptables-legacy -L" are caught; ensure you adjust the BLOCKED_FIREWALL entries
or the matching expression consistently and still avoid false positives like
iptables-save/restore before calling block.

In @.claude/settings.json:
- Around line 34-36: The settings.json intentionally omits the "Bash(*sudo *)"
deny pattern (while tier1/2/3 include it) to allow sudo on bare-metal; add a
short clarifying note in the project permissions documentation
(DEVCONTAINER_PERMISSIONS.md) describing this design choice, stating that
settings.json is for bare-metal installs and therefore does not block sudo
whereas the tier files enforce sudo denial for containerized/devcontainer
environments, so readers understand why "Bash(*sudo *)" is present in tiers but
absent from settings.json.

In `@tests/test_hooks.py`:
- Around line 236-242: The test
TestPolicyBlockerFirewallPatterns::test_policy_blocker_blocks_firewall_commands
should also assert that the devcontainer-policy-blocker.sh contains the "sudo "
pattern; update the pattern list used in that test (the for loop over ["iptables
", "ip6tables ", "ipset ", "nft ", "init-firewall"]) to include "sudo " so the
test verifies the Bash(*sudo *) tamper protection as well.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: fed3a26a-84cf-4508-ac09-545b673b4ebc

📥 Commits

Reviewing files that changed from the base of the PR and between 566e9cd and 5ef9084.

📒 Files selected for processing (12)
  • .claude/hooks/devcontainer-policy-blocker.sh
  • .claude/hooks/firewall-edit-blocker.sh
  • .claude/settings.json
  • .devcontainer/devcontainer.json
  • .devcontainer/init-firewall.sh
  • .devcontainer/permissions/tier1-assisted.json
  • .devcontainer/permissions/tier2-autonomous.json
  • .devcontainer/permissions/tier3-full-trust.json
  • CLAUDE.md
  • docs/DECISIONS.md
  • docs/DEVCONTAINER_PERMISSIONS.md
  • tests/test_hooks.py

Comment on lines +236 to +242
class TestPolicyBlockerFirewallPatterns:
"""Verify policy blocker blocks firewall commands."""

def test_policy_blocker_blocks_firewall_commands(self) -> None:
content = (HOOKS_DIR / "devcontainer-policy-blocker.sh").read_text(encoding="utf-8")
for pattern in ["iptables ", "ip6tables ", "ipset ", "nft ", "init-firewall"]:
assert pattern in content, f"devcontainer-policy-blocker missing firewall pattern: {pattern}"
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Consider adding sudo to the firewall pattern test.

The test verifies iptables, ip6tables, ipset, nft, and init-firewall patterns, but the tier JSON files also add Bash(*sudo *) as part of the firewall tamper protection. Consider including "sudo " in the pattern list for completeness.

Suggested addition
     def test_policy_blocker_blocks_firewall_commands(self) -> None:
         content = (HOOKS_DIR / "devcontainer-policy-blocker.sh").read_text(encoding="utf-8")
-        for pattern in ["iptables ", "ip6tables ", "ipset ", "nft ", "init-firewall"]:
+        for pattern in ["iptables ", "ip6tables ", "ipset ", "nft ", "init-firewall", "sudo "]:
             assert pattern in content, f"devcontainer-policy-blocker missing firewall pattern: {pattern}"
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
class TestPolicyBlockerFirewallPatterns:
"""Verify policy blocker blocks firewall commands."""
def test_policy_blocker_blocks_firewall_commands(self) -> None:
content = (HOOKS_DIR / "devcontainer-policy-blocker.sh").read_text(encoding="utf-8")
for pattern in ["iptables ", "ip6tables ", "ipset ", "nft ", "init-firewall"]:
assert pattern in content, f"devcontainer-policy-blocker missing firewall pattern: {pattern}"
class TestPolicyBlockerFirewallPatterns:
"""Verify policy blocker blocks firewall commands."""
def test_policy_blocker_blocks_firewall_commands(self) -> None:
content = (HOOKS_DIR / "devcontainer-policy-blocker.sh").read_text(encoding="utf-8")
for pattern in ["iptables ", "ip6tables ", "ipset ", "nft ", "init-firewall", "sudo "]:
assert pattern in content, f"devcontainer-policy-blocker missing firewall pattern: {pattern}"
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/test_hooks.py` around lines 236 - 242, The test
TestPolicyBlockerFirewallPatterns::test_policy_blocker_blocks_firewall_commands
should also assert that the devcontainer-policy-blocker.sh contains the "sudo "
pattern; update the pattern list used in that test (the for loop over ["iptables
", "ip6tables ", "ipset ", "nft ", "init-firewall"]) to include "sudo " so the
test verifies the Bash(*sudo *) tamper protection as well.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@docs/DEVCONTAINER_PERMISSIONS.md`:
- Line 101: The sentence claiming "Docker's own network stack already provides
inbound isolation" is too absolute—update that phrasing to qualify that inbound
isolation depends on configuration; replace it with wording such as: "Docker's
network stack can provide inbound isolation depending on port publishing,
network mode, and the host firewall context" and/or add a short parenthetical
mentioning published ports and bridge/host/network modes so the doc accurately
reflects that isolation is conditional rather than guaranteed.

In `@tests/test_hooks.py`:
- Around line 239-242: Extract the inline firewall pattern list in
test_policy_blocker_blocks_firewall_commands into a module-level constant (e.g.,
FIREWALL_PATTERNS) declared near the top of tests/test_hooks.py, then replace
the literal list in the test with that constant; keep using HOOKS_DIR and the
existing loop/assert logic to read devcontainer-policy-blocker.sh and assert
each pattern is present so readability and maintainability are improved.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 71f385f0-e43b-4f11-98db-647f5297e982

📥 Commits

Reviewing files that changed from the base of the PR and between 5ef9084 and dc78f40.

📒 Files selected for processing (3)
  • .claude/hooks/devcontainer-policy-blocker.sh
  • docs/DEVCONTAINER_PERMISSIONS.md
  • tests/test_hooks.py


## Inbound Firewall

By default, the devcontainer firewall only restricts **egress** (outbound) traffic. Inbound traffic is left permissive because Docker's own network stack already provides inbound isolation, and strict inbound rules prevent legitimate use cases like exposing dev servers to LAN devices or other containers.
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Avoid absolute inbound-isolation wording.

Line 101 says Docker “already provides inbound isolation,” which can be interpreted too broadly. Please qualify this to avoid giving a false security guarantee (e.g., isolation depends on port publishing/network mode and host firewall context).

Suggested wording tweak
-By default, the devcontainer firewall only restricts **egress** (outbound) traffic. Inbound traffic is left permissive because Docker's own network stack already provides inbound isolation, and strict inbound rules prevent legitimate use cases like exposing dev servers to LAN devices or other containers.
+By default, the devcontainer firewall only restricts **egress** (outbound) traffic. Inbound traffic is left permissive because Docker networking typically limits direct inbound reachability unless ports are published, and strict inbound rules can prevent legitimate use cases like exposing dev servers to LAN devices or other containers.
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
By default, the devcontainer firewall only restricts **egress** (outbound) traffic. Inbound traffic is left permissive because Docker's own network stack already provides inbound isolation, and strict inbound rules prevent legitimate use cases like exposing dev servers to LAN devices or other containers.
By default, the devcontainer firewall only restricts **egress** (outbound) traffic. Inbound traffic is left permissive because Docker networking typically limits direct inbound reachability unless ports are published, and strict inbound rules can prevent legitimate use cases like exposing dev servers to LAN devices or other containers.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/DEVCONTAINER_PERMISSIONS.md` at line 101, The sentence claiming
"Docker's own network stack already provides inbound isolation" is too
absolute—update that phrasing to qualify that inbound isolation depends on
configuration; replace it with wording such as: "Docker's network stack can
provide inbound isolation depending on port publishing, network mode, and the
host firewall context" and/or add a short parenthetical mentioning published
ports and bridge/host/network modes so the doc accurately reflects that
isolation is conditional rather than guaranteed.

Comment on lines +239 to +242
def test_policy_blocker_blocks_firewall_commands(self) -> None:
content = (HOOKS_DIR / "devcontainer-policy-blocker.sh").read_text(encoding="utf-8")
for pattern in ["iptables ", "iptables-legacy ", "iptables-nft ", "ip6tables ", "ip6tables-legacy ", "ip6tables-nft ", "ipset ", "nft ", "init-firewall"]:
assert pattern in content, f"devcontainer-policy-blocker missing firewall pattern: {pattern}"
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Consider extracting patterns to a constant for readability.

The inline pattern list is lengthy. Extracting to a module-level constant would improve readability and make future updates easier.

♻️ Suggested refactor
+FIREWALL_PATTERNS = [
+    "iptables ",
+    "iptables-legacy ",
+    "iptables-nft ",
+    "ip6tables ",
+    "ip6tables-legacy ",
+    "ip6tables-nft ",
+    "ipset ",
+    "nft ",
+    "init-firewall",
+]
+
+
 class TestPolicyBlockerFirewallPatterns:
     """Verify policy blocker blocks firewall commands."""

     def test_policy_blocker_blocks_firewall_commands(self) -> None:
         content = (HOOKS_DIR / "devcontainer-policy-blocker.sh").read_text(encoding="utf-8")
-        for pattern in ["iptables ", "iptables-legacy ", "iptables-nft ", "ip6tables ", "ip6tables-legacy ", "ip6tables-nft ", "ipset ", "nft ", "init-firewall"]:
+        for pattern in FIREWALL_PATTERNS:
             assert pattern in content, f"devcontainer-policy-blocker missing firewall pattern: {pattern}"
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/test_hooks.py` around lines 239 - 242, Extract the inline firewall
pattern list in test_policy_blocker_blocks_firewall_commands into a module-level
constant (e.g., FIREWALL_PATTERNS) declared near the top of tests/test_hooks.py,
then replace the literal list in the test with that constant; keep using
HOOKS_DIR and the existing loop/assert logic to read
devcontainer-policy-blocker.sh and assert each pattern is present so readability
and maintainability are improved.

@stranma stranma force-pushed the fix/firewall-inbound-tamper-protection branch from dc78f40 to f7c9b9e Compare March 19, 2026 22:39
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In @.devcontainer/init-firewall.sh:
- Around line 202-204: The current init-firewall.sh branch only sets iptables -P
INPUT DROP when ALLOW_INBOUND != "true", leaving INPUT policy unchanged when
ALLOW_INBOUND=="true" which breaks idempotency; update the conditional so the
permissive branch explicitly runs iptables -P INPUT ACCEPT (i.e., when
ALLOW_INBOUND == "true" set the INPUT policy to ACCEPT) so each run
deterministically enforces the intended mode and prevents leftover DROP policies
from previous runs.

In `@tests/test_hooks.py`:
- Around line 175-179: The test_settings_denies_firewall_commands currently
reads settings.json as a raw string and asserts substrings; instead parse the
JSON (use settings_path.read_text + json.loads) and locate the permissions.deny
array, then assert that each expected pattern (iptables, ip6tables, ipset, nft,
init-firewall) is present as an element in that deny list (e.g., compare
membership or set equality) so the test fails if those patterns were moved out
of permissions.deny; update the test to raise a clear assertion if permissions
or deny are missing.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 78151549-aeff-4aa7-ab5e-e0c0d2fff950

📥 Commits

Reviewing files that changed from the base of the PR and between dc78f40 and f7c9b9e.

📒 Files selected for processing (5)
  • .claude/settings.json
  • .devcontainer/devcontainer.json
  • .devcontainer/init-firewall.sh
  • docs/DECISIONS.md
  • tests/test_hooks.py

Comment on lines +202 to +204
if [ "$ALLOW_INBOUND" != "true" ]; then
iptables -P INPUT DROP
fi
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Permissive mode is not explicitly enforced (idempotency bug).

When ALLOW_INBOUND=true, INPUT policy is left unchanged. If a previous run set INPUT DROP, permissive mode can still behave as strict. Set INPUT ACCEPT explicitly in the permissive branch.

Proposed fix
-if [ "$ALLOW_INBOUND" != "true" ]; then
-    iptables -P INPUT DROP
-fi
+if [ "$ALLOW_INBOUND" != "true" ]; then
+    iptables -P INPUT DROP
+else
+    iptables -P INPUT ACCEPT
+fi

Based on learnings: "Implement two-layer defense against data exfiltration: firewall (iptables whitelist in devcontainer) and exfiltration guard (dangerous-actions-blocker.sh hook)".

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if [ "$ALLOW_INBOUND" != "true" ]; then
iptables -P INPUT DROP
fi
if [ "$ALLOW_INBOUND" != "true" ]; then
iptables -P INPUT DROP
else
iptables -P INPUT ACCEPT
fi
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.devcontainer/init-firewall.sh around lines 202 - 204, The current
init-firewall.sh branch only sets iptables -P INPUT DROP when ALLOW_INBOUND !=
"true", leaving INPUT policy unchanged when ALLOW_INBOUND=="true" which breaks
idempotency; update the conditional so the permissive branch explicitly runs
iptables -P INPUT ACCEPT (i.e., when ALLOW_INBOUND == "true" set the INPUT
policy to ACCEPT) so each run deterministically enforces the intended mode and
prevents leftover DROP policies from previous runs.

Comment on lines +175 to +179
def test_settings_denies_firewall_commands(self) -> None:
settings_path = Path(__file__).parent.parent / ".claude" / "settings.json"
content = settings_path.read_text(encoding="utf-8")
for pattern in ["iptables", "ip6tables", "ipset", "nft", "init-firewall"]:
assert pattern in content, f"settings.json missing firewall deny pattern: {pattern}"
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Test is too weak: it checks substrings, not actual deny-list entries.

This can pass even if firewall patterns are moved out of permissions.deny. Parse JSON and assert exact deny patterns in the deny array.

Proposed fix
+import json
@@
 class TestFirewallDenyRules:
@@
     def test_settings_denies_firewall_commands(self) -> None:
         settings_path = Path(__file__).parent.parent / ".claude" / "settings.json"
-        content = settings_path.read_text(encoding="utf-8")
-        for pattern in ["iptables", "ip6tables", "ipset", "nft", "init-firewall"]:
-            assert pattern in content, f"settings.json missing firewall deny pattern: {pattern}"
+        data = json.loads(settings_path.read_text(encoding="utf-8"))
+        deny_rules = set(data.get("permissions", {}).get("deny", []))
+        expected = {
+            "Bash(*iptables *)",
+            "Bash(*ip6tables *)",
+            "Bash(*ipset *)",
+            "Bash(*nft *)",
+            "Bash(*init-firewall*)",
+        }
+        for pattern in expected:
+            assert pattern in deny_rules, f"settings.json missing firewall deny pattern: {pattern}"

Based on learnings: "Implement two-layer defense against data exfiltration: firewall (iptables whitelist in devcontainer) and exfiltration guard (dangerous-actions-blocker.sh hook)".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/test_hooks.py` around lines 175 - 179, The
test_settings_denies_firewall_commands currently reads settings.json as a raw
string and asserts substrings; instead parse the JSON (use
settings_path.read_text + json.loads) and locate the permissions.deny array,
then assert that each expected pattern (iptables, ip6tables, ipset, nft,
init-firewall) is present as an element in that deny list (e.g., compare
membership or set equality) so the test fails if those patterns were moved out
of permissions.deny; update the test to raise a clear assertion if permissions
or deny are missing.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant