git: 9f3032b76b3c - stable/14 - pf tests: Add option to send fragmented packets

From: Dag-Erling Smørgrav <des_at_FreeBSD.org>
Date: Wed, 24 Apr 2024 22:12:40 UTC
The branch stable/14 has been updated by des:

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

commit 9f3032b76b3c077d032fb86b614a5d64e04a7ed4
Author:     Kajetan Staszkiewicz <vegeta@tuxpowered.net>
AuthorDate: 2023-10-26 09:14:14 +0000
Commit:     Dag-Erling Smørgrav <des@FreeBSD.org>
CommitDate: 2024-04-24 22:11:56 +0000

    pf tests: Add option to send fragmented packets
    
    Add option to send fragmented packets and to properly sniff them by
    reassembling them by the sniffer itself.
    
    Reviewed by:    kp
    Sponsored by:   InnoGames GmbH
    Differential Revision:  https://reviews.freebsd.org/D42354
    
    (cherry picked from commit d7c9de2d68ca81c557e069c2b431529cf597886c)
    
    pf: Fix packet reassembly
    
    Don't drop fragmented packets when reassembly is disabled, they can be
    matched by rules with "fragment" keyword. Ensure that presence of scrub
    rules forces old behaviour.
    
    Reviewed by:    kp
    Sponsored by:   InnoGames GmbH
    Differential Revision:  https://reviews.freebsd.org/D42355
    
    (cherry picked from commit ede5d4ff5b39ccbc193c30fb6c093c7c4de9a464)
    
    pf: Update documentation regarding matching, scrubbing and reassembly
    
    Update pf documentation:
    
     - default behaviour of fragment reassembly
     - introduction of scrub option for filter rules
     - disadvantages of using the old scrub ruleset
     - options supported for match rules
     - fix missing list block end
     - remove duplicate description of match filter rule
     - update example to modern syntax
    
    Reviewed by:    kp
    Fragments obtained from:        OpenBSD
    Sponsored by:   InnoGames GmbH
    Differential Revision:  https://reviews.freebsd.org/D42270
    
    (cherry picked from commit 5ed470bdb9da6442d6030cf0a7a8493f759fbb43)
---
 share/man/man5/pf.conf.5                           | 147 ++++++++++++++-------
 sys/netpfil/pf/pf_norm.c                           |  51 ++++---
 tests/sys/netpfil/common/pft_ping.py               |  39 ++++--
 tests/sys/netpfil/common/sniffer.py                |  18 ++-
 tests/sys/netpfil/pf/Makefile                      |   1 +
 tests/sys/netpfil/pf/fragmentation_compat.sh       |  11 --
 .../sys/netpfil/pf/fragmentation_no_reassembly.sh  | 130 ++++++++++++++++++
 7 files changed, 308 insertions(+), 89 deletions(-)

diff --git a/share/man/man5/pf.conf.5 b/share/man/man5/pf.conf.5
index 8255a89587be..3193c18760c8 100644
--- a/share/man/man5/pf.conf.5
+++ b/share/man/man5/pf.conf.5
@@ -401,7 +401,9 @@ set limit frags 20000
 .Ed
 .Pp
 sets the maximum number of entries in the memory pool used for fragment
-reassembly (generated by
+reassembly (generated by the
+.Ar set reassemble
+option or
 .Ar scrub
 rules) to 20000.
 Using
@@ -495,6 +497,29 @@ For example:
 .Bd -literal -offset indent
 set optimization aggressive
 .Ed
+.It Ar set reassemble yes | no Op Cm no-df
+The
+.Cm reassemble
+option is used to enable or disable the reassembly of fragmented packets,
+and can be set to
+.Cm yes
+or
+.Cm no .
+If
+.Cm no-df
+is also specified, fragments with the
+.Dq dont-fragment
+bit set are reassembled too,
+instead of being dropped;
+the reassembled packet will have the
+.Dq dont-fragment
+bit cleared.
+The default value is
+.Cm no .
+.Pp
+This option is ignored if there are pre-FreeBSD 14
+.Cm scrub
+rules present.
 .It Ar set block-policy
 The
 .Ar block-policy
@@ -768,19 +793,21 @@ Used to specify that packets must already be tagged with the given tag in order
 to match the rule.
 Inverse tag matching can also be done by specifying the !  operator before the
 tagged keyword.
+.El
 .Sh TRAFFIC NORMALIZATION
-Traffic normalization is used to sanitize packet content in such
-a way that there are no ambiguities in packet interpretation on
-the receiving side.
-The normalizer does IP fragment reassembly to prevent attacks
-that confuse intrusion detection systems by sending overlapping
-IP fragments.
-Packet normalization is invoked with the
-.Ar scrub
-directive.
-.Pp
-.Ar scrub
-has the following options:
+Traffic normalization is a broad umbrella term
+for aspects of the packet filter which deal with
+verifying packets, packet fragments, spoofed traffic,
+and other irregularities.
+.Ss Scrub
+Scrub involves sanitising packet content in such a way
+that there are no ambiguities in packet interpretation on the receiving side.
+It is invoked with the
+.Cm scrub
+option, added to filter rules.
+.Pp
+Parameters are specified enclosed in parentheses.
+At least one of the following parameters must be specified:
 .Bl -tag -width xxxx
 .It Ar no-df
 Clears the
@@ -839,22 +866,8 @@ Replaces the IP identification field with random values to compensate
 for predictable values generated by many hosts.
 This option only applies to packets that are not fragmented
 after the optional fragment reassembly.
-.It Ar fragment reassemble
-Using
-.Ar scrub
-rules, fragments can be reassembled by normalization.
-In this case, fragments are buffered until they form a complete
-packet, and only the completed packet is passed on to the filter.
-The advantage is that filter rules have to deal only with complete
-packets, and can ignore fragments.
-The drawback of caching fragments is the additional memory cost.
-This is the default behaviour unless no fragment reassemble is specified.
-.It Ar no fragment reassemble
-Do not reassemble fragments.
 .It Ar reassemble tcp
 Statefully normalizes TCP connections.
-.Ar scrub reassemble tcp
-rules may not have the direction (in/out) specified.
 .Ar reassemble tcp
 performs the following normalizations:
 .Pp
@@ -906,6 +919,41 @@ blind attacker would have to guess the timestamp as well.
 .Pp
 For example,
 .Bd -literal -offset indent
+match in all scrub (no-df random-id max-mss 1440)
+.Ed
+.Ss Scrub ruleset (pre-FreeBSD 14)
+In order to maintain compatibility with older releases of FreeBSD
+.Ar scrub
+rules can also be specified in their own ruleset.
+In such case they are invoked with the
+.Ar scrub
+directive.
+If there are such rules present they determine packet reassembly behaviour.
+When no such rules are present the option
+.Ar set reassembly
+takes precedence.
+The
+.Ar scrub
+rules can take all parameters specified above for a
+.Ar scrub
+option of filter rules and 2 more parameters controlling fragment reassembly:
+.Bl -tag -width xxxx
+.It Ar fragment reassemble
+Using
+.Ar scrub
+rules, fragments can be reassembled by normalization.
+In this case, fragments are buffered until they form a complete
+packet, and only the completed packet is passed on to the filter.
+The advantage is that filter rules have to deal only with complete
+packets, and can ignore fragments.
+The drawback of caching fragments is the additional memory cost.
+This is the default behaviour unless no fragment reassemble is specified.
+.It Ar no fragment reassemble
+Do not reassemble fragments.
+.El
+.Pp
+For example,
+.Bd -literal -offset indent
 scrub in on $ext_if all fragment reassemble
 .Ed
 .Pp
@@ -917,6 +965,14 @@ much in the same way as
 works in the packet filter (see below).
 This mechanism should be used when it is necessary to exclude specific packets
 from broader scrub rules.
+.Pp
+.Ar scrub
+rules in the
+.Ar scrub
+ruleset are evaluated for every packet before stateful filtering.
+This means excessive usage of them will cause performance penalty.
+.Ar scrub reassemble tcp
+rules must not have the direction (in/out) specified.
 .Sh QUEUEING with ALTQ
 The ALTQ system is currently not available in the GENERIC kernel nor as
 loadable modules.
@@ -1494,28 +1550,21 @@ rules differ from
 .Ar block
 and
 .Ar pass
-rules in that parameters are set every time a packet matches the rule, not only
+rules in that parameters are set for every rule a packet matches, not only
 on the last matching rule.
 For the following parameters, this means that the parameter effectively becomes
 "sticky" until explicitly overridden:
 .Ar queue ,
 .Ar dnpipe ,
-.Ar dnqueue
+.Ar dnqueue ,
+.Ar rtable ,
+.Ar scrub
 .
 .It Ar pass
 The packet is passed;
 state is created unless the
 .Ar no state
 option is specified.
-.It Ar match
-Action is unaltered, the previously matched rule's action still matters.
-Match rules apply queue and rtable assignments for every matched packet,
-subsequent matching pass or match rules can overwrite the assignment,
-if they don't specify a queue or an rtable, respectively, the previously
-set value remains.
-Additionally, match rules can contain log statements; the is logging done
-for each and every matching match rule, so it is possible to log a single
-packet multiple times.
 .El
 .Pp
 By default
@@ -2597,6 +2646,8 @@ contain the necessary header information for the subprotocol that allows
 to filter on things such as TCP ports or to perform NAT.
 .Pp
 Besides the use of
+.Ar set reassemble
+option or
 .Ar scrub
 rules as described in
 .Sx TRAFFIC NORMALIZATION
@@ -2605,7 +2656,11 @@ above, there are three options for handling fragments in the packet filter.
 One alternative is to filter individual fragments with filter rules.
 If no
 .Ar scrub
-rule applies to a fragment, it is passed to the filter.
+rule applies to a fragment or
+.Ar set reassemble
+is set to
+.Cm no
+, it is passed to the filter.
 Filter rules with matching IP header parameters decide whether the
 fragment is passed or blocked, in the same way as complete packets
 are filtered.
@@ -2638,11 +2693,13 @@ rules.
 .Pp
 In most cases, the benefits of reassembly outweigh the additional
 memory cost, and it's recommended to use
+.Ar set reassemble
+option or
 .Ar scrub
-rules to reassemble
-all fragments via the
+rules with the
 .Ar fragment reassemble
-modifier.
+modifier to reassemble
+all fragments.
 .Pp
 The memory allocated for fragment caching can be limited using
 .Xr pfctl 8 .
@@ -3007,12 +3064,12 @@ rdr on $ext_if proto tcp from any to any port 80 \e
 # (157.161.48.183, the only routable address)
 # and the private network is 10.0.0.0/8, for which we are doing NAT.
 
+# Reassemble incoming traffic
+set reassemble yes
+
 # use a macro for the interface name, so it can be changed easily
 ext_if = \&"kue0\&"
 
-# normalize all incoming traffic
-scrub in on $ext_if all fragment reassemble
-
 # block and log everything by default
 block return log on $ext_if all
 
diff --git a/sys/netpfil/pf/pf_norm.c b/sys/netpfil/pf/pf_norm.c
index 2625966a0278..a92462c53f15 100644
--- a/sys/netpfil/pf/pf_norm.c
+++ b/sys/netpfil/pf/pf_norm.c
@@ -1043,14 +1043,22 @@ pf_normalize_ip(struct mbuf **m0, struct pfi_kkif *kif, u_short *reason,
 	int			 ip_len;
 	int			 tag = -1;
 	int			 verdict;
-	int			 srs;
+	bool			 scrub_compat;
 
 	PF_RULES_RASSERT();
 
 	r = TAILQ_FIRST(pf_main_ruleset.rules[PF_RULESET_SCRUB].active.ptr);
-	/* Check if there any scrub rules. Lack of scrub rules means enforced
-	 * packet normalization operation just like in OpenBSD. */
-	srs = (r != NULL);
+	/*
+	 * Check if there are any scrub rules, matching or not.
+	 * Lack of scrub rules means:
+	 *  - enforced packet normalization operation just like in OpenBSD
+	 *  - fragment reassembly depends on V_pf_status.reass
+	 * With scrub rules:
+	 *  - packet normalization is performed if there is a matching scrub rule
+	 *  - fragment reassembly is performed if the matching rule has no
+	 *    PFRULE_FRAGMENT_NOREASS flag
+	 */
+	scrub_compat = (r != NULL);
 	while (r != NULL) {
 		pf_counter_u64_add(&r->evaluations, 1);
 		if (pfi_kkif_match(r->kif, kif) == r->ifnot)
@@ -1076,7 +1084,7 @@ pf_normalize_ip(struct mbuf **m0, struct pfi_kkif *kif, u_short *reason,
 			break;
 	}
 
-	if (srs) {
+	if (scrub_compat) {
 		/* With scrub rules present IPv4 normalization happens only
 		 * if one of rules has matched and it's not a "no scrub" rule */
 		if (r == NULL || r->action == PF_NOSCRUB)
@@ -1087,12 +1095,6 @@ pf_normalize_ip(struct mbuf **m0, struct pfi_kkif *kif, u_short *reason,
 		pf_counter_u64_add_protected(&r->bytes[pd->dir == PF_OUT], pd->tot_len);
 		pf_counter_u64_critical_exit();
 		pf_rule_to_actions(r, &pd->act);
-	} else if ((!V_pf_status.reass && (h->ip_off & htons(IP_MF | IP_OFFMASK)))) {
-		/* With no scrub rules IPv4 fragment reassembly depends on the
-		 * global switch. Fragments can be dropped early if reassembly
-		 * is disabled. */
-		REASON_SET(reason, PFRES_NORM);
-		goto drop;
 	}
 
 	/* Check for illegal packets */
@@ -1107,9 +1109,10 @@ pf_normalize_ip(struct mbuf **m0, struct pfi_kkif *kif, u_short *reason,
 	}
 
 	/* Clear IP_DF if the rule uses the no-df option or we're in no-df mode */
-	if ((((r && r->rule_flag & PFRULE_NODF) ||
-	    (V_pf_status.reass & PF_REASS_NODF)) && h->ip_off & htons(IP_DF)
-	)) {
+	if (((!scrub_compat && V_pf_status.reass & PF_REASS_NODF) ||
+	    (r != NULL && r->rule_flag & PFRULE_NODF)) &&
+	    (h->ip_off & htons(IP_DF))
+	) {
 		u_int16_t ip_off = h->ip_off;
 
 		h->ip_off &= htons(~IP_DF);
@@ -1143,7 +1146,9 @@ pf_normalize_ip(struct mbuf **m0, struct pfi_kkif *kif, u_short *reason,
 		goto bad;
 	}
 
-	if (r==NULL || !(r->rule_flag & PFRULE_FRAGMENT_NOREASS)) {
+	if ((!scrub_compat && V_pf_status.reass) ||
+	    (r != NULL && !(r->rule_flag & PFRULE_FRAGMENT_NOREASS))
+	) {
 		max = fragoff + ip_len;
 
 		/* Fully buffer all of the fragments
@@ -1203,14 +1208,20 @@ pf_normalize_ip6(struct mbuf **m0, struct pfi_kkif *kif,
 	int			 ooff;
 	u_int8_t		 proto;
 	int			 terminal;
-	int			 srs;
+	bool			 scrub_compat;
 
 	PF_RULES_RASSERT();
 
 	r = TAILQ_FIRST(pf_main_ruleset.rules[PF_RULESET_SCRUB].active.ptr);
-	/* Check if there any scrub rules. Lack of scrub rules means enforced
-	 * packet normalization operation just like in OpenBSD. */
-	srs = (r != NULL);
+	/*
+	 * Check if there are any scrub rules, matching or not.
+	 * Lack of scrub rules means:
+	 *  - enforced packet normalization operation just like in OpenBSD
+	 * With scrub rules:
+	 *  - packet normalization is performed if there is a matching scrub rule
+	 * XXX: Fragment reassembly always performed for IPv6!
+	 */
+	scrub_compat = (r != NULL);
 	while (r != NULL) {
 		pf_counter_u64_add(&r->evaluations, 1);
 		if (pfi_kkif_match(r->kif, kif) == r->ifnot)
@@ -1235,7 +1246,7 @@ pf_normalize_ip6(struct mbuf **m0, struct pfi_kkif *kif,
 			break;
 	}
 
-	if (srs) {
+	if (scrub_compat) {
 		/* With scrub rules present IPv6 normalization happens only
 		 * if one of rules has matched and it's not a "no scrub" rule */
 		if (r == NULL || r->action == PF_NOSCRUB)
diff --git a/tests/sys/netpfil/common/pft_ping.py b/tests/sys/netpfil/common/pft_ping.py
index af5b84e34c67..1abf4f609832 100644
--- a/tests/sys/netpfil/common/pft_ping.py
+++ b/tests/sys/netpfil/common/pft_ping.py
@@ -82,17 +82,29 @@ def prepare_ipv4(dst_address, send_params):
 
 def send_icmp_ping(dst_address, sendif, send_params):
     send_length = send_params['length']
+    send_frag_length = send_params['frag_length']
+    packets = []
     ether = sp.Ether()
     if ':' in dst_address:
         ip6 = prepare_ipv6(dst_address, send_params)
         icmp = sp.ICMPv6EchoRequest(data=sp.raw(build_payload(send_length)))
-        req = ether / ip6 / icmp
+        if send_frag_length:
+            for packet in sp.fragment(ip6 / icmp, fragsize=send_frag_length):
+                packets.append(ether / packet)
+        else:
+            packets.append(ether / ip6 / icmp)
+
     else:
         ip = prepare_ipv4(dst_address, send_params)
         icmp = sp.ICMP(type='echo-request')
         raw = sp.raw(build_payload(send_length))
-        req = ether / ip / icmp / raw
-    sp.sendp(req, sendif, verbose=False)
+        if send_frag_length:
+            for packet in sp.fragment(ip / icmp / raw, fragsize=send_frag_length):
+                packets.append(ether / packet)
+        else:
+            packets.append(ether / ip / icmp / raw)
+    for packet in packets:
+        sp.sendp(packet, sendif, verbose=False)
 
 
 def send_tcp_syn(dst_address, sendif, send_params):
@@ -372,7 +384,7 @@ def check_tcp_syn_reply(expect_params, packet):
         return check_tcp_syn_reply_4(expect_params, packet)
 
 
-def setup_sniffer(recvif, ping_type, sniff_type, expect_params):
+def setup_sniffer(recvif, ping_type, sniff_type, expect_params, defrag):
     if ping_type == 'icmp' and sniff_type == 'request':
         checkfn = check_ping_request
     elif ping_type == 'icmp' and sniff_type == 'reply':
@@ -384,7 +396,7 @@ def setup_sniffer(recvif, ping_type, sniff_type, expect_params):
     else:
         raise Exception('Unspported ping or sniff type')
 
-    return Sniffer(expect_params, checkfn, recvif)
+    return Sniffer(expect_params, checkfn, recvif, defrag=defrag)
 
 
 def parse_args():
@@ -417,6 +429,8 @@ def parse_args():
     parser_send = parser.add_argument_group('Values set in transmitted packets')
     parser_send.add_argument('--send-flags', nargs=1, type=str,
         help='IPv4 fragmentation flags')
+    parser_send.add_argument('--send-frag-length', nargs=1, type=int,
+         help='Force IP fragmentation with given fragment length')
     parser_send.add_argument('--send-hlim', nargs=1, type=int,
         help='IPv6 Hop Limit or IPv4 Time To Live')
     parser_send.add_argument('--send-mss', nargs=1, type=int,
@@ -428,7 +442,7 @@ def parse_args():
     parser_send.add_argument('--send-tc', nargs=1, type=int,
         help='IPv6 Traffic Class or IPv4 DiffServ / ToS')
     parser_send.add_argument('--send-tcpopt-unaligned', action='store_true',
-            help='Include unaligned TCP options')
+         help='Include unaligned TCP options')
 
     # Expectations
     parser_expect = parser.add_argument_group('Values expected in sniffed packets')
@@ -467,7 +481,7 @@ def main():
     # Standardize parameters which have nargs=1.
     send_params = {}
     expect_params = {}
-    for param_name in ('flags', 'hlim', 'length', 'mss', 'seq', 'tc'):
+    for param_name in ('flags', 'hlim', 'length', 'mss', 'seq', 'tc', 'frag_length'):
         param_arg = vars(args).get(f'send_{param_name}')
         send_params[param_name] = param_arg[0] if param_arg else None
         param_arg = vars(args).get(f'expect_{param_name}')
@@ -488,6 +502,11 @@ def main():
 
     sniffers = []
 
+    if send_params['frag_length']:
+        defrag = True
+    else:
+        defrag = False
+
     if recv_ifs:
         sniffer_params = copy(expect_params)
         sniffer_params['src_address'] = None
@@ -495,7 +514,8 @@ def main():
         for iface in recv_ifs:
             LOGGER.debug(f'Installing receive sniffer on {iface}')
             sniffers.append(
-                setup_sniffer(iface, args.ping_type, 'request', sniffer_params,
+                setup_sniffer(iface, args.ping_type, 'request',
+                              sniffer_params, defrag,
             ))
 
     if reply_ifs:
@@ -505,7 +525,8 @@ def main():
         for iface in reply_ifs:
             LOGGER.debug(f'Installing reply sniffer on {iface}')
             sniffers.append(
-                setup_sniffer(iface, args.ping_type, 'reply', sniffer_params,
+                setup_sniffer(iface, args.ping_type, 'reply',
+                              sniffer_params, defrag,
             ))
 
     LOGGER.debug(f'Installed {len(sniffers)} sniffers')
diff --git a/tests/sys/netpfil/common/sniffer.py b/tests/sys/netpfil/common/sniffer.py
index ab3ddc0aea3c..14305a37278c 100644
--- a/tests/sys/netpfil/common/sniffer.py
+++ b/tests/sys/netpfil/common/sniffer.py
@@ -30,7 +30,7 @@ import scapy.all as sp
 import sys
 
 class Sniffer(threading.Thread):
-	def __init__(self, args, check_function, recvif, timeout=3):
+	def __init__(self, args, check_function, recvif, timeout=3, defrag=False):
 		threading.Thread.__init__(self)
 
 		self._sem = threading.Semaphore(0)
@@ -38,6 +38,7 @@ class Sniffer(threading.Thread):
 		self._timeout = timeout
 		self._recvif = recvif
 		self._check_function = check_function
+		self._defrag = defrag
 		self.correctPackets = 0
 
 		self.start()
@@ -55,6 +56,15 @@ class Sniffer(threading.Thread):
 
 	def run(self):
 		self.packets = []
-		self.packets = sp.sniff(iface=self._recvif,
-			stop_filter=self._checkPacket, timeout=self._timeout,
-			started_callback=self._startedCb)
+		if self._defrag:
+			# With fragment reassembly we can't stop the sniffer after catching
+			# the good packets, as those have not been reassembled. We must
+			#  wait for sniffer to finish and check returned packets instead.
+			self.packets = sp.sniff(session=sp.IPSession, iface=self._recvif,
+				timeout=self._timeout, started_callback=self._startedCb)
+			for p in self.packets:
+				self._checkPacket(p)
+		else:
+			self.packets = sp.sniff(iface=self._recvif,
+				stop_filter=self._checkPacket, timeout=self._timeout,
+				started_callback=self._startedCb)
diff --git a/tests/sys/netpfil/pf/Makefile b/tests/sys/netpfil/pf/Makefile
index d3b187789685..1083f89a5502 100644
--- a/tests/sys/netpfil/pf/Makefile
+++ b/tests/sys/netpfil/pf/Makefile
@@ -13,6 +13,7 @@ ATF_TESTS_SH+=	altq \
 		forward \
 		fragmentation_compat \
 		fragmentation_pass \
+		fragmentation_no_reassembly \
 		get_state \
 		icmp \
 		killstate \
diff --git a/tests/sys/netpfil/pf/fragmentation_compat.sh b/tests/sys/netpfil/pf/fragmentation_compat.sh
index f66ebbad6b33..21ce6b734ea1 100644
--- a/tests/sys/netpfil/pf/fragmentation_compat.sh
+++ b/tests/sys/netpfil/pf/fragmentation_compat.sh
@@ -300,17 +300,6 @@ reassemble_body()
 	atf_check -s exit:0 -o ignore ping -c 1 192.0.2.2
 
 	jexec alcatraz pfctl -e
-	pft_set_rules alcatraz \
-		"pass out" \
-		"block in" \
-		"pass in inet proto icmp all icmp-type echoreq"
-
-	# Single fragment passes
-	atf_check -s exit:0 -o ignore ping -c 1 192.0.2.2
-
-	# But a fragmented ping does not
-	atf_check -s exit:2 -o ignore ping -c 1 -s 2000 192.0.2.2
-
 	pft_set_rules alcatraz \
 		"scrub in" \
 		"pass out" \
diff --git a/tests/sys/netpfil/pf/fragmentation_no_reassembly.sh b/tests/sys/netpfil/pf/fragmentation_no_reassembly.sh
new file mode 100644
index 000000000000..fb5c15ac2ff8
--- /dev/null
+++ b/tests/sys/netpfil/pf/fragmentation_no_reassembly.sh
@@ -0,0 +1,130 @@
+#
+# SPDX-License-Identifier: BSD-2-Clause
+#
+# Copyright (c) 2017 Kristof Provost <kp@FreeBSD.org>
+# Copyright (c) 2023 Kajetan Staszkiewicz <vegeta@tuxpowered.net>
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions
+# are met:
+# 1. Redistributions of source code must retain the above copyright
+#    notice, this list of conditions and the following disclaimer.
+# 2. Redistributions in binary form must reproduce the above copyright
+#    notice, this list of conditions and the following disclaimer in the
+#    documentation and/or other materials provided with the distribution.
+#
+# THIS SOFTWARE IS PROVIDED BY THE AUTHOR AND CONTRIBUTORS ``AS IS'' AND
+# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+# ARE DISCLAIMED.  IN NO EVENT SHALL THE AUTHOR OR CONTRIBUTORS BE LIABLE
+# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS
+# OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
+# HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
+# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY
+# OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF
+# SUCH DAMAGE.
+
+. $(atf_get_srcdir)/utils.subr
+
+atf_test_case "match_full_v4" "cleanup"
+match_full_v4_head()
+{
+    atf_set descr 'Matching non-fragmented IPv4 packets'
+    atf_set require.user root
+    atf_set require.progs scapy
+}
+
+match_full_v4_body()
+{
+    setup_router_dummy_ipv4
+
+    # Sanity check.
+    ping_dummy_check_request exit:0 --ping-type=icmp
+
+    # Only non-fragmented packets are passed
+    jexec router pfctl -e
+    pft_set_rules router \
+        "pass out" \
+        "block in" \
+        "pass in inet proto icmp all icmp-type echoreq"
+    ping_dummy_check_request exit:0 --ping-type=icmp
+    ping_dummy_check_request exit:1 --ping-type=icmp --send-length=2000 --send-frag-length 1000
+}
+
+match_full_v4_cleanup()
+{
+    pft_cleanup
+}
+
+
+atf_test_case "match_fragment_v4" "cleanup"
+match_fragment_v4_head()
+{
+    atf_set descr 'Matching fragmented IPv4 packets'
+    atf_set require.user root
+    atf_set require.progs scapy
+}
+
+match_fragment_v4_body()
+{
+    setup_router_dummy_ipv4
+
+    # Sanity check.
+    ping_dummy_check_request exit:0 --ping-type=icmp
+
+    # Only fragmented packets are passed
+    pft_set_rules router \
+        "pass out" \
+        "block in" \
+        "pass in inet proto icmp fragment"
+    ping_dummy_check_request exit:1 --ping-type=icmp
+    ping_dummy_check_request exit:0 --ping-type=icmp --send-length=2000 --send-frag-length 1000
+}
+
+match_fragment_v4_cleanup()
+{
+    pft_cleanup
+}
+
+
+atf_test_case "compat_override_v4" "cleanup"
+compat_override_v4_head()
+{
+    atf_set descr 'Scrub rules override "set reassemble" for IPv4'
+    atf_set require.user root
+    atf_set require.progs scapy
+}
+
+compat_override_v4_body()
+{
+    setup_router_dummy_ipv4
+
+    # Sanity check.
+    ping_dummy_check_request exit:0 --ping-type=icmp
+
+    # The same as match_fragment_v4 but with "set reassemble yes" which
+    # is ignored because of presence of scrub rules.
+    # Only fragmented packets are passed.
+    pft_set_rules router \
+        "set reassemble yes" \
+        "no scrub" \
+        "pass out" \
+        "block in" \
+        "pass in inet proto icmp fragment"
+    ping_dummy_check_request exit:1 --ping-type=icmp
+    ping_dummy_check_request exit:0 --ping-type=icmp --send-length=2000 --send-frag-length 1000
+}
+
+compat_override_v4_cleanup()
+{
+    pft_cleanup
+}
+
+
+atf_init_test_cases()
+{
+    atf_add_test_case "match_full_v4"
+    atf_add_test_case "match_fragment_v4"
+    atf_add_test_case "compat_override_v4"
+}