256 lines
14 KiB
Bash
Executable File
256 lines
14 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
|
|
# The firewall page answers "what can another machine reach?", and that answer
|
|
# needs both halves at once.
|
|
#
|
|
# A port is reachable only when something is LISTENING on a network address AND
|
|
# the firewall permits it. Either half alone is not exposure -- which is exactly
|
|
# how a tidy rules list coexists with an open database, as it does on this
|
|
# machine.
|
|
#
|
|
# The rules:
|
|
#
|
|
# 1. Exposure is the crossing, not either half. A listener the firewall blocks
|
|
# is not exposed, and an allowed port nothing listens on is not either.
|
|
# 2. Ephemeral client sockets are not services. A browser's outbound UDP port
|
|
# looks identical to a service in `ss`, and listing twenty of them buries
|
|
# the two rows that matter.
|
|
# 3. Nothing destructive happens without saying what it cuts off, by name.
|
|
# 4. The page never states what it has not checked. It said "firewalld is
|
|
# stopped" for the seconds before its first read returned.
|
|
# 5. Rich rules are shown and never edited: a syntax is not a setting, but
|
|
# hiding it would misrepresent the configuration.
|
|
#
|
|
# Read-only. It never changes a firewall rule.
|
|
|
|
set -uo pipefail
|
|
|
|
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
|
helper="$repo_dir/config/dot/quickshell/scripts/panama-firewall"
|
|
service="$repo_dir/config/dot/quickshell/services/Firewall.qml"
|
|
page="$repo_dir/config/dot/quickshell/modules/settings/FirewallPage.qml"
|
|
|
|
fail() {
|
|
printf 'firewall contract: %s\n' "$1" >&2
|
|
exit 1
|
|
}
|
|
|
|
for path in "$helper" "$service" "$page"; do
|
|
[[ -r "$path" ]] || fail "missing $path"
|
|
done
|
|
[[ -x "$helper" ]] || fail 'panama-firewall is not executable'
|
|
|
|
# ── 1. Exposure is the crossing ─────────────────────────────────────────────
|
|
grep -q 'def listeners' "$helper" || fail 'nothing enumerates what is listening'
|
|
grep -q 'def allowed_ports' "$helper" || fail 'nothing enumerates what the firewall permits'
|
|
# Checked from the DATA, not from the source. An earlier version grepped for
|
|
# the guard line and passed with it deleted, because the same words appear on an
|
|
# unrelated line a few lines below -- so the check was matching itself into a
|
|
# false pass while every listener was being reported as exposed.
|
|
|
|
# The crossing itself, against a recorded firewall. It cannot be tested against
|
|
# this machine: its zone permits every port above 1024, so "listening" and
|
|
# "listening AND permitted" produce identical answers, and the blocked case
|
|
# needs a listener below port 1024, which needs root to create.
|
|
work="$(mktemp -d /tmp/panama-firewall.XXXXXX)"
|
|
trap 'rm -rf "$work"' EXIT
|
|
cat >"$work/fixture.json" <<'FIXTURE'
|
|
{
|
|
"zones": [{"name": "test", "interfaces": ["eth0"], "services": ["ssh"],
|
|
"ports": ["8000-8999/tcp"], "richRules": [], "target": "default"}],
|
|
"servicePorts": {"ssh": ["22/tcp"]},
|
|
"listeners": [
|
|
{"port": 22, "protocol": "tcp", "process": "sshd"},
|
|
{"port": 8080, "protocol": "tcp", "process": "webserver"},
|
|
{"port": 5432, "protocol": "tcp", "process": "postgres"},
|
|
{"port": 631, "protocol": "tcp", "process": "cupsd"}
|
|
]
|
|
}
|
|
FIXTURE
|
|
|
|
crossed="$(PANAMA_FIREWALL_FIXTURE="$work/fixture.json" "$helper" snapshot 2>/dev/null)" \
|
|
|| fail 'the recorded firewall could not be read'
|
|
reachable="$(jq -r '[.exposed[].port] | sort | join(",")' <<<"$crossed")"
|
|
|
|
# 22 is allowed by the ssh service; 8080 falls in the open range. 5432 and 631
|
|
# are listening and NOT permitted, so they are not exposure.
|
|
[[ "$reachable" == "22,8080" ]] \
|
|
|| fail "the crossing is wrong: reachable ports were [$reachable], expected [22,8080] -- 5432 and 631 are listening but not permitted"
|
|
|
|
jq -e '[.exposed[] | select(.port == 22) | .allowedBy] | .[0] == "the ssh service"' <<<"$crossed" >/dev/null \
|
|
|| fail 'a port allowed by a named service is not attributed to that service'
|
|
jq -e '[.exposed[] | select(.port == 8080) | .allowedBy] | .[0] == "the open port range"' <<<"$crossed" >/dev/null \
|
|
|| fail 'a port allowed by a range is not attributed to the range'
|
|
|
|
# ── 2. Ephemeral sockets are excluded ───────────────────────────────────────
|
|
grep -q 'EPHEMERAL_FLOOR' "$helper" \
|
|
|| fail 'ephemeral client sockets are not distinguished from services'
|
|
|
|
# ── 3. Destructive actions name their consequences ──────────────────────────
|
|
page_code="$(grep -vE '^\s*//' "$page")"
|
|
grep -q 'rangeDependents' "$service" \
|
|
|| fail 'nothing computes what closing the port range would cut off'
|
|
grep -q 'Closing this cuts off' <<<"$page_code" \
|
|
|| fail 'closing the port range does not say what it cuts off'
|
|
grep -q 'confirmingRange' <<<"$page_code" \
|
|
|| fail 'the port range can be closed without confirming'
|
|
grep -q 'confirmingRemoval' <<<"$page_code" \
|
|
|| fail 'a service can be removed without confirming'
|
|
# Removing ssh while someone is connected over it ends their session.
|
|
grep -q 'sshSessions' "$service" || fail 'the service does not know about live SSH sessions'
|
|
grep -q 'connected over SSH right now' <<<"$page_code" \
|
|
|| fail 'removing ssh does not warn when someone is connected over it'
|
|
|
|
# ── 4. The page does not answer before it has looked ────────────────────────
|
|
grep -q 'Firewall.scanned' <<<"$page_code" \
|
|
|| fail 'the page reports firewall state before its first read has returned'
|
|
grep -qE 'Checking' <<<"$page_code" \
|
|
|| fail 'there is no state for "not read yet", so it must be claiming one of the answers'
|
|
|
|
# ── 5. Rich rules are shown, not edited ─────────────────────────────────────
|
|
grep -q 'richRules' "$helper" || fail 'rich rules are not read, so the page would hide them'
|
|
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'
|
|
jq -e '(.exposed | type == "array") and (.zones | type == "array")' <<<"$state" >/dev/null \
|
|
|| fail 'the snapshot is missing exposure or zones'
|
|
|
|
if [[ "$(jq -r '.available' <<<"$state")" == "true" ]]; then
|
|
# Everything reported as exposed must name a rule THIS ZONE ACTUALLY HAS.
|
|
# A permissive stand-in like "assumed" satisfies "non-empty" while meaning
|
|
# the crossing was never performed, so the reason is matched against the
|
|
# zone's real services and port ranges.
|
|
allowed_reasons="$(jq -r '
|
|
(.zones[0].services // [] | map("the \(.) service"))
|
|
+ (if ((.zones[0].ports // []) | length) > 0 then ["the open port range"] else [] end)
|
|
| .[]' <<<"$state" | sort -u)"
|
|
[[ -n "$allowed_reasons" ]] || fail 'the zone reports no services and no ports, so nothing could be permitted'
|
|
while read -r reason; do
|
|
[[ -n "$reason" ]] || continue
|
|
grep -qxF "$reason" <<<"$allowed_reasons" \
|
|
|| fail "something is reported as reachable via \"$reason\", which is not a rule this zone has"
|
|
done < <(jq -r '.exposed[].allowedBy' <<<"$state" | sort -u)
|
|
# And must be a real port.
|
|
jq -e '[.exposed[] | (.port > 0 and .port < 65536)] | all' <<<"$state" >/dev/null \
|
|
|| fail 'an exposed entry has no valid port'
|
|
# Loopback-only listeners are not exposure and must never appear.
|
|
jq -e '[.exposed[] | select(.name == "loopback")] | length == 0' <<<"$state" >/dev/null \
|
|
|| 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
|
|
for bad in "22" "22/sctp" "70000/tcp" "abc/tcp"; do
|
|
[[ -n "$(refusal add-port "$bad")" ]] || fail "a bad port specification was accepted: $bad"
|
|
done
|
|
[[ -n "$(refusal set-zone 'eth0; reboot' public)" ]] || fail 'a bad interface name was accepted'
|
|
[[ -n "$(refusal bogus)" ]] || fail 'an unknown command was accepted'
|
|
|
|
printf 'firewall contract: PASS (%s reachable, %s of them data stores)\n' \
|
|
"$(jq '.exposed | length' <<<"$state")" \
|
|
"$(jq '.exposedDataStores | length' <<<"$state")"
|