Skip to content

Commit 7cd5dca

Browse files
authored
[client] Fix rule order for deny rules in peer ACLs (#4147)
1 parent 0e62325 commit 7cd5dca

File tree

10 files changed

+321
-58
lines changed

10 files changed

+321
-58
lines changed

client/firewall/iptables/acl_linux.go

Lines changed: 24 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ func (m *aclManager) AddPeerFiltering(
8585
) ([]firewall.Rule, error) {
8686
chain := chainNameInputRules
8787

88-
ipsetName = transformIPsetName(ipsetName, sPort, dPort)
88+
ipsetName = transformIPsetName(ipsetName, sPort, dPort, action)
8989
specs := filterRuleSpecs(ip, string(protocol), sPort, dPort, action, ipsetName)
9090

9191
mangleSpecs := slices.Clone(specs)
@@ -135,7 +135,14 @@ func (m *aclManager) AddPeerFiltering(
135135
return nil, fmt.Errorf("rule already exists")
136136
}
137137

138-
if err := m.iptablesClient.Append(tableFilter, chain, specs...); err != nil {
138+
// Insert DROP rules at the beginning, append ACCEPT rules at the end
139+
if action == firewall.ActionDrop {
140+
// Insert at the beginning of the chain (position 1)
141+
err = m.iptablesClient.Insert(tableFilter, chain, 1, specs...)
142+
} else {
143+
err = m.iptablesClient.Append(tableFilter, chain, specs...)
144+
}
145+
if err != nil {
139146
return nil, err
140147
}
141148

@@ -388,17 +395,25 @@ func actionToStr(action firewall.Action) string {
388395
return "DROP"
389396
}
390397

391-
func transformIPsetName(ipsetName string, sPort, dPort *firewall.Port) string {
392-
switch {
393-
case ipsetName == "":
398+
func transformIPsetName(ipsetName string, sPort, dPort *firewall.Port, action firewall.Action) string {
399+
if ipsetName == "" {
394400
return ""
401+
}
402+
403+
// Include action in the ipset name to prevent squashing rules with different actions
404+
actionSuffix := ""
405+
if action == firewall.ActionDrop {
406+
actionSuffix = "-drop"
407+
}
408+
409+
switch {
395410
case sPort != nil && dPort != nil:
396-
return ipsetName + "-sport-dport"
411+
return ipsetName + "-sport-dport" + actionSuffix
397412
case sPort != nil:
398-
return ipsetName + "-sport"
413+
return ipsetName + "-sport" + actionSuffix
399414
case dPort != nil:
400-
return ipsetName + "-dport"
415+
return ipsetName + "-dport" + actionSuffix
401416
default:
402-
return ipsetName
417+
return ipsetName + actionSuffix
403418
}
404419
}

client/firewall/iptables/manager_linux_test.go

Lines changed: 78 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package iptables
33
import (
44
"fmt"
55
"net/netip"
6+
"strings"
67
"testing"
78
"time"
89

@@ -15,7 +16,7 @@ import (
1516

1617
var ifaceMock = &iFaceMock{
1718
NameFunc: func() string {
18-
return "lo"
19+
return "wg-test"
1920
},
2021
AddressFunc: func() wgaddr.Address {
2122
return wgaddr.Address{
@@ -109,10 +110,84 @@ func TestIptablesManager(t *testing.T) {
109110
})
110111
}
111112

113+
func TestIptablesManagerDenyRules(t *testing.T) {
114+
ipv4Client, err := iptables.NewWithProtocol(iptables.ProtocolIPv4)
115+
require.NoError(t, err)
116+
117+
manager, err := Create(ifaceMock)
118+
require.NoError(t, err)
119+
require.NoError(t, manager.Init(nil))
120+
121+
defer func() {
122+
err := manager.Close(nil)
123+
require.NoError(t, err)
124+
}()
125+
126+
t.Run("add deny rule", func(t *testing.T) {
127+
ip := netip.MustParseAddr("10.20.0.3")
128+
port := &fw.Port{Values: []uint16{22}}
129+
130+
rule, err := manager.AddPeerFiltering(nil, ip.AsSlice(), "tcp", nil, port, fw.ActionDrop, "deny-ssh")
131+
require.NoError(t, err, "failed to add deny rule")
132+
require.NotEmpty(t, rule, "deny rule should not be empty")
133+
134+
// Verify the rule was added by checking iptables
135+
for _, r := range rule {
136+
rr := r.(*Rule)
137+
checkRuleSpecs(t, ipv4Client, rr.chain, true, rr.specs...)
138+
}
139+
})
140+
141+
t.Run("deny rule precedence test", func(t *testing.T) {
142+
ip := netip.MustParseAddr("10.20.0.4")
143+
port := &fw.Port{Values: []uint16{80}}
144+
145+
// Add accept rule first
146+
_, err := manager.AddPeerFiltering(nil, ip.AsSlice(), "tcp", nil, port, fw.ActionAccept, "accept-http")
147+
require.NoError(t, err, "failed to add accept rule")
148+
149+
// Add deny rule second for same IP/port - this should take precedence
150+
_, err = manager.AddPeerFiltering(nil, ip.AsSlice(), "tcp", nil, port, fw.ActionDrop, "deny-http")
151+
require.NoError(t, err, "failed to add deny rule")
152+
153+
// Inspect the actual iptables rules to verify deny rule comes before accept rule
154+
rules, err := ipv4Client.List("filter", chainNameInputRules)
155+
require.NoError(t, err, "failed to list iptables rules")
156+
157+
// Debug: print all rules
158+
t.Logf("All iptables rules in chain %s:", chainNameInputRules)
159+
for i, rule := range rules {
160+
t.Logf(" [%d] %s", i, rule)
161+
}
162+
163+
var denyRuleIndex, acceptRuleIndex int = -1, -1
164+
for i, rule := range rules {
165+
if strings.Contains(rule, "DROP") {
166+
t.Logf("Found DROP rule at index %d: %s", i, rule)
167+
if strings.Contains(rule, "deny-http") && strings.Contains(rule, "80") {
168+
denyRuleIndex = i
169+
}
170+
}
171+
if strings.Contains(rule, "ACCEPT") {
172+
t.Logf("Found ACCEPT rule at index %d: %s", i, rule)
173+
if strings.Contains(rule, "accept-http") && strings.Contains(rule, "80") {
174+
acceptRuleIndex = i
175+
}
176+
}
177+
}
178+
179+
require.NotEqual(t, -1, denyRuleIndex, "deny rule should exist in iptables")
180+
require.NotEqual(t, -1, acceptRuleIndex, "accept rule should exist in iptables")
181+
require.Less(t, denyRuleIndex, acceptRuleIndex,
182+
"deny rule should come before accept rule in iptables chain (deny at index %d, accept at index %d)",
183+
denyRuleIndex, acceptRuleIndex)
184+
})
185+
}
186+
112187
func TestIptablesManagerIPSet(t *testing.T) {
113188
mock := &iFaceMock{
114189
NameFunc: func() string {
115-
return "lo"
190+
return "wg-test"
116191
},
117192
AddressFunc: func() wgaddr.Address {
118193
return wgaddr.Address{
@@ -176,7 +251,7 @@ func checkRuleSpecs(t *testing.T, ipv4Client *iptables.IPTables, chainName strin
176251
func TestIptablesCreatePerformance(t *testing.T) {
177252
mock := &iFaceMock{
178253
NameFunc: func() string {
179-
return "lo"
254+
return "wg-test"
180255
},
181256
AddressFunc: func() wgaddr.Address {
182257
return wgaddr.Address{

client/firewall/nftables/acl_linux.go

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -341,30 +341,38 @@ func (m *AclManager) addIOFiltering(
341341
userData := []byte(ruleId)
342342

343343
chain := m.chainInputRules
344-
nftRule := m.rConn.AddRule(&nftables.Rule{
344+
rule := &nftables.Rule{
345345
Table: m.workTable,
346346
Chain: chain,
347347
Exprs: mainExpressions,
348348
UserData: userData,
349-
})
349+
}
350+
351+
// Insert DROP rules at the beginning, append ACCEPT rules at the end
352+
var nftRule *nftables.Rule
353+
if action == firewall.ActionDrop {
354+
nftRule = m.rConn.InsertRule(rule)
355+
} else {
356+
nftRule = m.rConn.AddRule(rule)
357+
}
350358

351359
if err := m.rConn.Flush(); err != nil {
352360
return nil, fmt.Errorf(flushError, err)
353361
}
354362

355-
rule := &Rule{
363+
ruleStruct := &Rule{
356364
nftRule: nftRule,
357365
mangleRule: m.createPreroutingRule(expressions, userData),
358366
nftSet: ipset,
359367
ruleID: ruleId,
360368
ip: ip,
361369
}
362-
m.rules[ruleId] = rule
370+
m.rules[ruleId] = ruleStruct
363371
if ipset != nil {
364372
m.ipsetStore.AddReferenceToIpset(ipset.Name)
365373
}
366374

367-
return rule, nil
375+
return ruleStruct, nil
368376
}
369377

370378
func (m *AclManager) createPreroutingRule(expressions []expr.Any, userData []byte) *nftables.Rule {

client/firewall/nftables/manager_linux_test.go

Lines changed: 91 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package nftables
22

33
import (
44
"bytes"
5+
"encoding/binary"
56
"fmt"
67
"net/netip"
78
"os/exec"
@@ -20,7 +21,7 @@ import (
2021

2122
var ifaceMock = &iFaceMock{
2223
NameFunc: func() string {
23-
return "lo"
24+
return "wg-test"
2425
},
2526
AddressFunc: func() wgaddr.Address {
2627
return wgaddr.Address{
@@ -103,9 +104,8 @@ func TestNftablesManager(t *testing.T) {
103104
Kind: expr.VerdictAccept,
104105
},
105106
}
106-
compareExprsIgnoringCounters(t, rules[0].Exprs, expectedExprs1)
107-
108-
expectedExprs2 := []expr.Any{
107+
// Since DROP rules are inserted at position 0, the DROP rule comes first
108+
expectedDropExprs := []expr.Any{
109109
&expr.Payload{
110110
DestRegister: 1,
111111
Base: expr.PayloadBaseNetworkHeader,
@@ -141,7 +141,12 @@ func TestNftablesManager(t *testing.T) {
141141
},
142142
&expr.Verdict{Kind: expr.VerdictDrop},
143143
}
144-
require.ElementsMatch(t, rules[1].Exprs, expectedExprs2, "expected the same expressions")
144+
145+
// Compare DROP rule at position 0 (inserted first due to InsertRule)
146+
compareExprsIgnoringCounters(t, rules[0].Exprs, expectedDropExprs)
147+
148+
// Compare connection tracking rule at position 1 (pushed down by DROP rule insertion)
149+
compareExprsIgnoringCounters(t, rules[1].Exprs, expectedExprs1)
145150

146151
for _, r := range rule {
147152
err = manager.DeletePeerRule(r)
@@ -160,10 +165,90 @@ func TestNftablesManager(t *testing.T) {
160165
require.NoError(t, err, "failed to reset")
161166
}
162167

168+
func TestNftablesManagerRuleOrder(t *testing.T) {
169+
// This test verifies rule insertion order in nftables peer ACLs
170+
// We add accept rule first, then deny rule to test ordering behavior
171+
manager, err := Create(ifaceMock)
172+
require.NoError(t, err)
173+
require.NoError(t, manager.Init(nil))
174+
175+
defer func() {
176+
err = manager.Close(nil)
177+
require.NoError(t, err)
178+
}()
179+
180+
ip := netip.MustParseAddr("100.96.0.2").Unmap()
181+
testClient := &nftables.Conn{}
182+
183+
// Add accept rule first
184+
_, err = manager.AddPeerFiltering(nil, ip.AsSlice(), fw.ProtocolTCP, nil, &fw.Port{Values: []uint16{80}}, fw.ActionAccept, "accept-http")
185+
require.NoError(t, err, "failed to add accept rule")
186+
187+
// Add deny rule second for the same traffic
188+
_, err = manager.AddPeerFiltering(nil, ip.AsSlice(), fw.ProtocolTCP, nil, &fw.Port{Values: []uint16{80}}, fw.ActionDrop, "deny-http")
189+
require.NoError(t, err, "failed to add deny rule")
190+
191+
err = manager.Flush()
192+
require.NoError(t, err, "failed to flush")
193+
194+
rules, err := testClient.GetRules(manager.aclManager.workTable, manager.aclManager.chainInputRules)
195+
require.NoError(t, err, "failed to get rules")
196+
197+
t.Logf("Found %d rules in nftables chain", len(rules))
198+
199+
// Find the accept and deny rules and verify deny comes before accept
200+
var acceptRuleIndex, denyRuleIndex int = -1, -1
201+
for i, rule := range rules {
202+
hasAcceptHTTPSet := false
203+
hasDenyHTTPSet := false
204+
hasPort80 := false
205+
var action string
206+
207+
for _, e := range rule.Exprs {
208+
// Check for set lookup
209+
if lookup, ok := e.(*expr.Lookup); ok {
210+
if lookup.SetName == "accept-http" {
211+
hasAcceptHTTPSet = true
212+
} else if lookup.SetName == "deny-http" {
213+
hasDenyHTTPSet = true
214+
}
215+
}
216+
// Check for port 80
217+
if cmp, ok := e.(*expr.Cmp); ok {
218+
if cmp.Op == expr.CmpOpEq && len(cmp.Data) == 2 && binary.BigEndian.Uint16(cmp.Data) == 80 {
219+
hasPort80 = true
220+
}
221+
}
222+
// Check for verdict
223+
if verdict, ok := e.(*expr.Verdict); ok {
224+
if verdict.Kind == expr.VerdictAccept {
225+
action = "ACCEPT"
226+
} else if verdict.Kind == expr.VerdictDrop {
227+
action = "DROP"
228+
}
229+
}
230+
}
231+
232+
if hasAcceptHTTPSet && hasPort80 && action == "ACCEPT" {
233+
t.Logf("Rule [%d]: accept-http set + Port 80 + ACCEPT", i)
234+
acceptRuleIndex = i
235+
} else if hasDenyHTTPSet && hasPort80 && action == "DROP" {
236+
t.Logf("Rule [%d]: deny-http set + Port 80 + DROP", i)
237+
denyRuleIndex = i
238+
}
239+
}
240+
241+
require.NotEqual(t, -1, acceptRuleIndex, "accept rule should exist in nftables")
242+
require.NotEqual(t, -1, denyRuleIndex, "deny rule should exist in nftables")
243+
require.Less(t, denyRuleIndex, acceptRuleIndex,
244+
"deny rule should come before accept rule in nftables chain (deny at index %d, accept at index %d)",
245+
denyRuleIndex, acceptRuleIndex)
246+
}
247+
163248
func TestNFtablesCreatePerformance(t *testing.T) {
164249
mock := &iFaceMock{
165250
NameFunc: func() string {
166-
return "lo"
251+
return "wg-test"
167252
},
168253
AddressFunc: func() wgaddr.Address {
169254
return wgaddr.Address{

client/firewall/uspfilter/allow_netbird.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ func (m *Manager) Close(stateManager *statemanager.Manager) error {
1818
defer m.mutex.Unlock()
1919

2020
m.outgoingRules = make(map[netip.Addr]RuleSet)
21+
m.incomingDenyRules = make(map[netip.Addr]RuleSet)
2122
m.incomingRules = make(map[netip.Addr]RuleSet)
2223

2324
if m.udpTracker != nil {

client/firewall/uspfilter/allow_netbird_windows.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ func (m *Manager) Close(*statemanager.Manager) error {
2727
defer m.mutex.Unlock()
2828

2929
m.outgoingRules = make(map[netip.Addr]RuleSet)
30+
m.incomingDenyRules = make(map[netip.Addr]RuleSet)
3031
m.incomingRules = make(map[netip.Addr]RuleSet)
3132

3233
if m.udpTracker != nil {

0 commit comments

Comments
 (0)