Skip site navigation (1)Skip section navigation (2)
Date:      Thu, 23 Apr 2026 19:30:17 +0000
From:      R. Christian McDonald <rcm@FreeBSD.org>
To:        src-committers@FreeBSD.org, dev-commits-src-all@FreeBSD.org, dev-commits-src-main@FreeBSD.org
Subject:   git: 4578c15ab914 - main - pf: Document broadcast/multicast forwarding through route-to
Message-ID:  <69ea7349.1e33a.7f5a58cd@gitrepo.freebsd.org>

index | next in thread | raw e-mail

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"


home | help

Want to link to this message? Use this
URL: <https://mail-archive.FreeBSD.org/cgi/mid.cgi?69ea7349.1e33a.7f5a58cd>