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
This commit is contained in:
R. Christian McDonald
2026-04-23 14:52:32 -04:00
parent aad4fec5d7
commit 4578c15ab9
2 changed files with 391 additions and 1 deletions
+45 -1
View File
@@ -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
+346
View File
@@ -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"