Skip to content

Commit d4ec11a

Browse files
committed
Harden registry HTTP surface: SSRF defense, WAL, snapshot validation
- Extract SSRF validator into pkg/urlvalidate and apply to every registry URL surface (IDP config, webhook target, snapshot restore). - Match cloud metadata hostnames case-insensitively; prior check bypassed when the attacker uppercased a segment. - Validate URLs when restoring snapshots so a tainted snapshot can't smuggle a malicious IDP/webhook URL on startup. - Cap crypto-map growth and short-circuit before scalar-mult when the unauth map is already at the bound. - Add SPDX headers to all registry sources; tighten tests for the new validator paths.
1 parent 8476ad1 commit d4ec11a

38 files changed

Lines changed: 842 additions & 74 deletions

pkg/registry/audit_export.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
// SPDX-License-Identifier: AGPL-3.0-or-later
2+
13
package registry
24

35
import (

pkg/registry/audit_export_test.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
// SPDX-License-Identifier: AGPL-3.0-or-later
2+
13
package registry
24

35
import (
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+
package registry
4+
5+
import (
6+
"testing"
7+
)
8+
9+
// TestDecidePoloGate verifies the pure gate math behind the
10+
// registry-side authorize_task_submit endpoint. Replaces the gate-math
11+
// portion of test_task_polo_gate.sh — docker tier still covers the
12+
// end-to-end "rejection cites polo" wire behavior, but a Go unit test
13+
// covers every decision branch in milliseconds.
14+
func TestDecidePoloGate(t *testing.T) {
15+
intp := func(n int) *int { return &n }
16+
cases := []struct {
17+
name string
18+
subPolo int
19+
recPolo int
20+
minPolo *int
21+
wantAllowed bool
22+
}{
23+
{"equal_zero_allows", 0, 0, nil, true},
24+
{"submitter_higher_allows", 5, 0, nil, true},
25+
{"submitter_lower_denies", -1, 0, nil, false},
26+
{"equal_nonzero_allows", 7, 7, nil, true},
27+
28+
{"min_met_allows", 10, 0, intp(5), true},
29+
{"min_equal_allows", 5, 0, intp(5), true},
30+
{"min_unmet_denies", 4, 0, intp(5), false},
31+
{"min_zero_allows", 0, 0, intp(0), true},
32+
33+
// Guarantee floor takes precedence over the receiver's own polo.
34+
{"min_beats_receiver_rule_allow", 20, 100, intp(10), true},
35+
{"min_beats_receiver_rule_deny", 5, 0, intp(10), false},
36+
}
37+
for _, tc := range cases {
38+
t.Run(tc.name, func(t *testing.T) {
39+
allowed, reason := decidePoloGate(tc.subPolo, tc.recPolo, tc.minPolo)
40+
if allowed != tc.wantAllowed {
41+
t.Fatalf("allowed=%v want=%v (sub=%d rec=%d min=%v) reason=%q",
42+
allowed, tc.wantAllowed, tc.subPolo, tc.recPolo, tc.minPolo, reason)
43+
}
44+
if !allowed {
45+
if reason == "" {
46+
t.Fatal("deny must return a non-empty reason")
47+
}
48+
// Privacy: reason must NOT leak numeric polo values.
49+
if containsDigit(reason) && tc.minPolo == nil {
50+
t.Fatalf("reason leaks polo numbers: %q", reason)
51+
}
52+
}
53+
})
54+
}
55+
}
56+
57+
func containsDigit(s string) bool {
58+
for _, r := range s {
59+
if r >= '0' && r <= '9' {
60+
return true
61+
}
62+
}
63+
return false
64+
}

pkg/registry/binary_client.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
// SPDX-License-Identifier: AGPL-3.0-or-later
2+
13
package registry
24

35
import (
@@ -123,7 +125,9 @@ func (c *BinaryClient) heartbeatLocked(nodeID uint32, sig []byte) (int64, bool,
123125
return 0, false, fmt.Errorf("send heartbeat: %w", err)
124126
}
125127

128+
c.conn.SetReadDeadline(time.Now().Add(30 * time.Second))
126129
msgType, payload, err := wireReadFrame(c.conn)
130+
c.conn.SetReadDeadline(time.Time{})
127131
if err != nil {
128132
return 0, false, fmt.Errorf("recv heartbeat: %w", err)
129133
}
@@ -158,7 +162,9 @@ func (c *BinaryClient) lookupLocked(nodeID uint32) (*WireLookupResult, error) {
158162
return nil, fmt.Errorf("send lookup: %w", err)
159163
}
160164

165+
c.conn.SetReadDeadline(time.Now().Add(30 * time.Second))
161166
msgType, payload, err := wireReadFrame(c.conn)
167+
c.conn.SetReadDeadline(time.Time{})
162168
if err != nil {
163169
return nil, fmt.Errorf("recv lookup: %w", err)
164170
}
@@ -197,7 +203,9 @@ func (c *BinaryClient) resolveLocked(nodeID, requesterID uint32, sig []byte) (*W
197203
return nil, fmt.Errorf("send resolve: %w", err)
198204
}
199205

206+
c.conn.SetReadDeadline(time.Now().Add(30 * time.Second))
200207
msgType, payload, err := wireReadFrame(c.conn)
208+
c.conn.SetReadDeadline(time.Time{})
201209
if err != nil {
202210
return nil, fmt.Errorf("recv resolve: %w", err)
203211
}
@@ -242,7 +250,9 @@ func (c *BinaryClient) sendJSONLocked(msg map[string]interface{}) (map[string]in
242250
return nil, fmt.Errorf("send: %w", err)
243251
}
244252

253+
c.conn.SetReadDeadline(time.Now().Add(30 * time.Second))
245254
msgType, payload, readErr := wireReadFrame(c.conn)
255+
c.conn.SetReadDeadline(time.Time{})
246256
if readErr != nil {
247257
return nil, fmt.Errorf("recv: %w", readErr)
248258
}

pkg/registry/binary_client_test.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
// SPDX-License-Identifier: AGPL-3.0-or-later
2+
13
package registry
24

35
import (

pkg/registry/client.go

Lines changed: 44 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
// SPDX-License-Identifier: AGPL-3.0-or-later
2+
13
package registry
24

35
import (
@@ -159,7 +161,9 @@ func (c *Client) sendLocked(msg map[string]interface{}) (map[string]interface{},
159161
if err := writeMessage(c.conn, msg); err != nil {
160162
return nil, fmt.Errorf("send: %w", err)
161163
}
164+
c.conn.SetReadDeadline(time.Now().Add(30 * time.Second))
162165
resp, err := readMessage(c.conn)
166+
c.conn.SetReadDeadline(time.Time{})
163167
if err != nil {
164168
return nil, fmt.Errorf("recv: %w", err)
165169
}
@@ -575,12 +579,20 @@ func (c *Client) SetPoloScore(nodeID uint32, poloScore int) (map[string]interfac
575579
})
576580
}
577581

578-
// GetPoloScore retrieves the current polo score for a node.
582+
// GetPoloScore retrieves the polo score for a node. Polo is private —
583+
// callers may only read their OWN polo. The registry rejects cross-reads.
584+
// callerNodeID identifies the requester (must equal nodeID); the request
585+
// is signed via c.sign so the registry can verify ownership.
579586
func (c *Client) GetPoloScore(nodeID uint32) (int, error) {
580-
resp, err := c.Send(map[string]interface{}{
581-
"type": "get_polo_score",
582-
"node_id": nodeID,
583-
})
587+
msg := map[string]interface{}{
588+
"type": "get_polo_score",
589+
"node_id": nodeID,
590+
"caller_node_id": nodeID,
591+
}
592+
if sig := c.sign(fmt.Sprintf("get_polo_score:%d", nodeID)); sig != "" {
593+
msg["signature"] = sig
594+
}
595+
resp, err := c.Send(msg)
584596
if err != nil {
585597
return 0, err
586598
}
@@ -590,6 +602,33 @@ func (c *Client) GetPoloScore(nodeID uint32) (int, error) {
590602
return 0, fmt.Errorf("polo_score not found in response")
591603
}
592604

605+
// AuthorizeTaskSubmit asks the registry to decide whether `submitter`
606+
// may submit a task to `receiver`. The registry compares polos
607+
// internally and returns only the verdict — daemons never see another
608+
// node's polo. Optionally pass a guarantee floor via minPolo (>0) so
609+
// the receiver can require submitters to meet a minimum score.
610+
func (c *Client) AuthorizeTaskSubmit(callerNodeID, submitter, receiver uint32, minPolo int) (bool, string, error) {
611+
msg := map[string]interface{}{
612+
"type": "authorize_task_submit",
613+
"caller_node_id": callerNodeID,
614+
"submitter_node_id": submitter,
615+
"receiver_node_id": receiver,
616+
}
617+
if minPolo > 0 {
618+
msg["min_polo"] = float64(minPolo)
619+
}
620+
if sig := c.sign(fmt.Sprintf("authorize_task_submit:%d:%d:%d", callerNodeID, submitter, receiver)); sig != "" {
621+
msg["signature"] = sig
622+
}
623+
resp, err := c.Send(msg)
624+
if err != nil {
625+
return false, "", err
626+
}
627+
authorized, _ := resp["authorized"].(bool)
628+
reason, _ := resp["reason"].(string)
629+
return authorized, reason, nil
630+
}
631+
593632
// InviteToNetwork stores a pending invite for a target node to join an invite-only network.
594633
func (c *Client) InviteToNetwork(networkID uint16, inviterID, targetNodeID uint32, adminToken string) (map[string]interface{}, error) {
595634
msg := map[string]interface{}{

pkg/registry/client_iter117_test.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
// SPDX-License-Identifier: AGPL-3.0-or-later
2+
13
package registry
24

35
import (

pkg/registry/client_iter118_test.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
// SPDX-License-Identifier: AGPL-3.0-or-later
2+
13
package registry
24

35
import (

0 commit comments

Comments
 (0)