Skip to content

Commit f09a320

Browse files
committed
security: audit fixes and threat model update
1 parent 5b59532 commit f09a320

10 files changed

Lines changed: 100 additions & 46 deletions

File tree

bridge.go

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -45,9 +45,15 @@ func newBridge() http.Handler {
4545
return
4646
}
4747

48-
c, err := websocket.Accept(w, r, &websocket.AcceptOptions{
49-
InsecureSkipVerify: true,
50-
})
48+
opts := &websocket.AcceptOptions{}
49+
if cfg.WSAllowedOrigins != "" {
50+
for _, o := range strings.Split(cfg.WSAllowedOrigins, ",") {
51+
if o = strings.TrimSpace(o); o != "" {
52+
opts.OriginPatterns = append(opts.OriginPatterns, o)
53+
}
54+
}
55+
}
56+
c, err := websocket.Accept(w, r, opts)
5157
if err != nil {
5258
return
5359
}

docs/flows.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ sequenceDiagram
5050
H-->>U: 201 {success: true}
5151
```
5252

53-
**Source:** [`auth.go:11-51`](../auth.go) | [`crypto.go:29-43`](../crypto.go) | [`respond.go:50-64`](../respond.go)
53+
**Source:** [`auth.go:11-51`](../auth.go) | [`crypto.go:29-43`](../crypto.go) | [`respond.go:18-26`](../respond.go)
5454

5555
---
5656

@@ -112,7 +112,7 @@ sequenceDiagram
112112
SS->>SS: enforceSessionLimit() — max 3 per user
113113
SS-->>H: sessionId
114114
115-
H->>H: signToken(access) + signToken(refresh, gen=0)
115+
H->>H: signToken(access) + signRefreshToken(refresh, gen=0)
116116
Note over H: Access: {uid, sid, typ:"access", exp:+15min}<br/>Refresh: {uid, sid, typ:"refresh", gen:0, exp:+7d}<br/>Signed with separate secrets (HS256)
117117
118118
H->>U: Set-Cookie: access_token (HttpOnly, Secure, SameSite=Strict)

docs/threat-model.md

Lines changed: 51 additions & 26 deletions
Large diffs are not rendered by default.

internal/ctl/ctl_actions.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -308,7 +308,7 @@ func (m *model) closeTail() {
308308
m.tailKeepaliveStop = nil
309309
}
310310
if m.tailConn != nil {
311-
unsub := api.WSUnsubscribeRequest{Type: "unsubscribe_events", ID: "tail-2"}
311+
unsub := api.WSUnsubscribeRequest{Type: "unsubscribe_events", ID: "tail-1"}
312312
data, _ := json.Marshal(unsub)
313313
_ = m.tailConn.Write(context.Background(), websocket.MessageText, data)
314314
m.tailConn.Close(websocket.StatusNormalClosure, "client disconnected")

node.go

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,11 @@ func lookupNodeForAgent(agentCredID int) int {
6161
name = fmt.Sprintf("node-%d", agentCredID)
6262
}
6363

64-
meshIP := nextMeshIP()
64+
meshIP, err := nextMeshIP()
65+
if err != nil {
66+
slog.Error("mesh full, cannot auto-create node", "agent_id", agentCredID)
67+
return 0
68+
}
6569
_, err = store.Exec(
6670
"INSERT INTO node (label, wg_pubkey, allowed_ips, agent_credential_id) VALUES (?,?,?,?)",
6771
name, "pending", meshIP, agentCredID,
@@ -77,12 +81,12 @@ func lookupNodeForAgent(agentCredID int) int {
7781
}
7882

7983
// nextMeshIP finds the next available IP in the 10.0.0.0/24 mesh.
80-
// 10.0.0.1 is reserved for the server.
81-
func nextMeshIP() string {
84+
// 10.0.0.1 is reserved for the server. Returns an error if all 253 addresses are exhausted.
85+
func nextMeshIP() (string, error) {
8286
var maxOctet int
8387
rows, err := store.Query("SELECT allowed_ips FROM node")
8488
if err != nil {
85-
return "10.0.0.2/32"
89+
return "10.0.0.2/32", nil
8690
}
8791
defer rows.Close()
8892

@@ -108,9 +112,9 @@ func nextMeshIP() string {
108112
next = 2 // 10.0.0.1 is the server
109113
}
110114
if next > 254 {
111-
next = 254
115+
return "", fmt.Errorf("mesh full: all 253 addresses in use")
112116
}
113-
return fmt.Sprintf("10.0.0.%d/32", next)
117+
return fmt.Sprintf("10.0.0.%d/32", next), nil
114118
}
115119

116120
// handleWGStatus processes a wg.status message from a node agent.

node_test.go

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,10 @@ func TestNextMeshIP(t *testing.T) {
6969
t.Fatalf("insert node 2: %v", err)
7070
}
7171

72-
got := nextMeshIP()
72+
got, err := nextMeshIP()
73+
if err != nil {
74+
t.Fatalf("nextMeshIP() unexpected error: %v", err)
75+
}
7376
if got != "10.0.0.4/32" {
7477
t.Errorf("nextMeshIP() = %q, want %q", got, "10.0.0.4/32")
7578
}
@@ -79,7 +82,10 @@ func TestNextMeshIP_SkipsServer(t *testing.T) {
7982
nodeTestSetup(t)
8083

8184
// No nodes in the database yet. First call should skip .1 (server) and return .2.
82-
got := nextMeshIP()
85+
got, err := nextMeshIP()
86+
if err != nil {
87+
t.Fatalf("nextMeshIP() unexpected error: %v", err)
88+
}
8389
if got != "10.0.0.2/32" {
8490
t.Errorf("nextMeshIP() = %q, want %q", got, "10.0.0.2/32")
8591
}

ops.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,10 @@ func handleOpsAgentCreate(w http.ResponseWriter, r *http.Request) {
116116
jsonError(w, 400, "VALIDATION_ERROR", "Name required")
117117
return
118118
}
119+
if !validLabel(body.Name) {
120+
jsonError(w, 400, "VALIDATION_ERROR", "Name must be 1-32 alphanumeric or hyphen characters")
121+
return
122+
}
119123

120124
apiKey := randomHex(32) // 256-bit key
121125
keyHash := hashAPIKey(apiKey)

respond.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,7 @@ func setTokenCookies(w http.ResponseWriter, uid int, sid string) error {
126126
if err != nil {
127127
return err
128128
}
129-
refresh, err := signToken(uid, sid, "refresh", cfg.RefreshSecret, refreshExpiry)
129+
refresh, err := signRefreshToken(uid, sid, cfg.RefreshSecret, 0) // gen=0 matches initial session refresh_gen
130130
if err != nil {
131131
return err
132132
}

ws.go

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -83,9 +83,15 @@ func handleAgentWS(w http.ResponseWriter, r *http.Request) {
8383
return
8484
}
8585

86-
c, err := websocket.Accept(w, r, &websocket.AcceptOptions{
87-
InsecureSkipVerify: true,
88-
})
86+
opts := &websocket.AcceptOptions{}
87+
if cfg.WSAllowedOrigins != "" {
88+
for _, o := range strings.Split(cfg.WSAllowedOrigins, ",") {
89+
if o = strings.TrimSpace(o); o != "" {
90+
opts.OriginPatterns = append(opts.OriginPatterns, o)
91+
}
92+
}
93+
}
94+
c, err := websocket.Accept(w, r, opts)
8995
if err != nil {
9096
return
9197
}

ws_events.go

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

33
import (
44
"encoding/json"
5+
"log/slog"
56
"strings"
67
"sync"
78
"time"
@@ -70,7 +71,8 @@ func handleWSQueryEvents(conn *wsConn, id string, payload json.RawMessage) {
7071
"SELECT type, COUNT(*) FROM security_event WHERE created_at >= ? GROUP BY type", since,
7172
)
7273
if err != nil {
73-
sendWSError(conn, id, "QUERY_ERROR", err.Error())
74+
slog.Error("ws query_events aggregate", "error", err)
75+
sendWSError(conn, id, "QUERY_ERROR", "Query failed")
7476
return
7577
}
7678
defer rows.Close()
@@ -102,7 +104,8 @@ func handleWSQueryEvents(conn *wsConn, id string, payload json.RawMessage) {
102104
args...,
103105
)
104106
if err != nil {
105-
sendWSError(conn, id, "QUERY_ERROR", err.Error())
107+
slog.Error("ws query_events", "error", err)
108+
sendWSError(conn, id, "QUERY_ERROR", "Query failed")
106109
return
107110
}
108111
defer rows.Close()

0 commit comments

Comments
 (0)