git: ad6562ec858f - main - pf: Don't pfsync states with unrecoverable routing information

From: Kajetan Staszkiewicz <ks_at_FreeBSD.org>
Date: Thu, 05 Dec 2024 22:09:33 UTC
The branch main has been updated by ks:

URL: https://cgit.FreeBSD.org/src/commit/?id=ad6562ec858fffbed83560ed3f79375a44be2705

commit ad6562ec858fffbed83560ed3f79375a44be2705
Author:     Kajetan Staszkiewicz <ks@FreeBSD.org>
AuthorDate: 2024-11-29 22:20:14 +0000
Commit:     Kajetan Staszkiewicz <ks@FreeBSD.org>
CommitDate: 2024-12-05 22:03:12 +0000

    pf: Don't pfsync states with unrecoverable routing information
    
    States created by route-to rules can't be trusted when received with
    pfsync version 1301 as they lack the rt and rt_kif information. They
    are imported, though, and pf_route() function attempts to recover
    the missing information for every forwarded packet.
    
    Move the recovery operation to pfsync_state_import() so that it's
    performed only once and if it's impossible don't import the state.
    Add an additional check for cases when recovery might produce wrong
    results.
    
    Reviewed by:            kp
    Approved by:            kp (mentor)
    Sponsored by:           InnoGames GmbH
    Differential Revision:  https://reviews.freebsd.org/D47906
---
 sys/netpfil/pf/if_pfsync.c     |  90 ++++++++++++---
 sys/netpfil/pf/pf.c            |  78 ++++---------
 tests/sys/netpfil/pf/pfsync.sh | 256 ++++++++++++++++++++++++++++++++++++-----
 3 files changed, 323 insertions(+), 101 deletions(-)

diff --git a/sys/netpfil/pf/if_pfsync.c b/sys/netpfil/pf/if_pfsync.c
index 8dd3d875dc0b..5923675ff144 100644
--- a/sys/netpfil/pf/if_pfsync.c
+++ b/sys/netpfil/pf/if_pfsync.c
@@ -110,6 +110,8 @@
 
 #include <netpfil/pf/pfsync_nv.h>
 
+#define	DPFPRINTF(n, x)	if (V_pf_status.debug >= (n)) printf x
+
 struct pfsync_bucket;
 struct pfsync_softc;
 
@@ -521,10 +523,13 @@ pfsync_state_import(union pfsync_state_union *sp, int flags, int msg_version)
 #endif
 	struct pfsync_state_key *kw, *ks;
 	struct pf_kstate	*st = NULL;
-	struct pf_state_key *skw = NULL, *sks = NULL;
-	struct pf_krule *r = NULL;
-	struct pfi_kkif	*kif;
-	int error;
+	struct pf_state_key	*skw = NULL, *sks = NULL;
+	struct pf_krule		*r = NULL;
+	struct pfi_kkif		*kif;
+	struct pfi_kkif		*rt_kif = NULL;
+	struct pf_kpooladdr	*rpool_first;
+	int			 error;
+	uint8_t			 rt = 0;
 
 	PF_RULES_RASSERT();
 
@@ -556,6 +561,67 @@ pfsync_state_import(union pfsync_state_union *sp, int flags, int msg_version)
 	else
 		r = &V_pf_default_rule;
 
+	/*
+	 * Check routing interface early on. Do it before allocating memory etc.
+	 * because there is a high chance there will be a lot more such states.
+	 */
+	switch (msg_version) {
+	case PFSYNC_MSG_VERSION_1301:
+		/*
+		 * On FreeBSD <= 13 the routing interface and routing operation
+		 * are not sent over pfsync. If the ruleset is identical,
+		 * though, we might be able to recover the routing information
+		 * from the local ruleset.
+		 */
+		if (r != &V_pf_default_rule) {
+			/*
+			 * The ruleset is identical, try to recover. If the rule
+			 * has a redirection pool with a single interface, there
+			 * is a chance that this interface is identical as on
+			 * the pfsync peer. If there's more than one interface,
+			 * give up, as we can't be sure that we will pick the
+			 * same one as the pfsync peer did.
+			 */
+			rpool_first = TAILQ_FIRST(&(r->rpool.list));
+			if ((rpool_first == NULL) ||
+			    (TAILQ_NEXT(rpool_first, entries) != NULL)) {
+				DPFPRINTF(PF_DEBUG_MISC,
+				    ("%s: can't recover routing information "
+				    "because of empty or bad redirection pool\n",
+				    __func__));
+				return ((flags & PFSYNC_SI_IOCTL) ? EINVAL : 0);
+			}
+			rt = r->rt;
+			rt_kif = rpool_first->kif;
+		} else if (!PF_AZERO(&sp->pfs_1301.rt_addr, sp->pfs_1301.af)) {
+			/*
+			 * Ruleset different, routing *supposedly* requested,
+			 * give up on recovering.
+			 */
+			DPFPRINTF(PF_DEBUG_MISC,
+			    ("%s: can't recover routing information "
+			    "because of different ruleset\n", __func__));
+			return ((flags & PFSYNC_SI_IOCTL) ? EINVAL : 0);
+		}
+	break;
+	case PFSYNC_MSG_VERSION_1400:
+		/*
+		 * On FreeBSD 14 and above we're not taking any chances.
+		 * We use the information synced to us.
+		 */
+		if (sp->pfs_1400.rt) {
+			rt_kif = pfi_kkif_find(sp->pfs_1400.rt_ifname);
+			if (rt_kif == NULL) {
+				DPFPRINTF(PF_DEBUG_MISC,
+				    ("%s: unknown route interface: %s\n",
+				    __func__, sp->pfs_1400.rt_ifname));
+				return ((flags & PFSYNC_SI_IOCTL) ? EINVAL : 0);
+			}
+			rt = sp->pfs_1400.rt;
+		}
+	break;
+	}
+
 	if ((r->max_states &&
 	    counter_u64_fetch(r->states_cur) >= r->max_states))
 		goto cleanup;
@@ -629,6 +695,9 @@ pfsync_state_import(union pfsync_state_union *sp, int flags, int msg_version)
 	st->act.log = sp->pfs_1301.log;
 	st->timeout = sp->pfs_1301.timeout;
 
+	st->act.rt = rt;
+	st->act.rt_kif = rt_kif;
+
 	switch (msg_version) {
 		case PFSYNC_MSG_VERSION_1301:
 			st->state_flags = sp->pfs_1301.state_flags;
@@ -680,17 +749,6 @@ pfsync_state_import(union pfsync_state_union *sp, int flags, int msg_version)
 			st->act.max_mss = ntohs(sp->pfs_1400.max_mss);
 			st->act.set_prio[0] = sp->pfs_1400.set_prio[0];
 			st->act.set_prio[1] = sp->pfs_1400.set_prio[1];
-			st->act.rt = sp->pfs_1400.rt;
-			if (st->act.rt && (st->act.rt_kif = pfi_kkif_find(sp->pfs_1400.rt_ifname)) == NULL) {
-				if (V_pf_status.debug >= PF_DEBUG_MISC)
-					printf("%s: unknown route interface: %s\n",
-					    __func__, sp->pfs_1400.rt_ifname);
-				if (flags & PFSYNC_SI_IOCTL)
-					error = EINVAL;
-				else
-					error = 0;
-				goto cleanup_keys;
-			}
 			break;
 		default:
 			panic("%s: Unsupported pfsync_msg_version %d",
@@ -737,7 +795,7 @@ pfsync_state_import(union pfsync_state_union *sp, int flags, int msg_version)
 
 cleanup:
 	error = ENOMEM;
-cleanup_keys:
+
 	if (skw == sks)
 		sks = NULL;
 	uma_zfree(V_pf_state_key_z, skw);
diff --git a/sys/netpfil/pf/pf.c b/sys/netpfil/pf/pf.c
index 8d9d595dc1ec..a0c9a92c6b84 100644
--- a/sys/netpfil/pf/pf.c
+++ b/sys/netpfil/pf/pf.c
@@ -7690,14 +7690,14 @@ pf_route(struct mbuf **m, struct pf_krule *r, struct ifnet *oifp,
 	struct mbuf		*m0, *m1, *md;
 	struct sockaddr_in	dst;
 	struct ip		*ip;
-	struct ifnet		*ifp = NULL;
-	struct pf_addr		 naddr;
+	struct ifnet		*ifp;
 	int			 error = 0;
 	uint16_t		 ip_len, ip_off;
 	uint16_t		 tmp;
 	int			 r_dir;
 
-	KASSERT(m && *m && r && oifp, ("%s: invalid parameters", __func__));
+	KASSERT(m && *m && r && oifp && pd->act.rt_kif,
+	    ("%s: invalid parameters", __func__));
 
 	SDT_PROBE4(pf, ip, route_to, entry, *m, pd, s, oifp);
 
@@ -7720,13 +7720,15 @@ pf_route(struct mbuf **m, struct pf_krule *r, struct ifnet *oifp,
 		goto bad_locked;
 	}
 
+	if ((ifp = pd->act.rt_kif->pfik_ifp) == NULL) {
+		m0 = *m;
+		*m = NULL;
+		SDT_PROBE1(pf, ip, route_to, drop, __LINE__);
+		goto bad_locked;
+	}
+
 	if (pd->act.rt == PF_DUPTO) {
 		if ((pd->pf_mtag->flags & PF_MTAG_FLAG_DUPLICATED)) {
-			ifp = pd->act.rt_kif ? pd->act.rt_kif->pfik_ifp : NULL;
-			/* If pfsync'd from FreeBSD < 14 */
-			if (ifp == NULL && r->rpool.cur != NULL)
-				ifp = r->rpool.cur->kif ?
-				    r->rpool.cur->kif->pfik_ifp : NULL;
 			if (s != NULL) {
 				PF_STATE_UNLOCK(s);
 			}
@@ -7764,36 +7766,18 @@ pf_route(struct mbuf **m, struct pf_krule *r, struct ifnet *oifp,
 	dst.sin_len = sizeof(dst);
 	dst.sin_addr = ip->ip_dst;
 	dst.sin_addr.s_addr = pd->act.rt_addr.v4.s_addr;
-	ifp = pd->act.rt_kif ? pd->act.rt_kif->pfik_ifp : NULL;
-
-	bzero(&naddr, sizeof(naddr));
 
 	if (s != NULL){
-		struct pfi_kkif *kif;
-
-		kif = pd->act.rt_kif;
-		/* If pfsync'd from FreeBSD < 14 */
-		if (ifp == NULL && r->rpool.cur != NULL) {
-			ifp = r->rpool.cur->kif ?
-			    r->rpool.cur->kif->pfik_ifp : NULL;
-			kif = r->rpool.cur->kif;
-		}
-		if (ifp != NULL && kif != NULL &&
-		    r->rule_flag & PFRULE_IFBOUND &&
+		if (r->rule_flag & PFRULE_IFBOUND &&
 		    pd->act.rt == PF_REPLYTO &&
 		    s->kif == V_pfi_all) {
-			s->kif = kif;
+			s->kif = pd->act.rt_kif;
 			s->orig_kif = oifp->if_pf_kif;
 		}
 
 		PF_STATE_UNLOCK(s);
 	}
 
-	if (ifp == NULL) {
-		SDT_PROBE1(pf, ip, route_to, drop, __LINE__);
-		goto bad;
-	}
-
 	if (pd->dir == PF_IN) {
 		if (pf_test(AF_INET, PF_OUT, PFIL_FWD, ifp, &m0, inp,
 		    &pd->act) != PF_PASS) {
@@ -7943,10 +7927,10 @@ pf_route6(struct mbuf **m, struct pf_krule *r, struct ifnet *oifp,
 	struct sockaddr_in6	dst;
 	struct ip6_hdr		*ip6;
 	struct ifnet		*ifp = NULL;
-	struct pf_addr		 naddr;
 	int			 r_dir;
 
-	KASSERT(m && *m && r && oifp, ("%s: invalid parameters", __func__));
+	KASSERT(m && *m && r && oifp && pd->act.rt_kif,
+	    ("%s: invalid parameters", __func__));
 
 	SDT_PROBE4(pf, ip6, route_to, entry, *m, pd, s, oifp);
 
@@ -7969,13 +7953,15 @@ pf_route6(struct mbuf **m, struct pf_krule *r, struct ifnet *oifp,
 		goto bad_locked;
 	}
 
+	if ((ifp = pd->act.rt_kif->pfik_ifp) == NULL) {
+		m0 = *m;
+		*m = NULL;
+		SDT_PROBE1(pf, ip6, route_to, drop, __LINE__);
+		goto bad_locked;
+	}
+
 	if (pd->act.rt == PF_DUPTO) {
 		if ((pd->pf_mtag->flags & PF_MTAG_FLAG_DUPLICATED)) {
-			ifp = pd->act.rt_kif ? pd->act.rt_kif->pfik_ifp : NULL;
-			/* If pfsync'd from FreeBSD < 14 */
-			if (ifp == NULL && r->rpool.cur != NULL)
-				ifp = r->rpool.cur->kif ?
-				    r->rpool.cur->kif->pfik_ifp : NULL;
 			if (s != NULL) {
 				PF_STATE_UNLOCK(s);
 			}
@@ -8013,35 +7999,17 @@ pf_route6(struct mbuf **m, struct pf_krule *r, struct ifnet *oifp,
 	dst.sin6_len = sizeof(dst);
 	dst.sin6_addr = ip6->ip6_dst;
 	PF_ACPY((struct pf_addr *)&dst.sin6_addr, &pd->act.rt_addr, AF_INET6);
-	bzero(&naddr, sizeof(naddr));
-	ifp = pd->act.rt_kif ? pd->act.rt_kif->pfik_ifp : NULL;
 
 	if (s != NULL) {
-		struct pfi_kkif *kif;
-
-		kif = pd->act.rt_kif;
-		/* If pfsync'd from FreeBSD < 14 */
-		if (ifp == NULL && r->rpool.cur != NULL) {
-			ifp = r->rpool.cur->kif ?
-			    r->rpool.cur->kif->pfik_ifp : NULL;
-			kif = r->rpool.cur->kif;
-		}
-		if (ifp != NULL && kif != NULL &&
-		    r->rule_flag & PFRULE_IFBOUND &&
+		if (r->rule_flag & PFRULE_IFBOUND &&
 		    pd->act.rt == PF_REPLYTO &&
 		    s->kif == V_pfi_all) {
-			s->kif = kif;
+			s->kif = pd->act.rt_kif;
 			s->orig_kif = oifp->if_pf_kif;
 		}
-
 		PF_STATE_UNLOCK(s);
 	}
 
-	if (ifp == NULL) {
-		SDT_PROBE1(pf, ip6, route_to, drop, __LINE__);
-		goto bad;
-	}
-
 	if (pd->dir == PF_IN) {
 		if (pf_test(AF_INET6, PF_OUT, PFIL_FWD | PF_PFIL_NOREFRAGMENT,
 		    ifp, &m0, inp, &pd->act) != PF_PASS) {
diff --git a/tests/sys/netpfil/pf/pfsync.sh b/tests/sys/netpfil/pf/pfsync.sh
index b97b06c5f9a2..0f3505460b50 100644
--- a/tests/sys/netpfil/pf/pfsync.sh
+++ b/tests/sys/netpfil/pf/pfsync.sh
@@ -835,16 +835,11 @@ basic_ipv6_cleanup()
 	pfsynct_cleanup
 }
 
-atf_test_case "route_to" "cleanup"
-route_to_head()
+route_to_common_head()
 {
-	atf_set descr 'Test route-to with default rule'
-	atf_set require.user root
-	atf_set require.progs scapy
-}
+	pfsync_version=$1
+	shift
 
-route_to_body()
-{
 	pfsynct_init
 
 	epair_sync=$(vnet_mkepair)
@@ -866,40 +861,111 @@ route_to_body()
 	jexec one ifconfig pfsync0 \
 		syncdev ${epair_sync}a \
 		maxupd 1 \
+		version $pfsync_version \
 		up
 
 	jexec two ifconfig ${epair_sync}b 192.0.2.2/24 up
 	jexec two ifconfig ${epair_two}a 198.51.100.2/24 up
 	jexec two ifconfig ${epair_out_two}a 203.0.113.2/24 up
-	#jexec two ifconfig ${epair_out_two}a name outif
+	jexec two ifconfig ${epair_out_two}a name outif
 	jexec two sysctl net.inet.ip.forwarding=1
 	jexec two arp -s 203.0.113.254 00:01:02:03:04:05
 	jexec two ifconfig pfsync0 \
 		syncdev ${epair_sync}b \
 		maxupd 1 \
+		version $pfsync_version \
 		up
 
-	# Enable pf!
+	ifconfig ${epair_one}b 198.51.100.254/24 up
+	ifconfig ${epair_two}b 198.51.100.253/24 up
+	route add -net 203.0.113.0/24 198.51.100.1
+	ifconfig ${epair_two}b up
+	ifconfig ${epair_out_one}b up
+	ifconfig ${epair_out_two}b up
+}
+
+route_to_common_tail()
+{
+	atf_check -s exit:0 env PYTHONPATH=${common_dir} \
+		${common_dir}/pft_ping.py \
+		--sendif ${epair_one}b \
+		--fromaddr 198.51.100.254 \
+		--to 203.0.113.254 \
+		--recvif ${epair_out_one}b
+
+	# Allow time for sync
+	sleep 2
+
+	states_one=$(mktemp)
+	states_two=$(mktemp)
+	jexec one pfctl -qvvss | normalize_pfctl_s > $states_one
+	jexec two pfctl -qvvss | normalize_pfctl_s > $states_two
+}
+
+atf_test_case "route_to_1301_body" "cleanup"
+route_to_1301_head()
+{
+	atf_set descr 'Test route-to with pfsync version 13.1'
+	atf_set require.user root
+	atf_set require.progs scapy
+}
+
+route_to_1301_body()
+{
+	route_to_common_head 1301
+
 	jexec one pfctl -e
 	pft_set_rules one \
 		"set skip on ${epair_sync}a" \
 		"pass out route-to (outif 203.0.113.254)"
+
 	jexec two pfctl -e
+	pft_set_rules two \
+		"set skip on ${epair_sync}b" \
+		"pass out route-to (outif 203.0.113.254)"
+
+	route_to_common_tail
 
-	# Make sure we have different rulesets so the synced state is associated with
-	# V_pf_default_rule
+	# Sanity check
+	grep -qE 'all icmp 198.51.100.254 -> 203.0.113.254:8 .*, rule 0 .* route-to: 203.0.113.254@outif origif: outif' $states_one ||
+		atf_fail "State missing on router one"
+
+	# With identical ruleset the routing information is recovered from the matching rule.
+	grep -qE 'all icmp 198.51.100.254 -> 203.0.113.254:8 .*, rule 0 .* route-to: 203.0.113.254@outif' $states_two ||
+		atf_fail "State missing on router two"
+
+	true
+}
+
+route_to_1301_cleanup()
+{
+	pfsynct_cleanup
+}
+
+atf_test_case "route_to_1301_bad_ruleset" "cleanup"
+route_to_1301_bad_ruleset_head()
+{
+	atf_set descr 'Test route-to with pfsync version 13.1 and incompatible ruleset'
+	atf_set require.user root
+	atf_set require.progs scapy
+}
+
+route_to_1301_bad_ruleset_body()
+{
+	route_to_common_head 1301
+
+	jexec one pfctl -e
+	pft_set_rules one \
+		"set skip on ${epair_sync}a" \
+		"pass out route-to (outif 203.0.113.254)"
+
+	jexec two pfctl -e
 	pft_set_rules two \
+		"set debug loud" \
 		"set skip on ${epair_sync}b" \
 		"pass out route-to (outif 203.0.113.254)" \
 		"pass out proto tcp"
 
-	ifconfig ${epair_one}b 198.51.100.254/24 up
-	ifconfig ${epair_two}b 198.51.100.253/24 up
-	route add -net 203.0.113.0/24 198.51.100.1
-	ifconfig ${epair_two}b up
-	ifconfig ${epair_out_one}b up
-	ifconfig ${epair_out_two}b up
-
 	atf_check -s exit:0 env PYTHONPATH=${common_dir} \
 		${common_dir}/pft_ping.py \
 		--sendif ${epair_one}b \
@@ -907,25 +973,151 @@ route_to_body()
 		--to 203.0.113.254 \
 		--recvif ${epair_out_one}b
 
-	# Allow time for sync
-	ifconfig ${epair_one}b inet 198.51.100.254 -alias
-	route del -net 203.0.113.0/24 198.51.100.1
-	route add -net 203.0.113.0/24 198.51.100.2
+	route_to_common_tail
 
-	sleep 2
+	# Sanity check
+	grep -qE 'all icmp 198.51.100.254 -> 203.0.113.254:8 .*, rule 0 .* route-to: 203.0.113.254@outif origif: outif' $states_one ||
+		atf_fail "State missing on router one"
 
-	# Now try to trigger the state on the other pfsync member
-	env PYTHONPATH=${common_dir} \
+	# Different ruleset on each router means the routing information recovery
+	# from rule is impossible. The state is not synced.
+	grep -qE 'all icmp 198.51.100.254 -> 203.0.113.254:8 .*' $states_two &&
+		atf_fail "State present on router two"
+
+	true
+}
+
+route_to_1301_bad_ruleset_cleanup()
+{
+	pfsynct_cleanup
+}
+
+atf_test_case "route_to_1301_bad_rpool" "cleanup"
+route_to_1301_bad_rpool_head()
+{
+	atf_set descr 'Test route-to with pfsync version 13.1 and different interface'
+	atf_set require.user root
+	atf_set require.progs scapy
+}
+
+route_to_1301_bad_rpool_body()
+{
+	route_to_common_head 1301
+
+	jexec one pfctl -e
+	pft_set_rules one \
+		"set skip on ${epair_sync}a" \
+		"pass out route-to { (outif 203.0.113.254) (outif 203.0.113.254) }"
+
+	jexec two pfctl -e
+	pft_set_rules two \
+		"set skip on ${epair_sync}b" \
+		"pass out route-to { (outif 203.0.113.254) (outif 203.0.113.254) }"
+
+	atf_check -s exit:0 env PYTHONPATH=${common_dir} \
 		${common_dir}/pft_ping.py \
-		--sendif ${epair_two}b \
+		--sendif ${epair_one}b \
 		--fromaddr 198.51.100.254 \
 		--to 203.0.113.254 \
-		--recvif ${epair_out_two}b
+		--recvif ${epair_out_one}b
+
+	route_to_common_tail
+
+	# Sanity check
+	grep -qE 'all icmp 198.51.100.254 -> 203.0.113.254:8 .*, rule 0 .* route-to: 203.0.113.254@outif origif: outif' $states_one ||
+		atf_fail "State missing on router one"
+
+	# The ruleset is identical but since the redirection pool contains multiple interfaces
+	# pfsync will not attempt to recover the routing information from the rule.
+	grep -qE 'all icmp 198.51.100.254 -> 203.0.113.254:8 .*' $states_two &&
+		atf_fail "State present on router two"
+
+	true
+}
+
+route_to_1301_bad_rpool_cleanup()
+{
+	pfsynct_cleanup
+}
+
+atf_test_case "route_to_1400_bad_ruleset" "cleanup"
+route_to_1400_bad_ruleset_head()
+{
+	atf_set descr 'Test route-to with pfsync version 14.0'
+	atf_set require.user root
+	atf_set require.progs scapy
+}
+
+route_to_1400_bad_ruleset_body()
+{
+	route_to_common_head 1400
+
+	jexec one pfctl -e
+	pft_set_rules one \
+		"set skip on ${epair_sync}a" \
+		"pass out route-to (outif 203.0.113.254)"
+
+	jexec two pfctl -e
+	pft_set_rules two \
+		"set skip on ${epair_sync}b"
+
+	route_to_common_tail
+
+	# Sanity check
+	grep -qE 'all icmp 198.51.100.254 -> 203.0.113.254:8 .*, rule 0 .* route-to: 203.0.113.254@outif origif: outif' $states_one ||
+		atf_fail "State missing on router one"
+
+	# Even with a different ruleset FreeBSD 14 syncs the state just fine.
+	# There's no recovery involved, the pfsync packet contains the routing information.
+	grep -qE 'all icmp 198.51.100.254 -> 203.0.113.254:8 .* route-to: 203.0.113.254@outif' $states_two ||
+		atf_fail "State missing on router two"
+
+	true
+}
+
+route_to_1400_bad_ruleset_cleanup()
+{
+	pfsynct_cleanup
+}
+
+atf_test_case "route_to_1400_bad_ifname" "cleanup"
+route_to_1400_bad_ifname_head()
+{
+	atf_set descr 'Test route-to with pfsync version 14.0'
+	atf_set require.user root
+	atf_set require.progs scapy
+}
+
+route_to_1400_bad_ifname_body()
+{
+	route_to_common_head 1400
+
+	jexec one pfctl -e
+	pft_set_rules one \
+		"set skip on ${epair_sync}a" \
+		"pass out route-to (outif 203.0.113.254)"
+
+	jexec two pfctl -e
+	jexec two ifconfig outif name outif_new
+	pft_set_rules two \
+		"set skip on ${epair_sync}b" \
+		"pass out route-to (outif_new 203.0.113.254)"
+
+	route_to_common_tail
+
+	# Sanity check
+	grep -qE 'all icmp 198.51.100.254 -> 203.0.113.254:8 .*, rule 0 .* route-to: 203.0.113.254@outif origif: outif' $states_one ||
+		atf_fail "State missing on router one"
+
+	# Since FreeBSD 14 never attempts recovery of missing routing information
+	# a state synced to a router with a different interface name is dropped.
+	grep -qE 'all icmp 198.51.100.254 -> 203.0.113.254:8 .*' $states_two &&
+		atf_fail "State present on router two"
 
 	true
 }
 
-route_to_cleanup()
+route_to_1400_bad_ifname_cleanup()
 {
 	pfsynct_cleanup
 }
@@ -942,5 +1134,9 @@ atf_init_test_cases()
 	atf_add_test_case "timeout"
 	atf_add_test_case "basic_ipv6_unicast"
 	atf_add_test_case "basic_ipv6"
-	atf_add_test_case "route_to"
+	atf_add_test_case "route_to_1301"
+	atf_add_test_case "route_to_1301_bad_ruleset"
+	atf_add_test_case "route_to_1301_bad_rpool"
+	atf_add_test_case "route_to_1400_bad_ruleset"
+	atf_add_test_case "route_to_1400_bad_ifname"
 }