git: 4578c15ab914 - main - pf: Document broadcast/multicast forwarding through route-to
- Go to: [ bottom of page ] [ top of archives ] [ this month ]
Date: Thu, 23 Apr 2026 19:30:17 UTC
The branch main has been updated by rcm:
URL: https://cgit.FreeBSD.org/src/commit/?id=4578c15ab914b6d71e93147f1b2e9b8048f394c8
commit 4578c15ab914b6d71e93147f1b2e9b8048f394c8
Author: R. Christian McDonald <rcm@FreeBSD.org>
AuthorDate: 2026-04-23 18:52:32 +0000
Commit: R. Christian McDonald <rcm@FreeBSD.org>
CommitDate: 2026-04-23 19:23:59 +0000
pf: Document broadcast/multicast forwarding through route-to
pf_route() and pf_route6() forward broadcast and multicast traffic
when a route-to rule matches, without any check against the output
interface's broadcast domain. This is a deliberate property of the
route option code path, but it is not documented and the workaround
is non-obvious.
Document the behavior in pf.conf(5) with example block-out rules on
the target interface, scoped with the received-on qualifier so that
only forwarded traffic is dropped while the router's own broadcast
and multicast traffic continues to pass.
Add regression tests covering the full broadcast/multicast and
forwarded/local matrix on both IPv4 and IPv6.
Reviewed by: glebius, kp
Approved by: kp (mentor)
MFC after: 1 week
Sponsored by: Rubicon Communications, LLC ("Netgate")
Differential Revision: https://reviews.freebsd.org/D56559
---
share/man/man5/pf.conf.5 | 46 +++++-
tests/sys/netpfil/pf/route_to.sh | 346 +++++++++++++++++++++++++++++++++++++++
2 files changed, 391 insertions(+), 1 deletion(-)
diff --git a/share/man/man5/pf.conf.5 b/share/man/man5/pf.conf.5
index 707053233e5a..978634e8afb7 100644
--- a/share/man/man5/pf.conf.5
+++ b/share/man/man5/pf.conf.5
@@ -27,7 +27,7 @@
.\" ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
.\" POSSIBILITY OF SUCH DAMAGE.
.\"
-.Dd January 16, 2026
+.Dd April 22, 2026
.Dt PF.CONF 5
.Os
.Sh NAME
@@ -2431,6 +2431,50 @@ option creates a duplicate of the packet and routes it like
.Ar route-to .
The original packet gets routed as it normally would.
.El
+.Pp
+Unlike the kernel's normal forwarding path, the route option forwarding
+path does not drop broadcast or multicast traffic when the output
+interface has been overridden by a route option.
+If a
+.Ar route-to ,
+.Ar reply-to ,
+or
+.Ar dup-to
+rule matches traffic destined to a broadcast address (either the
+limited broadcast or a subnet-directed broadcast) or to an IPv4/IPv6
+multicast address, the packet is forwarded out the specified interface,
+which may cross broadcast domains.
+.Pp
+Rulesets that use
+.Ar route-to ,
+.Ar reply-to ,
+or
+.Ar dup-to
+with a permissive destination
+.Po e.g.\&
+.Li from any to any
+.Pc
+can plug this leak with explicit
+.Ar block out
+rules on the route option's target interface.
+To avoid blocking the router's own broadcast or multicast traffic,
+scope the block rules to forwarded packets with the
+.Ar received-on any
+qualifier.
+For example, assuming
+.Li $wan
+is the
+.Ar route-to
+target interface:
+.Bd -literal -offset indent
+block out quick on $wan inet from any to 255.255.255.255 received-on any
+block out quick on $wan inet from any to ($wan:broadcast) received-on any
+block out quick on $wan inet from any to 224.0.0.0/4 received-on any
+block out quick on $wan inet6 from any to ff00::/8 received-on any
+.Ed
+.Pp
+One block-out rule set is needed per interface that may be used as
+a route option target.
.Sh POOL OPTIONS
For
.Ar nat
diff --git a/tests/sys/netpfil/pf/route_to.sh b/tests/sys/netpfil/pf/route_to.sh
index 13b60c8f80bc..7bf4b11788d8 100644
--- a/tests/sys/netpfil/pf/route_to.sh
+++ b/tests/sys/netpfil/pf/route_to.sh
@@ -97,6 +97,80 @@ pf_map_addr_common()
done
}
+# Setup the environment for bcast_* and mcast_* tests.
+rt_leak_setup()
+{
+ pft_init
+
+ epair_lan=$(vnet_mkepair)
+ epair_wan=$(vnet_mkepair)
+
+ # client (lan)
+ vnet_mkjail client ${epair_lan}a
+ jexec client ifconfig ${epair_lan}a 192.0.2.2/24 up
+ jexec client ifconfig ${epair_lan}a inet6 2001:db8:1::2/64 no_dad up
+ jexec client route add default 192.0.2.1
+ jexec client route add -inet6 default 2001:db8:1::1
+
+ # router
+ vnet_mkjail router ${epair_lan}b ${epair_wan}a
+ jexec router ifconfig ${epair_lan}b 192.0.2.1/24 up
+ jexec router ifconfig ${epair_lan}b inet6 2001:db8:1::1/64 no_dad up
+ jexec router ifconfig ${epair_wan}a 198.51.100.1/24 up
+ jexec router ifconfig ${epair_wan}a inet6 2001:db8:2::1/64 no_dad up
+ jexec router sysctl net.inet.ip.forwarding=1
+ jexec router sysctl net.inet6.ip6.forwarding=1
+ jexec router route add 255.255.255.255 -iface ${epair_wan}a
+ jexec router route add 224.0.0.0/4 -iface ${epair_wan}a
+ jexec router route add -inet6 ff00::/8 -iface ${epair_wan}a
+ jexec router pfctl -e
+
+ # wan
+ vnet_mkjail wan ${epair_wan}b
+ jexec wan ifconfig ${epair_wan}b 198.51.100.2/24 up
+ jexec wan ifconfig ${epair_wan}b inet6 2001:db8:2::2/64 no_dad up
+ jexec wan pfctl -e
+ pft_set_rules wan \
+ "pass" \
+ "pass in on ${epair_wan}b inet proto udp from any to any port 5000 label rt_leak_probe" \
+ "pass in on ${epair_wan}b inet6 proto udp from any to any port 5000 label rt_leak_probe"
+
+ # Sanity check before proceeding.
+ atf_check -s exit:0 -o ignore jexec client ping -c 1 -t 1 192.0.2.1
+}
+
+# Install the router ruleset for bcast_* and mcast_* tests.
+rt_leak_install_rules()
+{
+ pft_set_rules router \
+ "block all" \
+ "pass out keep state" \
+ "pass in on ${epair_lan}b inet proto icmp all keep state" \
+ "pass in on ${epair_lan}b inet6 proto icmp6 all keep state" \
+ "pass inet6 proto icmp6 icmp6-type { neighbrsol, neighbradv, routersol, routeradv } keep state" \
+ "$@"
+}
+
+# Packet count observed by the probe rule in the wan jail.
+rt_leak_probe_pkts()
+{
+ jexec wan pfctl -sl | awk '$1 == "rt_leak_probe" { print $3 }'
+}
+
+# Send one UDP datagram from $1 (a jail name) to $2 (a destination address).
+rt_leak_send()
+{
+ atf_check -s exit:0 -o ignore jexec "$1" python3 -c "
+import socket
+dst = '$2'
+af = socket.AF_INET6 if ':' in dst else socket.AF_INET
+s = socket.socket(af, socket.SOCK_DGRAM)
+if af == socket.AF_INET:
+ s.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
+s.sendto(b'rt_leak_probe', (dst, 5000))
+"
+}
+
atf_test_case "v4" "cleanup"
v4_head()
{
@@ -1647,6 +1721,270 @@ prefer_ipv6_nexthop_ipv4_random_prefix_ipv6_cleanup()
pft_cleanup
}
+atf_test_case "bcast_directed_forwarded" "cleanup"
+bcast_directed_forwarded_head()
+{
+ atf_set descr 'Forwarded subnet directed broadcast is blocked by a received-on-scoped block-out rule'
+ atf_set require.user root
+ atf_set require.progs python3
+}
+bcast_directed_forwarded_body()
+{
+ rt_leak_setup
+
+ # pf_route() does not guard against forwarding broadcast traffic
+ # across broadcast domains. Operators who use route-to with a
+ # permissive destination must plug the leak manually with a
+ # block-out rule matching the target interface's broadcast
+ # address. Scope the rule to forwarded traffic with received-on
+ # so the router's own broadcasts are *not* affected.
+ rt_leak_install_rules \
+ "block out quick on ${epair_wan}a inet from any to (${epair_wan}a:broadcast) received-on any" \
+ "pass in on ${epair_lan}b route-to (${epair_wan}a 198.51.100.2) inet proto udp from any to any keep state"
+
+ rt_leak_send client 198.51.100.255
+
+ pkts=$(rt_leak_probe_pkts)
+ if [ "${pkts:-0}" -ne 0 ]; then
+ jexec wan pfctl -vvsr
+ atf_fail "directed broadcast leaked to wan despite block-out rule (${pkts} packet(s))"
+ fi
+}
+bcast_directed_forwarded_cleanup()
+{
+ pft_cleanup
+}
+
+atf_test_case "bcast_limited_forwarded" "cleanup"
+bcast_limited_forwarded_head()
+{
+ atf_set descr 'Forwarded limited broadcast is blocked by a received-on-scoped block-out rule'
+ atf_set require.user root
+ atf_set require.progs python3
+}
+bcast_limited_forwarded_body()
+{
+ rt_leak_setup
+
+ # pf_route() does not guard against forwarding broadcast traffic
+ # across broadcast domains. Operators who use route-to with a
+ # permissive destination must plug the leak manually with a
+ # block-out rule matching 255.255.255.255 on the route-to target
+ # interface. Scope the rule to forwarded traffic with received-on
+ # so the router's own broadcasts are *not* affected.
+ rt_leak_install_rules \
+ "block out quick on ${epair_wan}a inet from any to 255.255.255.255 received-on any" \
+ "pass in on ${epair_lan}b route-to (${epair_wan}a 198.51.100.2) inet proto udp from any to any keep state"
+
+ rt_leak_send client 255.255.255.255
+
+ pkts=$(rt_leak_probe_pkts)
+ if [ "${pkts:-0}" -ne 0 ]; then
+ jexec wan pfctl -vvsr
+ atf_fail "limited broadcast leaked to wan despite block-out rule (${pkts} packet(s))"
+ fi
+}
+bcast_limited_forwarded_cleanup()
+{
+ pft_cleanup
+}
+
+atf_test_case "bcast_directed_local" "cleanup"
+bcast_directed_local_head()
+{
+ atf_set descr 'Router-originated directed broadcast is not blocked by a received-on-scoped rule'
+ atf_set require.user root
+ atf_set require.progs python3
+}
+bcast_directed_local_body()
+{
+ rt_leak_setup
+
+ # Install the same ruleset used by bcast_{directed,limited}_forwarded.
+ # The received-on qualifier should restrict the block to forwarded
+ # packets, leaving router-originated broadcasts to pass normally.
+ rt_leak_install_rules \
+ "block out quick on ${epair_wan}a inet from any to (${epair_wan}a:broadcast) received-on any" \
+ "block out quick on ${epair_wan}a inet from any to 255.255.255.255 received-on any" \
+ "pass in on ${epair_lan}b route-to (${epair_wan}a 198.51.100.2) inet proto udp from any to any keep state"
+
+ # Router emits a directed broadcast on its own wan subnet.
+ rt_leak_send router 198.51.100.255
+
+ pkts=$(rt_leak_probe_pkts)
+ if [ "${pkts:-0}" -eq 0 ]; then
+ jexec router pfctl -vvsr
+ atf_fail "router-originated broadcast was incorrectly blocked by received-on-scoped rule"
+ fi
+}
+bcast_directed_local_cleanup()
+{
+ pft_cleanup
+}
+
+atf_test_case "bcast_limited_local" "cleanup"
+bcast_limited_local_head()
+{
+ atf_set descr 'Router-originated limited broadcast is not blocked by a received-on-scoped rule'
+ atf_set require.user root
+ atf_set require.progs python3
+}
+bcast_limited_local_body()
+{
+ rt_leak_setup
+
+ # Install the same ruleset used by bcast_{directed,limited}_forwarded.
+ # The received-on qualifier should restrict the block to forwarded
+ # packets, leaving router-originated broadcasts to pass normally.
+ rt_leak_install_rules \
+ "block out quick on ${epair_wan}a inet from any to (${epair_wan}a:broadcast) received-on any" \
+ "block out quick on ${epair_wan}a inet from any to 255.255.255.255 received-on any" \
+ "pass in on ${epair_lan}b route-to (${epair_wan}a 198.51.100.2) inet proto udp from any to any keep state"
+
+ # Router emits a limited broadcast on its own wan subnet.
+ rt_leak_send router 255.255.255.255
+
+ pkts=$(rt_leak_probe_pkts)
+ if [ "${pkts:-0}" -eq 0 ]; then
+ jexec router pfctl -vvsr
+ atf_fail "router-originated limited broadcast was incorrectly blocked by received-on-scoped rule"
+ fi
+}
+bcast_limited_local_cleanup()
+{
+ pft_cleanup
+}
+
+atf_test_case "mcast_v4_forwarded" "cleanup"
+mcast_v4_forwarded_head()
+{
+ atf_set descr 'Forwarded IPv4 multicast is blocked by a received-on-scoped block-out rule'
+ atf_set require.user root
+ atf_set require.progs python3
+}
+mcast_v4_forwarded_body()
+{
+ rt_leak_setup
+
+ # pf_route() does not guard against forwarding multicast traffic
+ # across broadcast domains. An IPv4 multicast block-out rule on
+ # the route-to target interface plugs the leak. Scope the rule
+ # to forwarded traffic with received-on so the router's own
+ # multicast is *not* affected.
+ rt_leak_install_rules \
+ "block out quick on ${epair_wan}a inet from any to 224.0.0.0/4 received-on any" \
+ "pass in on ${epair_lan}b route-to (${epair_wan}a 198.51.100.2) inet proto udp from any to any keep state"
+
+ rt_leak_send client 224.0.0.1
+
+ pkts=$(rt_leak_probe_pkts)
+ if [ "${pkts:-0}" -ne 0 ]; then
+ jexec wan pfctl -vvsr
+ atf_fail "IPv4 multicast leaked to wan despite block-out rule (${pkts} packet(s))"
+ fi
+}
+mcast_v4_forwarded_cleanup()
+{
+ pft_cleanup
+}
+
+atf_test_case "mcast_v6_forwarded" "cleanup"
+mcast_v6_forwarded_head()
+{
+ atf_set descr 'Forwarded IPv6 multicast is blocked by a received-on-scoped block-out rule'
+ atf_set require.user root
+ atf_set require.progs python3
+}
+mcast_v6_forwarded_body()
+{
+ rt_leak_setup
+
+ # pf_route6() does not guard against forwarding multicast traffic
+ # across broadcast domains. An IPv6 multicast block-out rule on
+ # the route-to target interface plugs the leak. Scope the rule
+ # to forwarded traffic with received-on so the router's own
+ # multicast is *not* affected.
+ rt_leak_install_rules \
+ "block out quick on ${epair_wan}a inet6 from any to ff00::/8 received-on any" \
+ "pass in on ${epair_lan}b route-to (${epair_wan}a 2001:db8:2::2) inet6 proto udp from any to any keep state"
+
+ rt_leak_send client ff0e::1
+
+ pkts=$(rt_leak_probe_pkts)
+ if [ "${pkts:-0}" -ne 0 ]; then
+ jexec wan pfctl -vvsr
+ atf_fail "IPv6 multicast leaked to wan despite block-out rule (${pkts} packet(s))"
+ fi
+}
+mcast_v6_forwarded_cleanup()
+{
+ pft_cleanup
+}
+
+atf_test_case "mcast_v4_local" "cleanup"
+mcast_v4_local_head()
+{
+ atf_set descr 'Router-originated IPv4 multicast is not blocked by a received-on-scoped rule'
+ atf_set require.user root
+ atf_set require.progs python3
+}
+mcast_v4_local_body()
+{
+ rt_leak_setup
+
+ # Install the same ruleset used by mcast_v4_forwarded. The received-on
+ # qualifier should restrict the block to forwarded packets, leaving
+ # router-originated broadcasts to pass normally.
+ rt_leak_install_rules \
+ "block out quick on ${epair_wan}a inet from any to 224.0.0.0/4 received-on any" \
+ "pass in on ${epair_lan}b route-to (${epair_wan}a 198.51.100.2) inet proto udp from any to any keep state"
+
+ # Router emits an IPv4 multicast datagram from its own stack.
+ rt_leak_send router 224.0.0.1
+
+ pkts=$(rt_leak_probe_pkts)
+ if [ "${pkts:-0}" -eq 0 ]; then
+ jexec router pfctl -vvsr
+ atf_fail "router-originated multicast was incorrectly blocked by received-on-scoped rule"
+ fi
+}
+mcast_v4_local_cleanup()
+{
+ pft_cleanup
+}
+
+atf_test_case "mcast_v6_local" "cleanup"
+mcast_v6_local_head()
+{
+ atf_set descr 'Router-originated IPv6 multicast is not blocked by a received-on-scoped rule'
+ atf_set require.user root
+ atf_set require.progs python3
+}
+mcast_v6_local_body()
+{
+ rt_leak_setup
+
+ # Install the same ruleset used by mcast_v6_forwarded. The received-on
+ # qualifier should restrict the block to forwarded packets, leaving
+ # router-originated broadcasts to pass normally.
+ rt_leak_install_rules \
+ "block out quick on ${epair_wan}a inet6 from any to ff00::/8 received-on any" \
+ "pass in on ${epair_lan}b route-to (${epair_wan}a 2001:db8:2::2) inet6 proto udp from any to any keep state"
+
+ # Router emits an IPv6 multicast datagram from its own stack.
+ rt_leak_send router ff0e::1
+
+ pkts=$(rt_leak_probe_pkts)
+ if [ "${pkts:-0}" -eq 0 ]; then
+ jexec router pfctl -vvsr
+ atf_fail "router-originated IPv6 multicast was incorrectly blocked by received-on-scoped rule"
+ fi
+}
+mcast_v6_local_cleanup()
+{
+ pft_cleanup
+}
+
atf_init_test_cases()
{
atf_add_test_case "v4"
@@ -1666,6 +2004,14 @@ atf_init_test_cases()
atf_add_test_case "sticky"
atf_add_test_case "ttl"
atf_add_test_case "empty_pool"
+ atf_add_test_case "bcast_directed_forwarded"
+ atf_add_test_case "bcast_directed_local"
+ atf_add_test_case "bcast_limited_forwarded"
+ atf_add_test_case "bcast_limited_local"
+ atf_add_test_case "mcast_v4_forwarded"
+ atf_add_test_case "mcast_v4_local"
+ atf_add_test_case "mcast_v6_forwarded"
+ atf_add_test_case "mcast_v6_local"
# Tests for pf_map_addr() without prefer-ipv6-nexthop
atf_add_test_case "table_loop"
atf_add_test_case "roundrobin"