diff --git a/host_firewall/templates/host.nft.j2 b/host_firewall/templates/host.nft.j2 index 5c56870..e0ee024 100644 --- a/host_firewall/templates/host.nft.j2 +++ b/host_firewall/templates/host.nft.j2 @@ -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 %}