host_firewall: rejected handshake flows are remembered for 30s and reset per packet, the first data segment no longer slips through; handshake match needs a bare ACK

This commit is contained in:
2026-10-26 14:56:59 +00:00
parent 46fd6a4c40
commit 7604bc4dac
+24 -5
View File
@@ -112,6 +112,16 @@ table inet host {
type ipv6_addr; flags dynamic; timeout 10m; size 65535;
}
{% endfor %}
{% if rl_input or rl_forward %}
# Flows the handshake meter rejected: the client sends its first data segment right behind the ACK, which reaches the
# host before the reset reaches the client and would complete the handshake there.
set rl_hs_rejected_v4 {
type ipv4_addr . inet_service . ipv4_addr . inet_service; flags dynamic,timeout; timeout 30s; size 65535;
}
set rl_hs_rejected_v6 {
type ipv6_addr . inet_service . ipv6_addr . inet_service; flags dynamic,timeout; timeout 30s; size 65535;
}
{% endif %}
chain input {
type filter hook input priority filter; policy drop;
@@ -129,9 +139,13 @@ table inet host {
{% endif %}
{% if rl_input %}
# Before the established rule: the packet that completes a handshake is the second original-direction packet
# (needs net.netfilter.nf_conntrack_acct).
meta l4proto tcp ct state established ct direction original ct original packets 2 jump ratelimit_hs_in
# Before the established rule. Packets of rejected flows get a reset (not on RST, so that the half-open socket of the
# client side reset is cleaned up; a reset per packet is cheap, the client sends only a few before it sees the first).
# The packet that completes a handshake is a bare ACK and the second original-direction packet (needs
# net.netfilter.nf_conntrack_acct); an RST or FIN as second packet is no handshake, and a blind spoofer can send it.
meta l4proto tcp tcp flags & rst == 0 ip saddr . tcp sport . ip daddr . tcp dport @rl_hs_rejected_v4 counter reject with tcp reset
meta l4proto tcp tcp flags & rst == 0 ip6 saddr . tcp sport . ip6 daddr . tcp dport @rl_hs_rejected_v6 counter reject with tcp reset
meta l4proto tcp tcp flags & (fin | syn | rst | ack) == ack ct state established ct direction original ct original packets 2 jump ratelimit_hs_in
{% endif %}
ct state established,related accept
{% if firewall.scanners %}
@@ -258,8 +272,11 @@ table inet host {
drop
}
# The handshake is complete, so the connection has to be ended actively.
# The handshake is complete, so the connection has to be ended actively; the flow is remembered so that its later
# packets are reset without a log line (one fw-rl-hs: line per rejected connection).
chain rl_hs_reject {
meta nfproto ipv4 add @rl_hs_rejected_v4 { ip saddr . tcp sport . ip daddr . tcp dport }
meta nfproto ipv6 add @rl_hs_rejected_v6 { ip6 saddr . tcp sport . ip6 daddr . tcp dport }
limit rate {{ firewall.log.ratelimit_rate }} log prefix "fw-rl-hs: "
reject with tcp reset
}
@@ -385,7 +402,9 @@ table inet host {
# Docker-published ports and port forwards arrive here after DNAT; pod and guest egress is not DNATed.
ct status dnat ct state new jump ratelimit_fwd
meta l4proto tcp ct status dnat ct state established ct direction original ct original packets 2 jump ratelimit_hs_fwd
meta l4proto tcp ct status dnat tcp flags & rst == 0 ip saddr . tcp sport . ip daddr . tcp dport @rl_hs_rejected_v4 counter reject with tcp reset
meta l4proto tcp ct status dnat tcp flags & rst == 0 ip6 saddr . tcp sport . ip6 daddr . tcp dport @rl_hs_rejected_v6 counter reject with tcp reset
meta l4proto tcp tcp flags & (fin | syn | rst | ack) == ack ct status dnat ct state established ct direction original ct original packets 2 jump ratelimit_hs_fwd
{% endif %}
{% if firewall.spamhaus %}