Skip to content

fix(start-wrt): scope published-port NAT reflection to permitted Security Profiles - #3888

Open
Dominion5254 wants to merge 8 commits into
masterfrom
wrt/published-port-reflection-scope
Open

fix(start-wrt): scope published-port NAT reflection to permitted Security Profiles#3888
Dominion5254 wants to merge 8 commits into
masterfrom
wrt/published-port-reflection-scope

Conversation

@Dominion5254

Copy link
Copy Markdown
Collaborator

Problem

A published port was only reachable at the router's WAN address from the target device's own Security Profile. fw4 defaults a redirect's reflection_zone to its dest zone, so the hairpin rules matched the target's subnet alone. A client on any other profile, even one whose Access already permits it to reach the target's profile, sent its packet to the router's INPUT chain instead of the DNAT and got the router's own web UI. Found by running Synapse on a public domain behind StartWRT: the domain worked from the server's profile and from the Internet, but not from a phone on the guest profile.

What changed

  • Scope reflection to permitted zones (b5b6d6b8c). reflection_zone is now the target's zone plus every zone a config forwarding section already permits into it. Not every zone: each reflection zone gets a blanket ct status dnat accept ahead of its zone policy, so a reflected flow can never be rejected afterwards. The list is the only place to say no.
  • Only existing, non-masquerading zones (36be9d08c). fw4 treats one unknown name in the list as an invalid option and drops the whole redirect, WAN-side DNAT included. The list is filtered against the zones actually present, and WAN-like zones are excluded by their masq property rather than by the name wan.
  • Resync on profile changes and at boot (e329ca04e). The list is derived state, so a sync_reflection_zones pass now runs at the end of rewrite_firewall and delete_config, in published-ports set, in the automatic-forward apply_forward, and once at daemon start. Without it, deleting a profile left a dead zone name that killed the redirect, revoking Access left the profile hairpin-reachable, and a newly granted profile never joined. Automatic UPnP/PCP forwards get the same scoping through this pass.
  • Resolve the target's zone by address (c342e537e). The zone came from the neighbor table and fell back to lan for an offline device, which with scoped reflection computes the wrong permitted set. It is now resolved from the device's IPv4 against the profile subnets, with the static reservation covering offline devices. A device that still cannot be placed keeps dest 'lan' for the WAN DNAT but gets reflection '0'.
  • Source-restricted rules no longer hairpin. fw4's reflection rules drop src_ip, so a rule restricted to one public address was reachable at the WAN address by every device on the zone. Such rules are now written with reflection '0'. This is the ### Security entry in the changelog.
  • 84e55877c types FirewallRedirect.reflection as Option<bool> like the other fw4 booleans; bc9aad6fc trues up the docs wording.

Cross-zone reflection is DNAT-only by design: the reflection SNAT lands in the emitted zone's srcnat chain, which a hairpinned flow from another zone never enters, so the server sees the client's real address. Same-zone flows are SNATed to the router's zone address as before.

Verification

543 startwrt-core unit tests (7 new), make format-check clean.

Hardware bench on a K1 behind an upstream router (double NAT), deployed as a binary update, three profiles (Admin with the StartOS server, Guest with Access to Admin, IoT with no Access), manual TCP 443 rule to the server with the Remote Access override, public domain on the server's root CA, probed with curl --resolve <domain>:443:<router WAN address>:

Case Result
Rule carries reflection_zone 'lan' 'vlan_<guest>'; guest dstnat_ chain holds the reflection DNAT; IoT has no chain pass
Admin client hairpin pass, 302 from the service
Guest client hairpin (the reported bug) pass, 302 from the service
IoT client: router UI answers at the WAN address, direct access to the server rejected by zone policy pass
Source restricted to a /32: reflection '0', both dstnat_ chains empty, direct LAN access unaffected; restoring Source to Any brings the list and the hairpin back pass
Create a profile with Access: its zone joins the list. Delete it: zone leaves the list, fw4 check clean, WAN DNAT for 443 still rendered pass

Behind an upstream router only addresses on the router's own WAN interface hairpin, never the public IP; the docs now say so.

Merging with #3783

Rebasing this branch onto #3783's current tip is clean, and the combined tree builds. One test in this branch, apply_scopes_reflection_like_published_ports, uses LABEL_PCP, which #3783 renames to KIND_PCP, so whichever PR lands second renames that one token.

Docs and CHANGELOG.md (under the unreleased 1.1.0) are updated in the same change.

https://claude.ai/code/session_016bxSq9BshATVCVZy3eoKu9

Published-port redirects never set `reflection_zone`, so fw4 defaulted it
to the redirect's `dest` zone and emitted hairpin rules matching only the
target device's own subnet. A client on a different Security Profile —
one already permitted to forward into the target's zone — hit the
router's INPUT chain instead of the DNAT when it used the router's WAN
address.

Set `reflection_zone` to the target's zone plus every zone a `config
forwarding` section already permits to forward into it. Not every zone:
each reflection zone becomes the reflection redirect's `src`, which sets
fw4's `dflags.dnat` and emits a blanket `ct status dnat accept` into that
zone's forward chain ahead of the zone policy, so a reflected flow cannot
be rejected afterwards.

Also set `reflection '0'` on any port carrying a source restriction.
fw4 builds the reflection redirect fresh and copies neither `src_ip` nor
`src_mac` (`fw4.uc` ~2898), taking its saddr from the reflection zone's
subnets — so a source-restricted forward was reachable by hairpin from
any client in the reflection zone, including the same-subnet case that
already worked before this change.
`FirewallZone.masq`/`masq6`/`mtu_fix` are `Option<bool>` and serialize
as '1'/'0'; `reflection` is the same kind of fw4 boolean in the same
struct family, so give it the same type instead of the older
`Option<String>` idiom. The bytes written are unchanged.
`reflection_zones` copied `config forwarding` src names verbatim,
excluding only the literal "wan". fw4 parses each `reflection_zone`
entry as a zone reference: one name that is not a parsed zone fails the
whole option and drops the entire redirect — WAN-side DNAT included —
and a `src '*'` forwarding (legal fw4) would crash ruleset rendering
outright when fw4 dereferences the wildcard's subnets. Stock images
never write such forwardings, but an adopted config can carry them, and
a zone deleted after the list was written turns into exactly that
invalid name.

Filter the list against the `config zone` sections actually present,
and exclude WAN-like zones by property (`masq` set) rather than by the
name "wan"; the redirect's own src zone stays excluded as before. Also
write down why cross-zone hairpin is DNAT-only: the reflection SNAT
lands in the emitted zone's srcnat chain, which a hairpinned flow never
enters, so the server sees the client's real address and replies via
the router — intentional, not a missing rule.
`reflection_zone` was written only by `published-ports set`, but it is
derived from the `config forwarding` set, which changes underneath it:

- Deleting a profile removed its zone but left the name in every list
  carrying it, and fw4 treats one unknown name as an invalid option —
  dropping the whole redirect, WAN-side DNAT included. Deleting a guest
  profile could silently kill an admin-LAN published port from the
  Internet until some unrelated re-save rewrote the pp_ sections.
- Revoking a profile's Access left it in the lists, so its clients kept
  reaching the port via the WAN address — the exact reflect-then-reject
  hole the scoped list exists to prevent, with no rule able to stop it.
- A profile granted Access later (or created with it) was missing from
  the lists, so its clients kept hitting the original bug.

Add `sync_reflection_zones`, one pass re-deriving the list on every
redirect tagged `_pp_id` or `_apf_label` (skipping `reflection '0'`),
and run it wherever the inputs or the redirects change: at the end of
`rewrite_firewall` and `delete_config` (both already restart the
firewall afterwards), in `published-ports set` (replacing the inline
computation), in `apply_forward` — automatic UPnP/PCP forwards now
get the same scoped hairpin instead of fw4's dest-zone default, closing
the canonical StartOS case of a phone on another profile reaching a
UPnP-opened port by the server's public hostname — and once at boot,
so routers already carrying stale lists heal (reloading the firewall
only when the pass changed something).
`dest_zone` came from the neighbor table alone and fell back to "lan"
whenever the device had no entry — and a device that is offline (or
whose entry aged out) still passes validation, because its IPv4
resolves from the static reservation `set` itself creates. Since every
save rewrites all rules, toggling any port while one target was
offline silently rewrote that target's rule with the guessed zone;
with scoped reflection the wrong guess also computes the wrong
permitted set, e.g. handing an IoT profile with Access to the admin
LAN a hairpin route to a server that actually lives in a guest zone.

Resolve the zone from the device's IPv4 against the interface subnets
of the non-masquerading firewall zones (every profile is a /24 at its
gateway; the static reservation covers an offline device, and
configs-only mode too), keeping the neighbor-table result as a logged
cross-check only. A device that still cannot be placed keeps
`dest 'lan'` — the WAN-side DNAT stays as it was — but gets
`reflection '0'`: never hairpin into a guessed zone, and never refuse
the save over one offline device. `set` now parses "network" alongside
"firewall"/"dhcp"; the file is round-tripped unchanged.
- "every other profile that profile permits to reach it" read as
  though the target's profile grants the access; the Access setting
  lives on the *other* profile (see security-profiles.md), so say so.
- "answered by the router itself" becomes "the router answers instead
  of the device" — plainer.
- Reflection matches only addresses actually on the WAN interface:
  behind CGNAT or another upstream router the public IP a domain
  resolves to never hairpins, so one clause now says so before a
  support thread has to.
- Automatic (UPnP/PCP) forwards are scoped identically now, so the
  section says they are covered.
Resolving the zone by address alone dropped the neighbor-table result
that the previous code relied on, so a device with no IPv4 at all —
an IPv6-only target, which has neither a lease nor a reservation to
place it — fell to the "lan" guess even while the neighbor table knew
its zone. The IPv6 forward rule's `dest` then named the wrong zone and
fw4 bound it to the wrong egress interfaces. The same applied to an
IPv4 outside every profile subnet.

Pull the resolution into `device_zone`: the address decides when it
resolves (an offline device with a reservation still places, and a
save while one target is offline no longer rewrites its rule around a
guess), and the neighbor table places what the address cannot. Only a
device neither can place keeps the "lan" guess, and only that one is
never hairpinned. Tests run non-effectful, so the helper is what makes
the fallback testable.

Claude-Session: https://claude.ai/code/session_01Mx4wjVx7EC29aBsQmSt5xv
`sync_reflection_zones` left `reflection '0'` redirects untouched on the
grounds that they carry no list. fw4 validates every `reflection_zone`
name before it reads `reflection` or `enabled`, so a stale name on such
a redirect still drops the whole section, WAN-side DNAT included. Our
writer never emits that combination, but a hand-edited config can, and
the boot heal exists precisely for configs we did not write.

Empty the list on those redirects instead of skipping them; a redirect
that is already clean still counts as unchanged.

Claude-Session: https://claude.ai/code/session_01Mx4wjVx7EC29aBsQmSt5xv
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant