Own the network: details, VPN, enterprise Wi-Fi, and a firewall that can also allow
Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
This commit is contained in:
@@ -113,6 +113,86 @@ grep -q 'richRules' <<<"$page_code" || fail 'rich rules are not shown'
|
||||
grep -qiE 'addRichRule|removeRichRule|--add-rich-rule' "$helper" "$page" \
|
||||
&& fail 'the page edits rich rules, which are a syntax rather than a setting'
|
||||
|
||||
# ── 6. Opening something is not the same act as closing it ──────────────────
|
||||
#
|
||||
# The page gained its add side long after its remove side, and the temptation
|
||||
# was to give both the same confirm-then-act shape for symmetry. That would be
|
||||
# wrong, and wrong in the direction that matters: a confirmation dialog is how
|
||||
# this page says "this has a consequence you cannot see from here". Allowing a
|
||||
# port has exactly one consequence, and it is the sentence the user just read on
|
||||
# the button. Spending a confirm on it teaches people to click through the ones
|
||||
# that mean something.
|
||||
#
|
||||
# So: additions go straight through, removals and zone changes do not.
|
||||
grep -q 'Firewall.addService\|addService(' <<<"$page_code" \
|
||||
|| fail 'the page cannot allow a named service, so the firewall is still read-only from here'
|
||||
grep -q 'Firewall.addPort\|addPort(' <<<"$page_code" \
|
||||
|| fail 'the page cannot allow a port'
|
||||
grep -qE 'confirming(Add|Allow|Service|Port)\b' <<<"$page_code" \
|
||||
&& fail 'allowing something asks for a confirmation; that ceremony belongs to the actions that cut people off'
|
||||
# What it must say instead, because both facts are invisible from the button:
|
||||
# the rule outlives a reboot, and firewalld will raise a polkit prompt.
|
||||
grep -qi 'permanent' <<<"$page_code" \
|
||||
|| fail 'the add flow never says the rule is permanent'
|
||||
grep -qi 'ask for your password' <<<"$page_code" \
|
||||
|| fail 'the add flow never warns that the system will ask for a password'
|
||||
|
||||
# A zone change IS consequence-bearing, and its consequence is specific: it
|
||||
# changes which rules apply to one named interface, and every other interface
|
||||
# keeps the zone it had. A confirm that says "change zone?" tells the user
|
||||
# nothing they did not already know, so this pins that the interface is named.
|
||||
grep -q 'setZone' <<<"$page_code" \
|
||||
|| fail 'the page cannot change a connection zone'
|
||||
grep -q 'setDefaultZone' <<<"$page_code" \
|
||||
|| fail 'the page cannot change the default zone'
|
||||
grep -qE '(confirming|pending)(Zone|Interface)' <<<"$page_code" \
|
||||
|| fail 'a connection zone can be changed without confirming, and it decides which rules apply to that link'
|
||||
python3 - "$page" <<'PY' || fail 'the zone-change confirmation does not name the interface it applies to'
|
||||
import re
|
||||
import sys
|
||||
|
||||
lines = open(sys.argv[1], encoding="utf-8").read().splitlines()
|
||||
anchors = [i for i, line in enumerate(lines)
|
||||
if re.search(r"(confirming|pending)(Zone|Interface)", line)]
|
||||
if not anchors:
|
||||
raise SystemExit(1)
|
||||
# Any one of the two-stage anchors may be the one carrying the prose; the
|
||||
# declaration of the state is usually not.
|
||||
for anchor in anchors:
|
||||
window = "\n".join(lines[max(0, anchor - 20):anchor + 60])
|
||||
named = re.search(r"(interface|iface)", window, re.I)
|
||||
interpolated = re.search(r"\$\{|\" \+ |\+ \"", window)
|
||||
if named and interpolated:
|
||||
raise SystemExit(0)
|
||||
raise SystemExit(1)
|
||||
PY
|
||||
|
||||
# ── 7. The zone browser reads and does not write ────────────────────────────
|
||||
#
|
||||
# `zone-info` exists so somebody can look at what a zone would do before moving
|
||||
# an interface into it. A read that can write is not a browser, it is a foot-gun
|
||||
# with a magnifying glass on it.
|
||||
grep -q 'zone-info' "$helper" \
|
||||
|| fail 'the helper cannot describe a zone, so the zone browser has nothing to show'
|
||||
grep -q 'zoneInfo' "$service" \
|
||||
|| fail 'the service does not expose zone descriptions'
|
||||
python3 - "$helper" <<'PY' || fail 'the zone-info path can change the firewall'
|
||||
import re
|
||||
import sys
|
||||
|
||||
source = open(sys.argv[1], encoding="utf-8").read()
|
||||
match = re.search(r"\ndef zone_info\b.*?(?=\ndef |\Z)", source, re.S)
|
||||
if not match:
|
||||
raise SystemExit(1)
|
||||
body = match.group(0)
|
||||
# --info-zone and --list-* are reads. Anything that adds, removes, changes or
|
||||
# makes permanent is not.
|
||||
if re.search(r"--(add|remove|change|set|permanent|reload)", body):
|
||||
print(body[:400], file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
raise SystemExit(0)
|
||||
PY
|
||||
|
||||
command -v jq >/dev/null 2>&1 || { printf 'firewall contract: SKIP (no jq)\n'; exit 0; }
|
||||
|
||||
state="$("$helper" snapshot 2>/dev/null)" || fail 'snapshot failed'
|
||||
@@ -142,8 +222,25 @@ if [[ "$(jq -r '.available' <<<"$state")" == "true" ]]; then
|
||||
|| fail 'a loopback-only listener is reported as reachable'
|
||||
fi
|
||||
|
||||
# ── zone-info describes a zone and leaves it exactly as it found it ─────────
|
||||
if [[ "$(jq -r '.available' <<<"$state")" == "true" ]]; then
|
||||
zone_name="$(jq -r '.zones[0].name // ""' <<<"$state")"
|
||||
if [[ -n "$zone_name" ]]; then
|
||||
info="$("$helper" zone-info "$zone_name" 2>/dev/null)" \
|
||||
|| fail "zone-info failed for the zone this machine is actually in ($zone_name)"
|
||||
jq -e '(.services | type == "array") and (.ports | type == "array") and has("summary")' \
|
||||
<<<"$info" >/dev/null \
|
||||
|| fail "zone-info does not describe services, ports and a summary: $info"
|
||||
after="$("$helper" snapshot 2>/dev/null)" || fail 'the snapshot after zone-info failed'
|
||||
[[ "$(jq -cS '.zones' <<<"$after")" == "$(jq -cS '.zones' <<<"$state")" ]] \
|
||||
|| fail 'reading a zone changed the firewall, which is the one thing a browser must not do'
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── Refusals ────────────────────────────────────────────────────────────────
|
||||
refusal() { "$helper" "$@" 2>/dev/null | jq -r '.error // ""'; }
|
||||
[[ -n "$(refusal zone-info 'public; reboot')" ]] || fail 'a bad zone name was accepted by zone-info'
|
||||
[[ -n "$(refusal zone-info '')" ]] || fail 'an empty zone name was accepted by zone-info'
|
||||
for bad in "ssh; rm -rf /" "../escape" "" "UPPER CASE"; do
|
||||
[[ -n "$(refusal add-service "$bad")" ]] || fail "a bad service name was accepted: $bad"
|
||||
done
|
||||
|
||||
Reference in New Issue
Block a user