The fixed-port firewall rules were open to every program on the machine #368

Merged
enricobuehler merged 1 commits from worktree-firewall-program-scoped-rules into main 2026-08-21 14:14:12 +00:00
Owner

Reported by the user from #367, after the source-IP fix resolved their black screen. They are right.

The hole

service install added inbound rules carrying only localport=:

netsh advfirewall firewall add rule name="Punktfunk UDP" dir=in action=allow \
      protocol=UDP localport=47998-48010,9777,5353 profile=domain,private

A dir=in action=allow rule with no program= admits any process on the machine on those ports. Binding a high port on Windows needs no elevation, so any unprivileged program could take 9777, 47990, 5353 or the GameStream range and be reachable from the LAN just by binding first — and silently, because our rule is precisely what suppresses the "Allow this app to communicate on…" prompt that would otherwise be the only way in.

Six port groups were affected: GameStream (47984/47989/47998-48010/48010), native (9777), mgmt (47990), mDNS (5353), and the console pair (47992/47993). The data-plane rule added in #360 was already program-scoped — it is the pattern the rest now follow.

The change

Every rule is scoped to the executable that actually listens on it, keeping localport=. Program AND port is strictly tighter than either alone; the port is only ever dropped where it genuinely cannot be known in advance (the data plane binds 0.0.0.0:0 per session).

  • Host rules → the host exe, resolved once and shared with the data-plane rule.
  • Console rules (47992/47993) → the bundled <app>/bun/bun.exe, the same path service.rs's supervisor spawns. These listen under bun, not under the host.

The old argument for leaving them unscoped — "an install whose recorded exe path later moves still has its fixed ports open" — does not hold: service install re-runs the whole remove-then-add on every upgrade, so the path is refreshed, not left stale.

Fallbacks are deliberate and asymmetric:

rule program unresolvable
fixed-port falls back to the old any-program form — a looser rule still streams, no rule is a black screen
data plane skips — it has no localport= to fall back to, so a program-less version isn't a looser rule, it's an open host

One visible behaviour change

5353 is punktfunk's alone now. Anything else on the machine that answered mDNS through our permissive rule needs its own rule. service install says so in its output, gated on the scoping actually having happened.

⚠ Verification — read before merging

No PR-triggered job compiles this code. windows/service.rs and windows/install.rs are behind #[cfg(target_os = "windows")]; ci / rust is Linux and skips them, and windows-host.yml triggers only on push to main and v* tags. macOS can't build punktfunk-host at all. So the green checks on this PR mean less than they usually do.

What I did instead:

  • Dispatched windows-host.yml manually against this branch — that is the real compile, and this stays draft until it is green.
  • Ran the new fw_add_rule_args and its three tests in a throwaway crate (3/3 pass): fixed-port rules carry both program= and localport=; the data-plane rule carries a program and no port; a missing program degrades to the port-only form.
  • rustfmt --check directly on both files (confirms they parse).

Worth fixing separately: that windows-host.yml doesn't run on pull_request means every Windows-only change merges uncompiled, which is how this file's blast radius stays invisible until main.

Reported by the user from #367, after the source-IP fix resolved their black screen. They are right. ## The hole `service install` added inbound rules carrying only `localport=`: ``` netsh advfirewall firewall add rule name="Punktfunk UDP" dir=in action=allow \ protocol=UDP localport=47998-48010,9777,5353 profile=domain,private ``` A `dir=in action=allow` rule with no `program=` admits **any process on the machine** on those ports. Binding a high port on Windows needs no elevation, so any unprivileged program could take 9777, 47990, 5353 or the GameStream range and be reachable from the LAN just by binding first — and *silently*, because our rule is precisely what suppresses the "Allow this app to communicate on…" prompt that would otherwise be the only way in. Six port groups were affected: GameStream (47984/47989/47998-48010/48010), native (9777), mgmt (47990), mDNS (5353), and the console pair (47992/47993). The data-plane rule added in #360 was already program-scoped — it is the pattern the rest now follow. ## The change Every rule is scoped to the executable that actually listens on it, **keeping** `localport=`. Program AND port is strictly tighter than either alone; the port is only ever dropped where it genuinely cannot be known in advance (the data plane binds `0.0.0.0:0` per session). - Host rules → the host exe, resolved once and shared with the data-plane rule. - Console rules (47992/47993) → the bundled `<app>/bun/bun.exe`, the same path `service.rs`'s supervisor spawns. These listen under bun, not under the host. The old argument for leaving them unscoped — *"an install whose recorded exe path later moves still has its fixed ports open"* — does not hold: `service install` re-runs the whole remove-then-add on every upgrade, so the path is refreshed, not left stale. Fallbacks are deliberate and asymmetric: | rule | program unresolvable | |---|---| | fixed-port | falls back to the old any-program form — a looser rule still streams, no rule is a black screen | | data plane | **skips** — it has no `localport=` to fall back to, so a program-less version isn't a looser rule, it's an open host | ## One visible behaviour change 5353 is punktfunk's alone now. Anything else on the machine that answered mDNS through our permissive rule needs its own rule. `service install` says so in its output, gated on the scoping actually having happened. ## ⚠ Verification — read before merging **No PR-triggered job compiles this code.** `windows/service.rs` and `windows/install.rs` are behind `#[cfg(target_os = "windows")]`; `ci / rust` is Linux and skips them, and `windows-host.yml` triggers only on push to `main` and `v*` tags. macOS can't build `punktfunk-host` at all. So the green checks on this PR mean less than they usually do. What I did instead: - Dispatched `windows-host.yml` manually against this branch — that is the real compile, and this stays draft until it is green. - Ran the new `fw_add_rule_args` and its three tests in a throwaway crate (3/3 pass): fixed-port rules carry both `program=` and `localport=`; the data-plane rule carries a program and no port; a missing program degrades to the port-only form. - `rustfmt --check` directly on both files (confirms they parse). Worth fixing separately: that `windows-host.yml` doesn't run on `pull_request` means every Windows-only change merges uncompiled, which is how this file's blast radius stays invisible until main.
enricobuehler added 1 commit 2026-08-21 14:05:56 +00:00
fix(host,windows): the fixed-port firewall rules were open to every program on the machine
ci / docs-drift (pull_request) Successful in 29s
ci / bun-nix (pull_request) Successful in 1m19s
ci / web (pull_request) Successful in 1m27s
ci / docs-site (pull_request) Successful in 1m26s
ci / rust-arm64 (pull_request) Successful in 1m40s
ci / rust (pull_request) Successful in 5m38s
android / android (pull_request) Successful in 6m1s
0df4ca957f
`service install` added `dir=in action=allow` rules carrying only `localport=`,
which admit ANY process on the machine on those ports — GameStream
(47984/47989/47998-48010/48010), the native plane (9777), mgmt (47990), mDNS
(5353), and the console pair (47992/47993). Binding a high port on Windows
needs no elevation, so an unprivileged program could take any of them and be
reachable from the LAN simply by binding first — silently, because our rule is
precisely what suppresses the "Allow this app to communicate on…" prompt that
would otherwise be the only way in.

Scope every rule to the executable that actually listens on it, keeping the
ports: program AND port is strictly tighter than either alone. The host rules
name the host exe (resolved once and shared with the data-plane rule, which
already worked this way and is the pattern the rest now follow); the console
rules name the bundled `<app>/bun/bun.exe` the supervisor spawns.

The old argument for leaving them unscoped — "an install whose recorded exe
path later moves still has its fixed ports open" — does not hold: `service
install` re-runs this whole remove-then-add on every upgrade, so the path is
refreshed rather than left stale.

Fallbacks are deliberate and asymmetric. A fixed-port rule whose program can't
be resolved falls back to the old any-program form, because a looser rule still
streams and no rule is a black screen. The data-plane rule instead skips: it
has no `localport=` to fall back to, so a program-less version of it would not
be a looser rule but an open host.

One externally visible change, called out in installer output: 5353 is ours
alone now, so anything else on the machine that answered mDNS through
punktfunk's rule needs its own.

Reported by a user on 2026-08-21, after the source-IP fix in #367 resolved
their black screen.
enricobuehler marked the pull request as ready for review 2026-08-21 14:14:03 +00:00
enricobuehler merged commit 48eeae7527 into main 2026-08-21 14:14:12 +00:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: unom/punktfunk#368