From nobody Thu Apr 23 19:30:17 2026 X-Original-To: dev-commits-src-all@mlmmj.nyi.freebsd.org Received: from mx1.freebsd.org (mx1.freebsd.org [IPv6:2610:1c1:1:606c::19:1]) by mlmmj.nyi.freebsd.org (Postfix) with ESMTP id 4g1mQQ4LVMz6bP5T for ; Thu, 23 Apr 2026 19:30:22 +0000 (UTC) (envelope-from git@FreeBSD.org) Received: from mxrelay.nyi.freebsd.org (mxrelay.nyi.freebsd.org [IPv6:2610:1c1:1:606c::19:3]) (using TLSv1.3 with cipher TLS_AES_256_GCM_SHA384 (256/256 bits) key-exchange X25519 server-signature RSA-PSS (4096 bits) server-digest SHA256 client-signature RSA-PSS (4096 bits) client-digest SHA256) (Client CN "mxrelay.nyi.freebsd.org", Issuer "R13" (not verified)) by mx1.freebsd.org (Postfix) with ESMTPS id 4g1mQQ2Ht5z3mf6 for ; Thu, 23 Apr 2026 19:30:22 +0000 (UTC) (envelope-from git@FreeBSD.org) DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=freebsd.org; s=dkim; t=1776972622; h=from:from:reply-to:subject:subject:date:date:message-id:message-id: to:to:cc:mime-version:mime-version:content-type:content-type: content-transfer-encoding:content-transfer-encoding; bh=0zeIay2TlbqMVaP5687sZPaaX3j9FhLWIRSwESzBRC0=; b=nLKWfqpkCZWtI4EwwbTEbjsrivEO6BgSUcfadfEk0mEeonApYp19ssLV5eExMj56vtc83C dcTeG1NTq86CBH361fDW0/c8gGrtN505sP59NUmL7KTkWIuTeRoI3lsEhlHOm+uF2JWq9e 8jL4wl76WScJYwk43pFf9uh0m/WF95ScLG0giZzH7Ff3rcfhqxFFM1d15tvzHStN/1+46C hLs8YOl1a90gBW4QDES6Fq3D3EsKa3virUDEYPI+IMnJdasKJ6dXAnM587u/ZEImGYoB10 3/XhaFG+Lq9vC1tcs94p/M6EbuQu/Hl1q1v8pOSIVZ6A09ejoSNxNkhPAoBubg== ARC-Seal: i=1; s=dkim; d=freebsd.org; t=1776972622; a=rsa-sha256; cv=none; b=yT7JNlfipWYCiCBnR6xRT4cRLiaq800QzMuM4XBzVEWvo9EhoQqPDeYnZ7oWpsYhWdrB8U ZG/xEQOFJRUQTcm79Hh7kuFytPky3VakMKrB39KLxvVXnGZE1YtZswZwjYidSL1Y9T4D+1 qb0e8frZRrL7jI96erJGdsYm4EkMwPoqie2Sd5IPPkZnau0PsRUUxF4iq/eEc8gXz5snLk iG8RaHKS8+/dtRnrR0+r02Qha9OzaOz1o8aDGjurVFs8AafNwIKiPgIRlylKZwmzeGIs3S +M0Fiqb3H2SUM7CP84NQwBEaN6lH0DGGzqnM8xDyZjrmgMfBECDqFA+Asei1kg== ARC-Authentication-Results: i=1; mx1.freebsd.org; none ARC-Message-Signature: i=1; a=rsa-sha256; c=relaxed/relaxed; d=freebsd.org; s=dkim; t=1776972622; h=from:from:reply-to:subject:subject:date:date:message-id:message-id: to:to:cc:mime-version:mime-version:content-type:content-type: content-transfer-encoding:content-transfer-encoding; bh=0zeIay2TlbqMVaP5687sZPaaX3j9FhLWIRSwESzBRC0=; b=Sq7Ry/RNmb0KpxUQnHIF71b9jZA4+c1VnDSBHJuZrqOeob9eGgPzSqgBxJqHHucx7HZ2Qk //xk9KkgkLktdUXeQKV1/Tmo2T4mEVB1M2v+IXxt1/wBIIDo59NMUumxKMAML+DcOzc7io 4EPeDQDbLOlDZomhWEDExpMEAzhEV7CLv6vYyN3HGkoaJ7Ynvy4U8PDtvO14LzEPjsa6tl sI23v2GWojBecUc9+GziIKbzyh5XthFDwD9v53CGIrAmpCH7gqttuqrDYn1oP65FMbgqC2 R4x86GJXFTf9LsC1BOdzgvVIB5GBXHpFqSPPoMTGcPorLTH5sQ2xHLqj2+CgUg== Received: from gitrepo.freebsd.org (gitrepo.freebsd.org [IPv6:2610:1c1:1:6068::e6a:5]) by mxrelay.nyi.freebsd.org (Postfix) with ESMTP id 4g1mQQ11c7zVZq for ; Thu, 23 Apr 2026 19:30:22 +0000 (UTC) (envelope-from git@FreeBSD.org) Received: from git (uid 1279) (envelope-from git@FreeBSD.org) id 1e33a by gitrepo.freebsd.org (DragonFly Mail Agent v0.13+ on gitrepo.freebsd.org); Thu, 23 Apr 2026 19:30:17 +0000 To: src-committers@FreeBSD.org, dev-commits-src-all@FreeBSD.org, dev-commits-src-main@FreeBSD.org From: R. Christian McDonald Subject: git: 4578c15ab914 - main - pf: Document broadcast/multicast forwarding through route-to List-Id: Commit messages for all branches of the src repository List-Archive: https://lists.freebsd.org/archives/dev-commits-src-all List-Help: List-Post: List-Subscribe: List-Unsubscribe: X-BeenThere: dev-commits-src-all@freebsd.org Sender: owner-dev-commits-src-all@FreeBSD.org MIME-Version: 1.0 Content-Type: text/plain; charset=utf-8 Content-Transfer-Encoding: 8bit X-Git-Committer: rcm X-Git-Repository: src X-Git-Refname: refs/heads/main X-Git-Reftype: branch X-Git-Commit: 4578c15ab914b6d71e93147f1b2e9b8048f394c8 Auto-Submitted: auto-generated Date: Thu, 23 Apr 2026 19:30:17 +0000 Message-Id: <69ea7349.1e33a.7f5a58cd@gitrepo.freebsd.org> The branch main has been updated by rcm: URL: https://cgit.FreeBSD.org/src/commit/?id=4578c15ab914b6d71e93147f1b2e9b8048f394c8 commit 4578c15ab914b6d71e93147f1b2e9b8048f394c8 Author: R. Christian McDonald AuthorDate: 2026-04-23 18:52:32 +0000 Commit: R. Christian McDonald 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"