#!/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")"