Skip to content

Commit 80923a6

Browse files
committed
Phase 1 security hardening: trust gate for datagrams, SYN ordering
Security fixes: - Add trust gate to handleDatagramPacket (private nodes drop untrusted datagrams, matching SYN gate behavior) - Move trust check before rate limiting so untrusted sources cannot waste rate-limit tokens - Silent drop on untrusted SYN instead of RST to prevent leaking node existence - Normalize hostname resolve errors (denied returns same message as not-found to prevent enumeration) Tests: - Unskip 5 network tests (persistence, replication, broadcast exclusion, network create/join/leave via client) - Add 4 new trust gate tests (public node accepts untrusted SYN, datagram rejected from untrusted, datagram allowed from same network, datagram rejection webhook)
1 parent 2d7346c commit 80923a6

7 files changed

Lines changed: 257 additions & 32 deletions

File tree

pkg/daemon/daemon.go

Lines changed: 46 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -741,6 +741,26 @@ func (d *Daemon) handleStreamPacket(pkt *protocol.Packet) {
741741
return
742742
}
743743

744+
// Trust gate: private nodes only accept SYN from trusted or same-network peers.
745+
// Runs before rate limiting so untrusted sources cannot waste rate-limit tokens.
746+
if !d.config.Public {
747+
srcNode := pkt.Src.Node
748+
trusted := d.handshakes.IsTrusted(srcNode)
749+
if !trusted && d.regConn != nil {
750+
// Fall back to registry trust check (covers admin-set trust pairs + shared networks)
751+
trusted, _ = d.regConn.CheckTrust(d.NodeID(), srcNode)
752+
}
753+
if !trusted {
754+
slog.Warn("SYN rejected: untrusted source", "src_node", srcNode, "src_addr", pkt.Src, "dst_port", pkt.DstPort)
755+
d.webhook.Emit("syn.rejected", map[string]interface{}{
756+
"src_node_id": srcNode,
757+
"src_addr": pkt.Src.String(),
758+
"dst_port": pkt.DstPort,
759+
})
760+
return // silent drop — no RST to avoid leaking node existence
761+
}
762+
}
763+
744764
// SYN rate limiting
745765
if !d.allowSYN() {
746766
slog.Warn("SYN rate limit exceeded", "src_addr", pkt.Src, "src_port", pkt.SrcPort)
@@ -768,26 +788,6 @@ func (d *Daemon) handleStreamPacket(pkt *protocol.Packet) {
768788
return
769789
}
770790

771-
// Trust gate: private nodes only accept SYN from trusted or same-network peers
772-
if !d.config.Public {
773-
srcNode := pkt.Src.Node
774-
trusted := d.handshakes.IsTrusted(srcNode)
775-
if !trusted && d.regConn != nil {
776-
// Fall back to registry trust check (covers admin-set trust pairs + shared networks)
777-
trusted, _ = d.regConn.CheckTrust(d.NodeID(), srcNode)
778-
}
779-
if !trusted {
780-
slog.Warn("SYN rejected: untrusted source", "src_node", srcNode, "src_addr", pkt.Src, "dst_port", pkt.DstPort)
781-
d.webhook.Emit("syn.rejected", map[string]interface{}{
782-
"src_node_id": srcNode,
783-
"src_addr": pkt.Src.String(),
784-
"dst_port": pkt.DstPort,
785-
})
786-
d.sendRST(pkt)
787-
return
788-
}
789-
}
790-
791791
conn := d.ports.NewConnection(pkt.DstPort, pkt.Src, pkt.SrcPort)
792792
conn.Mu.Lock()
793793
conn.LocalAddr = d.Addr()
@@ -1056,13 +1056,33 @@ func (d *Daemon) sendDelayedACK(conn *Connection) {
10561056
}
10571057

10581058
func (d *Daemon) handleDatagramPacket(pkt *protocol.Packet) {
1059-
if len(pkt.Payload) > 0 {
1060-
d.webhook.Emit("data.datagram", map[string]interface{}{
1061-
"src_addr": pkt.Src.String(), "src_port": pkt.SrcPort,
1062-
"dst_port": pkt.DstPort, "size": len(pkt.Payload),
1063-
})
1064-
d.ipc.DeliverDatagram(pkt.Src, pkt.SrcPort, pkt.DstPort, pkt.Payload)
1059+
if len(pkt.Payload) == 0 {
1060+
return
10651061
}
1062+
1063+
// Trust gate: private nodes only accept datagrams from trusted or same-network peers
1064+
if !d.config.Public {
1065+
srcNode := pkt.Src.Node
1066+
trusted := d.handshakes.IsTrusted(srcNode)
1067+
if !trusted && d.regConn != nil {
1068+
trusted, _ = d.regConn.CheckTrust(d.NodeID(), srcNode)
1069+
}
1070+
if !trusted {
1071+
slog.Warn("datagram rejected: untrusted source", "src_node", srcNode, "src_addr", pkt.Src, "dst_port", pkt.DstPort)
1072+
d.webhook.Emit("datagram.rejected", map[string]interface{}{
1073+
"src_node_id": srcNode,
1074+
"src_addr": pkt.Src.String(),
1075+
"dst_port": pkt.DstPort,
1076+
})
1077+
return
1078+
}
1079+
}
1080+
1081+
d.webhook.Emit("data.datagram", map[string]interface{}{
1082+
"src_addr": pkt.Src.String(), "src_port": pkt.SrcPort,
1083+
"dst_port": pkt.DstPort, "size": len(pkt.Payload),
1084+
})
1085+
d.ipc.DeliverDatagram(pkt.Src, pkt.SrcPort, pkt.DstPort, pkt.Payload)
10661086
}
10671087

10681088
func (d *Daemon) handleControlPacket(pkt *protocol.Packet) {

pkg/registry/server.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1941,7 +1941,7 @@ func (s *Server) handleResolveHostname(msg map[string]interface{}) (map[string]i
19411941
}
19421942

19431943
if !allowed {
1944-
return nil, fmt.Errorf("resolve denied: hostname %q belongs to a private node", hostname)
1944+
return nil, fmt.Errorf("hostname %q not found", hostname) // same error as non-existent to prevent enumeration
19451945
}
19461946
}
19471947

tests/datagram_test.go

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,6 @@ func TestUnicastDatagram(t *testing.T) {
5757

5858
// TestBroadcastExcludesSender verifies the broadcaster does not receive its own broadcast.
5959
func TestBroadcastExcludesSender(t *testing.T) {
60-
t.Skip("custom networks are WIP")
6160
t.Parallel()
6261
env := NewTestEnv(t)
6362

tests/fuzz_registry_server_test.go

Lines changed: 60 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -724,11 +724,69 @@ func TestRegistryClientNetworkCreateNoToken(t *testing.T) {
724724
}
725725

726726
func TestRegistryClientNetworkCreateWithToken(t *testing.T) {
727-
t.Skip("custom networks are WIP")
727+
s := startTestServer(t)
728+
s.SetAdminToken(TestAdminToken)
729+
defer s.Close()
730+
731+
addr := s.Addr().(*net.TCPAddr)
732+
c, err := registry.Dial(addr.String())
733+
if err != nil {
734+
t.Fatalf("Dial: %v", err)
735+
}
736+
defer c.Close()
737+
738+
nodeID := regTestNodeWithKey(t, c, "127.0.0.1:4000")
739+
740+
resp, err := c.CreateNetwork(nodeID, "tokennet", "open", "", TestAdminToken)
741+
if err != nil {
742+
t.Fatalf("CreateNetwork with token: %v", err)
743+
}
744+
netID, ok := resp["network_id"].(float64)
745+
if !ok || netID < 1 {
746+
t.Fatalf("expected valid network_id, got %v", resp["network_id"])
747+
}
748+
t.Logf("created network %d", int(netID))
728749
}
729750

730751
func TestRegistryClientNetworkJoinLeave(t *testing.T) {
731-
t.Skip("custom networks are WIP")
752+
s := startTestServer(t)
753+
s.SetAdminToken(TestAdminToken)
754+
defer s.Close()
755+
756+
addr := s.Addr().(*net.TCPAddr)
757+
c1, err := registry.Dial(addr.String())
758+
if err != nil {
759+
t.Fatalf("Dial c1: %v", err)
760+
}
761+
defer c1.Close()
762+
c2, err := registry.Dial(addr.String())
763+
if err != nil {
764+
t.Fatalf("Dial c2: %v", err)
765+
}
766+
defer c2.Close()
767+
768+
nodeID1 := regTestNodeWithKey(t, c1, "127.0.0.1:4001")
769+
nodeID2 := regTestNodeWithKey(t, c2, "127.0.0.1:4002")
770+
771+
// Create network with node1
772+
resp, err := c1.CreateNetwork(nodeID1, "joinleave", "open", "", TestAdminToken)
773+
if err != nil {
774+
t.Fatalf("CreateNetwork: %v", err)
775+
}
776+
netID := uint16(resp["network_id"].(float64))
777+
778+
// Node2 joins
779+
_, err = c2.JoinNetwork(nodeID2, netID, "", 0, TestAdminToken)
780+
if err != nil {
781+
t.Fatalf("JoinNetwork: %v", err)
782+
}
783+
784+
// Node2 leaves
785+
_, err = c2.LeaveNetwork(nodeID2, netID, TestAdminToken)
786+
if err != nil {
787+
t.Fatalf("LeaveNetwork: %v", err)
788+
}
789+
t.Logf("node %d joined and left network %d", nodeID2, netID)
732790
}
733791

734792
func TestRegistryClientListNetworks(t *testing.T) {

tests/persistence_test.go

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ import (
1111
)
1212

1313
func TestRegistryPersistence(t *testing.T) {
14-
t.Skip("custom networks are WIP")
1514
t.Parallel()
1615
tmpDir, err := os.MkdirTemp("/tmp", "w4-persist-")
1716
if err != nil {

tests/replication_test.go

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@ import (
1616
// 3. Verify standby has the same data
1717
// 4. Verify standby rejects writes
1818
func TestRegistryReplication(t *testing.T) {
19-
t.Skip("custom networks are WIP")
2019
t.Parallel()
2120
tmpDir, err := os.MkdirTemp("/tmp", "w4-repl-")
2221
if err != nil {

tests/syn_trust_gate_test.go

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,3 +195,153 @@ func TestSYNRejectionWebhook(t *testing.T) {
195195
data, _ := ev.Data.(map[string]interface{})
196196
t.Logf("syn.rejected: src_node=%v", data["src_node_id"])
197197
}
198+
199+
// TestPublicNodeAcceptsUntrustedSYN verifies that public nodes skip the trust
200+
// gate entirely — any node can connect without trust or shared network.
201+
func TestPublicNodeAcceptsUntrustedSYN(t *testing.T) {
202+
t.Parallel()
203+
env := NewTestEnv(t)
204+
205+
server := env.AddDaemon(func(cfg *daemon.Config) {
206+
cfg.Public = true // public node — trust gate should be skipped
207+
})
208+
client := env.AddDaemon(func(cfg *daemon.Config) {
209+
cfg.Public = false // private, no trust, no shared network
210+
})
211+
212+
client.Daemon.AddTunnelPeer(server.Daemon.NodeID(), localUDPAddr(server.Daemon))
213+
server.Daemon.AddTunnelPeer(client.Daemon.NodeID(), localUDPAddr(client.Daemon))
214+
215+
conn, err := client.Driver.DialAddr(server.Daemon.Addr(), 7)
216+
if err != nil {
217+
t.Fatalf("expected dial to public node to succeed without trust: %v", err)
218+
}
219+
conn.Close()
220+
t.Log("dial to public node succeeded without trust")
221+
}
222+
223+
// TestDatagramRejectedFromUntrustedNode verifies that private nodes drop
224+
// datagrams from untrusted sources (same trust gate as SYN).
225+
func TestDatagramRejectedFromUntrustedNode(t *testing.T) {
226+
t.Parallel()
227+
env := NewTestEnv(t)
228+
229+
server := env.AddDaemon(func(cfg *daemon.Config) {
230+
cfg.Public = false
231+
})
232+
client := env.AddDaemon(func(cfg *daemon.Config) {
233+
cfg.Public = false
234+
})
235+
236+
// Pre-populate tunnel peers
237+
client.Daemon.AddTunnelPeer(server.Daemon.NodeID(), localUDPAddr(server.Daemon))
238+
server.Daemon.AddTunnelPeer(client.Daemon.NodeID(), localUDPAddr(client.Daemon))
239+
240+
// Start listening for datagrams on server
241+
received := make(chan struct{}, 1)
242+
go func() {
243+
dg, err := server.Driver.RecvFrom()
244+
if err == nil && dg != nil {
245+
received <- struct{}{}
246+
}
247+
}()
248+
249+
// Send datagram from untrusted client — should be silently dropped
250+
_ = client.Driver.SendTo(server.Daemon.Addr(), 5000, []byte("untrusted datagram"))
251+
252+
select {
253+
case <-received:
254+
t.Fatal("datagram from untrusted node should have been dropped, but was delivered")
255+
case <-time.After(500 * time.Millisecond):
256+
t.Log("correctly: datagram from untrusted node was dropped")
257+
}
258+
}
259+
260+
// TestDatagramAllowedFromSameNetwork verifies that datagrams from same-network
261+
// peers are delivered normally (trust via shared network membership).
262+
func TestDatagramAllowedFromSameNetwork(t *testing.T) {
263+
t.Parallel()
264+
env := NewTestEnv(t)
265+
266+
server := env.AddDaemon(func(cfg *daemon.Config) {
267+
cfg.Public = false
268+
})
269+
client := env.AddDaemon(func(cfg *daemon.Config) {
270+
cfg.Public = false
271+
})
272+
273+
// Join both to the same custom network via registry
274+
rc, err := registry.Dial(env.RegistryAddr)
275+
if err != nil {
276+
t.Fatalf("dial registry: %v", err)
277+
}
278+
defer rc.Close()
279+
280+
resp, err := rc.CreateNetwork(server.Daemon.NodeID(), "dg-trust-net", "open", "", env.AdminToken)
281+
if err != nil {
282+
t.Fatalf("create network: %v", err)
283+
}
284+
netID := uint16(resp["network_id"].(float64))
285+
286+
if _, err := rc.JoinNetwork(client.Daemon.NodeID(), netID, "", 0, env.AdminToken); err != nil {
287+
t.Fatalf("join network: %v", err)
288+
}
289+
290+
// Pre-populate tunnel peers
291+
client.Daemon.AddTunnelPeer(server.Daemon.NodeID(), localUDPAddr(server.Daemon))
292+
server.Daemon.AddTunnelPeer(client.Daemon.NodeID(), localUDPAddr(client.Daemon))
293+
294+
// Start listening for datagrams on server
295+
received := make(chan string, 1)
296+
go func() {
297+
dg, err := server.Driver.RecvFrom()
298+
if err == nil && dg != nil {
299+
received <- string(dg.Data)
300+
}
301+
}()
302+
303+
// Send datagram from trusted client — should be delivered
304+
if err := client.Driver.SendTo(server.Daemon.Addr(), 5000, []byte("trusted datagram")); err != nil {
305+
t.Fatalf("sendto: %v", err)
306+
}
307+
308+
select {
309+
case msg := <-received:
310+
if msg != "trusted datagram" {
311+
t.Errorf("expected %q, got %q", "trusted datagram", msg)
312+
}
313+
t.Log("datagram from same-network node delivered correctly")
314+
case <-time.After(5 * time.Second):
315+
t.Fatal("timeout waiting for datagram from same-network node")
316+
}
317+
}
318+
319+
// TestDatagramRejectionWebhook verifies that a datagram.rejected webhook event
320+
// is emitted when an untrusted node sends a datagram to a private node.
321+
func TestDatagramRejectionWebhook(t *testing.T) {
322+
t.Parallel()
323+
collector := newWebhookCollector()
324+
defer collector.Close()
325+
326+
env := NewTestEnv(t)
327+
328+
server := env.AddDaemon(func(cfg *daemon.Config) {
329+
cfg.Public = false
330+
cfg.WebhookURL = collector.URL()
331+
})
332+
client := env.AddDaemon(func(cfg *daemon.Config) {
333+
cfg.Public = false
334+
})
335+
336+
client.Daemon.AddTunnelPeer(server.Daemon.NodeID(), localUDPAddr(server.Daemon))
337+
338+
// Send datagram — should be rejected
339+
_ = client.Driver.SendTo(server.Daemon.Addr(), 5000, []byte("should be rejected"))
340+
341+
ev, ok := collector.WaitFor("datagram.rejected", 3*time.Second)
342+
if !ok {
343+
t.Fatal("expected datagram.rejected webhook event, but none received")
344+
}
345+
data, _ := ev.Data.(map[string]interface{})
346+
t.Logf("datagram.rejected: src_node=%v", data["src_node_id"])
347+
}

0 commit comments

Comments
 (0)